mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-28 12:32:28 +02:00
Replace Member model with UserProfile-backed data
Remove the static Member model/table, CRUD API, YAML seed files, and admin UI. Replace with UserProfile-driven members page that reads roles from OIDC identity provider. Key changes: - Drop members table, add roles column to user_profiles (Alembic migration) - Add GET /api/v1/user/profiles (paginated, no user_id exposed) - Add GET /api/v1/user/profile/me (auto-creates profile for current user) - Replace member_id node tag filter with adopted_by (profile UUID) - Members page now shows profiles grouped by operator/member roles - Profile page supports public view (/profile/:id) and owner edit (/profile) - Node detail page shows adoption card side-by-side with public key card - Auto-create user profile during OIDC login callback - Hide Adopted Nodes section for non-operator/admin users - Add member since date to profile cards - Add role badges and adopted node badges to member tiles - Add antenna/users icons to Members page group headers
This commit is contained in:
@@ -145,16 +145,14 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
tags: Mapped[list["NodeTag"]] = relationship(back_populates="node", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Member(Base, UUIDMixin, TimestampMixin):
|
||||
"""Network member model - stores info about network operators."""
|
||||
__tablename__ = "members"
|
||||
class UserProfile(Base, UUIDMixin, TimestampMixin):
|
||||
"""UserProfile model for authenticated OIDC users."""
|
||||
__tablename__ = "user_profiles"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
callsign: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
role: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
contact: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
public_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
roles: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
```
|
||||
|
||||
### FastAPI Routes
|
||||
@@ -266,12 +264,10 @@ meshcore-hub/
|
||||
│ │ ├── hash_utils.py # Hash utility functions
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ │ ├── node.py # Node model
|
||||
│ │ │ ├── member.py # Network member model
|
||||
│ │ │ ├── user_profile.py # User profile model (OIDC users)
|
||||
│ │ │ ├── user_profile_node.py # User-node adoption join table
|
||||
│ │ │ └── ...
|
||||
│ │ └── schemas/ # Pydantic schemas
|
||||
│ │ ├── members.py # Member API schemas
|
||||
│ │ ├── user_profiles.py # User profile API schemas
|
||||
│ │ └── ...
|
||||
│ ├── collector/
|
||||
@@ -281,7 +277,6 @@ meshcore-hub/
|
||||
│ │ ├── letsmesh_decoder.py # Native Python packet decoder
|
||||
│ │ ├── letsmesh_normalizer.py # LetsMesh upload topic normalizer
|
||||
│ │ ├── tag_import.py # Tag import from YAML
|
||||
│ │ ├── member_import.py # Member import from YAML
|
||||
│ │ ├── handlers/ # Event handlers
|
||||
│ │ └── webhook.py # Webhook dispatcher
|
||||
│ ├── api/
|
||||
@@ -291,8 +286,7 @@ meshcore-hub/
|
||||
│ │ ├── dependencies.py
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint
|
||||
│ │ └── routes/ # API routes
|
||||
│ │ ├── members.py # Member CRUD endpoints
|
||||
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
|
||||
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
|
||||
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
|
||||
│ │ └── ...
|
||||
│ └── web/
|
||||
@@ -342,14 +336,12 @@ meshcore-hub/
|
||||
│ └── meshcore-hub-update@.timer # Auto-update timer
|
||||
├── example/
|
||||
│ ├── seed/ # Example seed data files
|
||||
│ │ ├── node_tags.yaml # Example node tags
|
||||
│ │ └── members.yaml # Example network members
|
||||
│ │ └── node_tags.yaml # Example node tags
|
||||
│ └── content/ # Example custom content
|
||||
│ ├── pages/ # Example custom pages
|
||||
│ └── media/ # Example media files
|
||||
├── seed/ # Seed data directory (SEED_HOME)
|
||||
│ ├── node_tags.yaml # Node tags for import
|
||||
│ └── members.yaml # Network members for import
|
||||
│ └── node_tags.yaml # Node tags for import
|
||||
├── data/ # Runtime data (gitignored, DATA_HOME default)
|
||||
│ └── collector/ # Collector data
|
||||
│ └── meshcore.db # SQLite database
|
||||
@@ -410,7 +402,6 @@ Node tags are flexible key-value pairs that allow custom metadata to be attached
|
||||
|---------|-------------|-------|
|
||||
| `name` | Node display name | Used as the primary display name throughout the UI (overrides the advertised name) |
|
||||
| `description` | Short description | Displayed as supplementary text under the node name |
|
||||
| `member_id` | Member identifier reference | Links the node to a network member (matches `member_id` in Members table) |
|
||||
| `lat` | GPS latitude override | Overrides node-reported latitude for map display |
|
||||
| `lon` | GPS longitude override | Overrides node-reported longitude for map display |
|
||||
| `elevation` | GPS elevation override | Overrides node-reported elevation |
|
||||
@@ -419,7 +410,6 @@ Node tags are flexible key-value pairs that allow custom metadata to be attached
|
||||
**Important Notes:**
|
||||
- All tags are optional - nodes can function without any tags
|
||||
- Tag keys are case-sensitive
|
||||
- The `member_id` tag should reference a valid `member_id` from the Members table
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
@@ -679,8 +669,7 @@ The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does
|
||||
**Seed Data (`SEED_HOME`)** - Contains initial data files for database seeding:
|
||||
```
|
||||
${SEED_HOME}/
|
||||
├── node_tags.yaml # Node tags (keyed by public_key)
|
||||
└── members.yaml # Network members list
|
||||
└── node_tags.yaml # Node tags (keyed by public_key)
|
||||
```
|
||||
|
||||
**Custom Content (`CONTENT_HOME`)** - Custom pages and media for the web dashboard. See [docs/content.md](docs/content.md) for directory structure, frontmatter fields, and setup guide.
|
||||
@@ -696,9 +685,8 @@ Services automatically create their subdirectories if they don't exist.
|
||||
|
||||
### Seeding
|
||||
|
||||
The database can be seeded with node tags and network members from YAML files in `SEED_HOME`:
|
||||
The database can be seeded with node tags from YAML files in `SEED_HOME`:
|
||||
- `node_tags.yaml` - Node tag definitions (keyed by public_key)
|
||||
- `members.yaml` - Network member definitions
|
||||
|
||||
**Important:** Seeding is NOT automatic and must be run explicitly. This prevents seed files from overwriting user changes made via the admin UI.
|
||||
|
||||
|
||||
@@ -427,7 +427,7 @@ The web dashboard supports custom markdown pages and media files (including cust
|
||||
|
||||
## Seed Data
|
||||
|
||||
The database can be seeded with node tags and network members from YAML files. See [docs/seeding.md](docs/seeding.md) for format details, directory structure, and running the seed process.
|
||||
The database can be seeded with node tags from YAML files. See [docs/seeding.md](docs/seeding.md) for format details, directory structure, and running the seed process.
|
||||
|
||||
## API Documentation
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"""add roles to user_profiles, drop members
|
||||
|
||||
Revision ID: a7eaa878e58b
|
||||
Revises: 72b6578ee3bf
|
||||
Create Date: 2026-04-30 09:24:58.073046+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a7eaa878e58b"
|
||||
down_revision: Union[str, None] = "72b6578ee3bf"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("user_profiles", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("roles", sa.Text(), nullable=True))
|
||||
op.drop_table("members")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"members",
|
||||
sa.Column("id", sa.VARCHAR(36), nullable=False),
|
||||
sa.Column("member_id", sa.VARCHAR(100), nullable=False),
|
||||
sa.Column("name", sa.VARCHAR(255), nullable=False),
|
||||
sa.Column("callsign", sa.VARCHAR(20), nullable=True),
|
||||
sa.Column("role", sa.VARCHAR(100), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("contact", sa.VARCHAR(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("member_id"),
|
||||
)
|
||||
with op.batch_alter_table("user_profiles", schema=None) as batch_op:
|
||||
batch_op.drop_column("roles")
|
||||
+2
-2
@@ -336,7 +336,7 @@ services:
|
||||
command: ["db", "upgrade"]
|
||||
|
||||
# ==========================================================================
|
||||
# Seed Data - Import node_tags.yaml and members.yaml from SEED_HOME
|
||||
# Seed Data - Import node_tags.yaml from SEED_HOME
|
||||
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
|
||||
# Since tags are now managed via the admin UI, automatic seeding would
|
||||
# overwrite user changes.
|
||||
@@ -357,7 +357,7 @@ services:
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
# Imports both node_tags.yaml and members.yaml if they exist
|
||||
# Imports node_tags.yaml if it exists
|
||||
command: ["collector", "seed"]
|
||||
|
||||
# ==========================================================================
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
# Replace Members with UserProfile — Implementation Plan
|
||||
|
||||
**Date:** 2026-04-30
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Remove the static `Member` model/schema/routes and replace the Members page with a UserProfile-backed page. UserProfiles are auto-created from OIDC authentication and linked to nodes via the `UserProfileNode` adoption table. The new page shows two groups — Operators and Members — based on persisted OIDC roles. The `member_id` tag system used for node→member associations is replaced with UserProfile adoption data throughout nodes, advertisements, and map pages.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Roles stored on `UserProfile`** — A `roles` column (comma-separated string) is added to `user_profiles`, updated from `X-User-Roles` on each authenticated request. IdP role changes reflected on next profile access.
|
||||
2. **Profile UUID in public URLs** — Public profile links use the profile `id` (UUID), not the OIDC `user_id` subject identifier, to avoid exposing IdP identifiers.
|
||||
3. **Full Members removal** — Delete `Member` model, schemas, routes, admin page, seed import, CLI commands, tests. Drop `members` table via Alembic migration. No backward compatibility.
|
||||
4. **`FEATURE_MEMBERS` kept** — The env var still controls nav visibility, now backed by UserProfile data.
|
||||
5. **`/profile/:id` for public viewing** — Extend the existing profile route. Own profile (`/profile`) remains editable; public view (`/profile/:id`) is read-only (editable only for the owner).
|
||||
6. **Shared `_get_or_create_profile` utility** — Deduplicated from `user_profiles.py` and `adoptions.py` into a single module.
|
||||
7. **`member_id` tag system replaced with UserProfile adoptions** — The `member_id` tag on nodes (used for filtering and display in nodes, ads, and map pages) is replaced with UserProfile adoption data. Member filter dropdowns become "Adopted by user" filters.
|
||||
8. **Separate public profile schema** — A `UserProfilePublic` schema omits `user_id` from public-facing API responses.
|
||||
9. **Role names exposed in SPA config** — The server-side `OIDC_ROLE_OPERATOR` / `OIDC_ROLE_MEMBER` config values are exposed to the SPA via `window.__APP_CONFIG__.role_names` so the frontend can correctly group profiles by role.
|
||||
10. **Prometheus metrics replaced** — `meshcore_members_total` replaced with `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`.
|
||||
|
||||
## Terminology
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| Profile UUID | `user_profiles.id` — the UUID primary key, used in public URLs |
|
||||
| `user_id` | OIDC subject identifier (`sub` claim) — internal, not exposed in public APIs |
|
||||
| Operator | User with the `operator` role (from `OIDC_ROLE_OPERATOR` config) |
|
||||
| Member | User with the `member` role (from `OIDC_ROLE_MEMBER` config) |
|
||||
| Adoption | A `UserProfileNode` row linking a user to a node |
|
||||
| `member_id` tag | Legacy node tag referencing a Member slug — **removed** in this plan |
|
||||
|
||||
## Current State
|
||||
|
||||
### Member Model (`members` table)
|
||||
|
||||
```
|
||||
members
|
||||
├── id UUID PK
|
||||
├── member_id String(100) UNIQUE — human-readable slug (e.g., 'walshie86')
|
||||
├── name String(255)
|
||||
├── callsign String(20) nullable
|
||||
├── role String(100) nullable
|
||||
├── description Text nullable
|
||||
├── contact String(255) nullable
|
||||
├── created_at DateTime
|
||||
└── updated_at DateTime
|
||||
```
|
||||
|
||||
Nodes linked indirectly via `member_id` tag on `NodeTag`.
|
||||
|
||||
### UserProfile Model (`user_profiles` table)
|
||||
|
||||
```
|
||||
user_profiles
|
||||
├── id UUID PK
|
||||
├── user_id String(255) UNIQUE — OIDC subject
|
||||
├── name String(255) nullable
|
||||
├── callsign String(20) nullable
|
||||
├── created_at DateTime
|
||||
└── updated_at DateTime
|
||||
```
|
||||
|
||||
**Missing:** `roles` column.
|
||||
|
||||
Nodes linked via `UserProfileNode` join table (adoptions).
|
||||
|
||||
### `member_id` Tag Usage (to be replaced)
|
||||
|
||||
The `member_id` tag on nodes is used for filtering and display across the codebase:
|
||||
|
||||
| Location | Usage |
|
||||
|----------|-------|
|
||||
| `api/routes/nodes.py:44` | `member_id` query filter param |
|
||||
| `api/routes/advertisements.py:53` | Same `member_id` query filter |
|
||||
| `web/app.py:658-740` | Map data endpoint fetches `/api/v1/members`, builds lookup, populates `owner` |
|
||||
| `web/static/js/spa/pages/nodes.js` | Member filter dropdown, member name on node rows |
|
||||
| `web/static/js/spa/pages/advertisements.js` | Same member filter and display |
|
||||
| `web/static/js/spa/pages/map.js` | Member filter dropdown, filters by `node.member_id` |
|
||||
| `seed/node_tags.yaml` | 18 entries with `member_id` tags |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Current Auth | Notes |
|
||||
|----------|-------------|-------|
|
||||
| `GET /api/v1/members` | `RequireRead` | List all members — **to be removed** |
|
||||
| `GET /api/v1/members/{id}` | `RequireRead` | Get member by UUID — **to be removed** |
|
||||
| `POST /api/v1/members` | `RequireAdmin` | Create member — **to be removed** |
|
||||
| `PUT /api/v1/members/{id}` | `RequireAdmin` | Update member — **to be removed** |
|
||||
| `DELETE /api/v1/members/{id}` | `RequireAdmin` | Delete member — **to be removed** |
|
||||
| `GET /api/v1/user/profile/{user_id}` | `RequireUserOwner` | Owner-only, auto-creates — **to be updated** |
|
||||
| `PUT /api/v1/user/profile/{user_id}` | `RequireUserOwner` | Owner-only update — **to be updated** |
|
||||
| `POST /api/v1/adoptions` | `RequireOperatorOrAdmin` | Adopt a node — unchanged |
|
||||
| `DELETE /api/v1/adoptions/{pk}` | `RequireOperatorOrAdmin` | Release a node — unchanged |
|
||||
|
||||
### Frontend Routes
|
||||
|
||||
| Route | Page | Notes |
|
||||
|-------|------|-------|
|
||||
| `/members` | `members.js` | Public member listing — **to be rewritten** |
|
||||
| `/admin/members` | `admin/members.js` | Admin CRUD — **to be removed** |
|
||||
| `/profile` | `profile.js` | Own profile (editable) — **to be extended** |
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1: Database & Model Changes
|
||||
|
||||
#### 1.1 Add `roles` column to `UserProfile` model
|
||||
|
||||
**File:** `src/meshcore_hub/common/models/user_profile.py`
|
||||
|
||||
Add column:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, Text
|
||||
|
||||
roles: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
```
|
||||
|
||||
Add helper property:
|
||||
|
||||
```python
|
||||
@property
|
||||
def role_list(self) -> list[str]:
|
||||
if not self.roles:
|
||||
return []
|
||||
return [r.strip() for r in self.roles.split(",") if r.strip()]
|
||||
```
|
||||
|
||||
#### 1.2 Alembic migration
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
meshcore-hub db revision --autogenerate -m "add roles to user_profiles, drop members"
|
||||
```
|
||||
|
||||
Edit the generated migration to:
|
||||
- Add `roles` column (nullable text) to `user_profiles`
|
||||
- Drop `members` table
|
||||
|
||||
```bash
|
||||
meshcore-hub db upgrade
|
||||
```
|
||||
|
||||
### Phase 2: Shared Utility Refactor
|
||||
|
||||
#### 2.1 Extract `_get_or_create_profile`
|
||||
|
||||
**File:** `src/meshcore_hub/api/profile_utils.py` (new)
|
||||
|
||||
Create a shared helper module. The helper:
|
||||
|
||||
1. Looks up `UserProfile` by `user_id`
|
||||
2. If not found, creates one with `name` from `X-User-Name` header
|
||||
3. **Updates `roles`** from `X-User-Roles` header on every call
|
||||
4. Returns the profile
|
||||
|
||||
```python
|
||||
def get_or_create_profile(
|
||||
session: DbSession,
|
||||
user_id: str,
|
||||
request: Request,
|
||||
) -> UserProfile:
|
||||
query = select(UserProfile).where(UserProfile.user_id == user_id)
|
||||
profile = session.execute(query).scalar_one_or_none()
|
||||
if not profile:
|
||||
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
|
||||
profile = UserProfile(user_id=user_id, name=idp_name)
|
||||
|
||||
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
|
||||
profile.roles = roles_header or None
|
||||
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
return profile
|
||||
```
|
||||
|
||||
#### 2.2 Update consumers
|
||||
|
||||
**Files:** `api/routes/user_profiles.py`, `api/routes/adoptions.py`
|
||||
|
||||
- Remove local `_get_or_create_profile` from both files
|
||||
- Import shared helper from `api/profile_utils.py`
|
||||
- Update all call sites
|
||||
|
||||
### Phase 3: API Endpoint Changes
|
||||
|
||||
#### 3.1 New Pydantic schemas
|
||||
|
||||
**File:** `src/meshcore_hub/common/schemas/user_profiles.py`
|
||||
|
||||
```python
|
||||
class UserProfileRead(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class UserProfilePublic(BaseModel):
|
||||
"""Public-facing schema — omits user_id for privacy."""
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class UserProfileListItem(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
callsign: Optional[str] = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
node_count: int = 0
|
||||
|
||||
class UserProfileList(BaseModel):
|
||||
items: list[UserProfileListItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
class UserProfilePublicWithNodes(UserProfilePublic):
|
||||
"""Public profile view with adopted nodes."""
|
||||
nodes: list[AdoptedNodeRead] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
Update `UserProfileWithNodes` to add `roles` field (inherits from `UserProfileRead`).
|
||||
|
||||
#### 3.2 New public list endpoint
|
||||
|
||||
**File:** `src/meshcore_hub/api/routes/user_profiles.py`
|
||||
|
||||
```
|
||||
GET /api/v1/user/profiles
|
||||
```
|
||||
|
||||
- **Auth:** `RequireRead` (open if no API keys configured)
|
||||
- **Query params:** `limit` (default 100, max 500), `offset` (default 0)
|
||||
- **Response:** `UserProfileList` with `items`, `total`, `limit`, `offset`
|
||||
- Each item includes: `id`, `name`, `callsign`, `roles` (parsed list), `node_count`
|
||||
- No `user_id` exposed in list response (privacy)
|
||||
- Ordered by `name` ascending
|
||||
|
||||
**Route ordering:** This route MUST be registered before `GET /profile/{profile_id}` to avoid FastAPI matching "profiles" as a `{profile_id}` value.
|
||||
|
||||
```python
|
||||
@router.get("/profiles", response_model=UserProfileList)
|
||||
async def list_profiles(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> UserProfileList:
|
||||
```
|
||||
|
||||
#### 3.3 Update detail endpoint — support profile UUID lookup
|
||||
|
||||
**File:** `src/meshcore_hub/api/routes/user_profiles.py`
|
||||
|
||||
Change `GET /api/v1/user/profile/{profile_id}` to:
|
||||
|
||||
- Accept profile UUID instead of `user_id`
|
||||
- **Remove `_verify_owner` for GET** — anyone can view
|
||||
- Auto-create only when the caller is the authenticated owner
|
||||
- Return `UserProfilePublicWithNodes` (public schema, no `user_id`)
|
||||
- For the owner's own request, still return `UserProfileWithNodes` (includes `user_id`)
|
||||
|
||||
Two lookup strategies:
|
||||
1. If authenticated caller's `user_id` matches the profile's `user_id` → auto-create, return full profile
|
||||
2. Otherwise → lookup by profile UUID, return public view (404 if not found)
|
||||
|
||||
Auth dependency becomes optional (allow unauthenticated access).
|
||||
|
||||
#### 3.4 Update PUT endpoint
|
||||
|
||||
**File:** `src/meshcore_hub/api/routes/user_profiles.py`
|
||||
|
||||
Change `PUT /api/v1/user/profile/{profile_id}` to:
|
||||
|
||||
- Accept profile UUID
|
||||
- Keep owner + admin write restriction
|
||||
- Lookup profile by UUID, verify `profile.user_id == caller_id` (or admin)
|
||||
|
||||
#### 3.5 Replace `member_id` filter in nodes/advertisements API routes
|
||||
|
||||
**File:** `src/meshcore_hub/api/routes/nodes.py`
|
||||
|
||||
- Remove `member_id` query parameter (line 44)
|
||||
- Add optional `adopted_by` query parameter — filters nodes that are adopted by a specific user profile (by profile UUID)
|
||||
- Implementation: join `UserProfileNode` and filter by `user_profile_id`
|
||||
|
||||
**File:** `src/meshcore_hub/api/routes/advertisements.py`
|
||||
|
||||
- Remove `member_id` query parameter (line 53-54)
|
||||
- Add optional `adopted_by` query parameter — same approach as nodes
|
||||
|
||||
### Phase 4: Web App Updates
|
||||
|
||||
#### 4.1 Update endpoint access map
|
||||
|
||||
**File:** `src/meshcore_hub/web/app.py`
|
||||
|
||||
```python
|
||||
# Remove:
|
||||
"v1/members": {
|
||||
"GET": _OPEN,
|
||||
"POST": admin,
|
||||
"PUT": admin,
|
||||
"DELETE": admin,
|
||||
},
|
||||
|
||||
# Add:
|
||||
"v1/user/profiles": {
|
||||
"GET": _OPEN,
|
||||
},
|
||||
```
|
||||
|
||||
Update existing `v1/user/profile` entry to allow unauthenticated GET (remove role requirement for GET, keep for PUT).
|
||||
|
||||
#### 4.2 Update robots.txt / sitemap
|
||||
|
||||
**File:** `src/meshcore_hub/web/app.py`
|
||||
|
||||
- Keep `/members` in feature paths and sitemap (the page still exists, just backed by UserProfile data)
|
||||
|
||||
#### 4.3 Update map data endpoint
|
||||
|
||||
**File:** `src/meshcore_hub/web/app.py` (lines 658-740)
|
||||
|
||||
The `/map/data` endpoint currently:
|
||||
1. Fetches all members from `/api/v1/members`
|
||||
2. Builds a lookup by `member_id`
|
||||
3. For each node, reads `member_id` tag and resolves to member info
|
||||
4. Populates `owner` and `member_id` on each map node
|
||||
|
||||
Replace with:
|
||||
1. Fetch all user profiles from `/api/v1/user/profiles`
|
||||
2. Fetch all adoptions (or include adopted_by in node data from the nodes API)
|
||||
3. For each node, resolve adoption to user profile
|
||||
4. Populate `owner` on each map node (with `id`, `name`, `callsign` from profile)
|
||||
5. Remove `member_id` field from map node objects
|
||||
6. Remove `members_list` from map response (replace with `profiles` if needed for filter dropdown)
|
||||
|
||||
#### 4.4 Expose role names in SPA config
|
||||
|
||||
**File:** `src/meshcore_hub/web/app.py` (`_build_config_json`)
|
||||
|
||||
Add role names to the SPA config so the frontend can match profile roles to the configured operator/member/admin role values:
|
||||
|
||||
```python
|
||||
config["role_names"] = {
|
||||
"operator": settings.oidc_role_operator,
|
||||
"member": settings.oidc_role_member,
|
||||
"admin": settings.oidc_role_admin,
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Remove Members Infrastructure
|
||||
|
||||
#### 5.1 Delete files
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `src/meshcore_hub/common/models/member.py` | Member SQLAlchemy model |
|
||||
| `src/meshcore_hub/common/schemas/members.py` | Member Pydantic schemas |
|
||||
| `src/meshcore_hub/api/routes/members.py` | Member API routes |
|
||||
| `src/meshcore_hub/collector/member_import.py` | Member YAML import |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/admin/members.js` | Admin members page |
|
||||
| `seed/members.yaml` | Production member seed data |
|
||||
| `example/seed/members.yaml` | Example member seed data |
|
||||
|
||||
#### 5.2 Update `__init__` exports
|
||||
|
||||
**File:** `src/meshcore_hub/common/models/__init__.py`
|
||||
|
||||
- Remove `Member` import and export
|
||||
|
||||
**File:** `src/meshcore_hub/common/schemas/__init__.py`
|
||||
|
||||
- Remove `MemberCreate`, `MemberUpdate`, `MemberRead`, `MemberList` imports and exports
|
||||
|
||||
**File:** `src/meshcore_hub/api/routes/__init__.py`
|
||||
|
||||
- Remove `members_router` import and `include_router` call
|
||||
|
||||
#### 5.3 Update `collector/cli.py`
|
||||
|
||||
- Remove `import_members_cmd` (`import-members` command, lines 539-613)
|
||||
- Remove member import from `seed` command (lines 413-431 in `_run_seed_import`)
|
||||
- Remove `--members` flag from `truncate` command (line 705)
|
||||
- Remove `--all` member inclusion from `truncate` command (line 794)
|
||||
- Remove `Member` import from `truncate` command (line 851)
|
||||
- Remove member truncation logic (lines 867-870)
|
||||
|
||||
#### 5.4 Update `common/config.py`
|
||||
|
||||
- Remove `members_file` property (lines 184-189)
|
||||
|
||||
#### 5.5 Update `api/metrics.py`
|
||||
|
||||
- Remove `Member` import (line 17)
|
||||
- Replace `meshcore_members_total` gauge (lines 273-280) with:
|
||||
|
||||
```python
|
||||
user_profiles_total = Gauge(
|
||||
"meshcore_user_profiles_total",
|
||||
"Total number of user profiles",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(UserProfile.id))).scalar() or 0
|
||||
user_profiles_total.set(count)
|
||||
|
||||
user_profiles_by_role = Gauge(
|
||||
"meshcore_user_profiles_by_role",
|
||||
"Number of user profiles by role",
|
||||
["role"],
|
||||
registry=registry,
|
||||
)
|
||||
for row in session.execute(select(UserProfile.roles, func.count(UserProfile.id)).group_by(UserProfile.roles)):
|
||||
if row.roles:
|
||||
for role in row.roles.split(","):
|
||||
role = role.strip()
|
||||
if role:
|
||||
user_profiles_by_role.labels(role=role).set(
|
||||
session.execute(
|
||||
select(func.count(UserProfile.id)).where(UserProfile.roles.contains(role))
|
||||
).scalar() or 0
|
||||
)
|
||||
```
|
||||
|
||||
#### 5.6 Update `web/templates/spa.html`
|
||||
|
||||
- Remove admin Members nav link from admin section (both mobile and desktop menus)
|
||||
|
||||
#### 5.7 Update admin index page
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/pages/admin/index.js`
|
||||
|
||||
- Remove members admin card/link from the admin dashboard (lines 51-59)
|
||||
|
||||
#### 5.8 Update i18n
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/locales/en.json`
|
||||
|
||||
Remove:
|
||||
- `members.*` section
|
||||
- `admin_members.*` section
|
||||
- `admin.members_description` entry
|
||||
|
||||
Keep (used by the new UserProfile-backed members page):
|
||||
- `entities.members` / `entities.member` — still used for nav link and page title
|
||||
|
||||
Add:
|
||||
```json
|
||||
{
|
||||
"members_page": {
|
||||
"operators": "Operators",
|
||||
"members": "Members",
|
||||
"node_count": "{{count}} nodes",
|
||||
"empty_state": "No members yet",
|
||||
"empty_description": "Members will appear here once users log in and adopt nodes."
|
||||
},
|
||||
"user_profile": {
|
||||
"view_profile": "View Profile",
|
||||
"edit_profile": "Edit Profile",
|
||||
"role_operator": "Operator",
|
||||
"role_member": "Member"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.9 Remove `member_id` tags from seed data
|
||||
|
||||
**File:** `seed/node_tags.yaml`
|
||||
|
||||
- Remove all `member_id:` key-value entries (18 occurrences)
|
||||
|
||||
#### 5.10 Update `docker-compose.yml`
|
||||
|
||||
- Update seed service comment from "Imports both node_tags.yaml and members.yaml if they exist" to "Imports node_tags.yaml if it exists" (line 360)
|
||||
|
||||
### Phase 6: Frontend — Replace member_id References
|
||||
|
||||
#### 6.1 Update nodes page
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
|
||||
|
||||
- Remove fetch of `/api/v1/members` (line 61)
|
||||
- Remove `member_id` query param handling (line 15)
|
||||
- Remove `showMembers` flag and member filter dropdown (lines 22, 73-81)
|
||||
- Remove `memberIdTag` lookup and member name display (lines 93-96, 125-128, 148)
|
||||
- Remove "Member" column header from desktop table (line 198)
|
||||
- Optionally: add `adopted_by` filter if desired (or skip for now)
|
||||
|
||||
#### 6.2 Update advertisements page
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
|
||||
|
||||
- Same pattern as nodes: remove `/api/v1/members` fetch, `member_id` param, member filter dropdown, member display
|
||||
|
||||
#### 6.3 Update map page
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/pages/map.js`
|
||||
|
||||
- Remove member filter dropdown (lines 209-221)
|
||||
- Remove `memberFilter` logic (line 279)
|
||||
- Remove `node.member_id` filter comparison
|
||||
- The map data endpoint will now provide `owner` from UserProfile adoptions instead of `member_id` tags
|
||||
- Update owner display on map popups if applicable
|
||||
|
||||
### Phase 7: Frontend — Members Page & Profile Page
|
||||
|
||||
#### 7.1 Rewrite `members.js`
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/pages/members.js`
|
||||
|
||||
- Fetch from `GET /api/v1/user/profiles`
|
||||
- Read `config.role_names` to determine which role values map to operator/member
|
||||
- Split profiles into two groups:
|
||||
- **Operators** — where `roles` includes the operator role name, sorted alphabetically by `name`
|
||||
- **Members** — where `roles` includes the member role name (and not operator), sorted alphabetically by `name`
|
||||
- Each tile shows: `name`, `callsign` badge, `node_count` ("N nodes")
|
||||
- Tiles link to `/profile/${profile.id}`
|
||||
- Section headers: "Operators" and "Members"
|
||||
- Empty state: simple "No members yet" message
|
||||
- Layout: same responsive grid (`grid-cols-1 md:grid-cols-2 lg:grid-cols-3`)
|
||||
|
||||
#### 7.2 Update `app.js` routes
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/app.js`
|
||||
|
||||
- Remove `adminMembers` lazy import and `/admin/members` route (lines 26, 96)
|
||||
- Update `/profile` route to also handle `/profile/:id`:
|
||||
|
||||
```javascript
|
||||
router.addRoute('/profile', pageHandler(pages.profile));
|
||||
router.addRoute('/profile/:id', pageHandler(pages.profile));
|
||||
```
|
||||
|
||||
#### 7.3 Update `profile.js`
|
||||
|
||||
**File:** `src/meshcore_hub/web/static/js/spa/pages/profile.js`
|
||||
|
||||
- If `params.id` is present → public view mode:
|
||||
- Fetch `GET /api/v1/user/profile/{id}`
|
||||
- Render read-only: name, callsign, role badges, adopted nodes list
|
||||
- If the viewer owns this profile, show "Edit Profile" link to `/profile`
|
||||
- If no `params.id` → current behavior (own profile, editable)
|
||||
- Role badges rendered from `profile.roles` list, displayed with labels from `config.role_names`
|
||||
|
||||
### Phase 8: Tests
|
||||
|
||||
#### 8.1 Delete test files
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `tests/test_api/test_members.py` | Member API route tests |
|
||||
|
||||
#### 8.2 Delete test fixtures and helpers
|
||||
|
||||
| File | Fixture/Helper | Action |
|
||||
|---|---|---|
|
||||
| `tests/test_api/conftest.py` | `sample_member` (line 288) | Delete — creates `Member()` instance |
|
||||
| `tests/test_api/conftest.py` | `sample_node_with_member_tag` (line 409) | Delete — creates `member_id` tag node |
|
||||
| `tests/test_web/conftest.py` | `GET:/api/v1/members` mock response (line 154) | Delete |
|
||||
| `tests/test_web/conftest.py` | `mock_http_client_with_members` (line 466) | Delete |
|
||||
| `tests/test_web/conftest.py` | `web_app_with_members` (line 548) | Delete |
|
||||
| `tests/test_web/conftest.py` | `client_with_members` (line 568) | Delete |
|
||||
| `tests/test_web/conftest.py` | `"members": True` in features (line 20) | Keep — feature flag still used |
|
||||
|
||||
#### 8.3 Update test files — API tests
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tests/test_api/test_user_profiles.py` | Add: `GET /profiles` tests (list, pagination, roles, node_count). Add: public `GET /profile/{id}` by UUID. Add: role persistence tests. Update: existing tests for UUID-based lookup, public access. Verify `user_id` not in list response. |
|
||||
| `tests/test_api/test_nodes.py` | Remove `test_filter_by_member_id` (line 171). Add or update for `adopted_by` filter if implemented. |
|
||||
| `tests/test_api/test_advertisements.py` | Remove `test_filter_by_member_id` (line 217). Add or update for `adopted_by` filter if implemented. |
|
||||
| `tests/test_api/test_metrics.py` | Remove `meshcore_members_total` assertion (line 59). Remove `test_members_total_reflects_database` (line 150). Add tests for `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`. |
|
||||
|
||||
#### 8.4 Update test files — Web tests
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tests/test_web/test_members.py` | **Delete** — tests the old member page |
|
||||
| `tests/test_web/test_features.py` | Keep members feature flag tests (feature still exists). Update any tests that assert member-related content in sitemap/nav. |
|
||||
| `tests/test_web/test_advertisements.py` | Remove `test_advertisements_with_member_filter` (line 49) and related assertions. |
|
||||
| `tests/test_web/test_oidc.py` | Update `test_post_blocked_for_member` (line 196) — uses `POST /api/v1/members` which is being removed. Change to use a different write endpoint. |
|
||||
|
||||
#### 8.5 Update test files — Common tests
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tests/test_common/test_config.py` | Remove `test_members_file` (line 61) — property being deleted |
|
||||
| `tests/test_common/test_i18n.py` | Remove `"members"` from required sections (line 110). Remove `"admin_members"` from required sections (line 114). Keep `entities.members` assertion if it remains in en.json. |
|
||||
|
||||
#### 8.6 Run commands
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pytest tests/test_api/test_user_profiles.py -v
|
||||
pytest tests/test_api/ -v
|
||||
pytest tests/test_web/ -v
|
||||
pytest tests/test_common/ -v
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Phase 9: Documentation
|
||||
|
||||
#### 9.1 Update files
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `SCHEMAS.md` | Remove Member schemas, add `roles` to UserProfile schemas, document new endpoints (`GET /profiles`, updated `GET /profile/{id}`), remove `member_id` query param docs |
|
||||
| `docs/upgrading.md` | Breaking change: Member model/table removed, `member_id` tags no longer supported, seed files changed, Prometheus metric renamed |
|
||||
| `docs/seeding.md` | Remove member seeding section, remove `member_id` tag from seed format examples |
|
||||
| `AGENTS.md` | Update project structure (remove Member files), update env vars (remove member refs), update database conventions, remove `member_id` from standard node tags table, add `roles` to UserProfile model |
|
||||
| `README.md` | Remove member-related references, update feature descriptions |
|
||||
|
||||
---
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| # | File | Action | Phase |
|
||||
|---|------|--------|-------|
|
||||
| 1 | `common/models/user_profile.py` | Modify — add `roles` column | 1 |
|
||||
| 2 | `alembic/versions/*.py` | Create — add roles, drop members | 1 |
|
||||
| 3 | `api/profile_utils.py` | **Create** — shared `get_or_create_profile` | 2 |
|
||||
| 4 | `api/routes/user_profiles.py` | Modify — shared util, new endpoints, public access, route ordering | 3 |
|
||||
| 5 | `api/routes/adoptions.py` | Modify — shared util | 2 |
|
||||
| 6 | `common/schemas/user_profiles.py` | Modify — add `roles`, `UserProfilePublic`, list schemas | 3 |
|
||||
| 7 | `api/routes/nodes.py` | Modify — replace `member_id` filter with `adopted_by` | 3 |
|
||||
| 8 | `api/routes/advertisements.py` | Modify — replace `member_id` filter with `adopted_by` | 3 |
|
||||
| 9 | `web/app.py` | Modify — endpoint access map, sitemap, map data endpoint, SPA config role names | 4 |
|
||||
| 10 | `common/models/member.py` | **Delete** | 5 |
|
||||
| 11 | `common/schemas/members.py` | **Delete** | 5 |
|
||||
| 12 | `api/routes/members.py` | **Delete** | 5 |
|
||||
| 13 | `collector/member_import.py` | **Delete** | 5 |
|
||||
| 14 | `web/static/js/spa/pages/admin/members.js` | **Delete** | 5 |
|
||||
| 15 | `seed/members.yaml` | **Delete** | 5 |
|
||||
| 16 | `example/seed/members.yaml` | **Delete** | 5 |
|
||||
| 17 | `common/models/__init__.py` | Modify — remove Member export | 5 |
|
||||
| 18 | `common/schemas/__init__.py` | Modify — remove member schemas | 5 |
|
||||
| 19 | `api/routes/__init__.py` | Modify — remove members router | 5 |
|
||||
| 20 | `collector/cli.py` | Modify — remove member CLI commands | 5 |
|
||||
| 21 | `common/config.py` | Modify — remove `members_file` | 5 |
|
||||
| 22 | `api/metrics.py` | Modify — replace members metric with profiles metric | 5 |
|
||||
| 23 | `web/templates/spa.html` | Modify — remove admin members nav | 5 |
|
||||
| 24 | `web/static/js/spa/pages/admin/index.js` | Modify — remove members card | 5 |
|
||||
| 25 | `web/static/locales/en.json` | Modify — remove member keys, add new keys | 5 |
|
||||
| 26 | `seed/node_tags.yaml` | Modify — remove `member_id` tags | 5 |
|
||||
| 27 | `docker-compose.yml` | Modify — update seed service comment | 5 |
|
||||
| 28 | `web/static/js/spa/pages/nodes.js` | Modify — remove member filter/display | 6 |
|
||||
| 29 | `web/static/js/spa/pages/advertisements.js` | Modify — remove member filter/display | 6 |
|
||||
| 30 | `web/static/js/spa/pages/map.js` | Modify — remove member filter, use adoption data | 6 |
|
||||
| 31 | `web/static/js/spa/pages/members.js` | **Rewrite** — UserProfile-backed | 7 |
|
||||
| 32 | `web/static/js/spa/app.js` | Modify — remove admin members, add profile/:id | 7 |
|
||||
| 33 | `web/static/js/spa/pages/profile.js` | Modify — public view mode | 7 |
|
||||
| 34 | `tests/test_api/test_members.py` | **Delete** | 8 |
|
||||
| 35 | `tests/test_web/test_members.py` | **Delete** | 8 |
|
||||
| 36 | `tests/test_api/conftest.py` | Modify — remove member fixtures | 8 |
|
||||
| 37 | `tests/test_web/conftest.py` | Modify — remove member mock fixtures | 8 |
|
||||
| 38 | `tests/test_api/test_user_profiles.py` | Modify — new endpoint tests | 8 |
|
||||
| 39 | `tests/test_api/test_nodes.py` | Modify — remove member_id filter test | 8 |
|
||||
| 40 | `tests/test_api/test_advertisements.py` | Modify — remove member_id filter test | 8 |
|
||||
| 41 | `tests/test_api/test_metrics.py` | Modify — replace member metric tests | 8 |
|
||||
| 42 | `tests/test_web/test_features.py` | Modify — update member feature tests | 8 |
|
||||
| 43 | `tests/test_web/test_advertisements.py` | Modify — remove member filter test | 8 |
|
||||
| 44 | `tests/test_web/test_oidc.py` | Modify — use different endpoint for POST test | 8 |
|
||||
| 45 | `tests/test_common/test_config.py` | Modify — remove members_file test | 8 |
|
||||
| 46 | `tests/test_common/test_i18n.py` | Modify — remove member section tests | 8 |
|
||||
| 47 | `SCHEMAS.md` | Modify | 9 |
|
||||
| 48 | `docs/upgrading.md` | Modify | 9 |
|
||||
| 49 | `docs/seeding.md` | Modify | 9 |
|
||||
| 50 | `AGENTS.md` | Modify | 9 |
|
||||
| 51 | `README.md` | Modify | 9 |
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Phase 1: Database & model changes (model + migration)
|
||||
2. Phase 2: Shared utility refactor
|
||||
3. Phase 3: API endpoint changes (new endpoints, schema changes, replace member_id filters)
|
||||
4. Phase 4: Web app updates (access map, map data, SPA config)
|
||||
5. Phase 5: Remove Members infrastructure (delete files, update exports, metrics, CLI, seed data)
|
||||
6. Phase 6: Frontend — replace member_id references (nodes, ads, map pages)
|
||||
7. Phase 7: Frontend — members page rewrite & profile page extension
|
||||
8. Phase 8: Tests
|
||||
9. Phase 9: Documentation
|
||||
10. `pre-commit run --all-files` + `pytest`
|
||||
@@ -0,0 +1,156 @@
|
||||
# Replace Members with UserProfile — Task Checklist
|
||||
|
||||
**Plan:** `docs/plans/20260430-0805-members-refactor/plan.md`
|
||||
**Status:** Not Started
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database & Model Changes
|
||||
|
||||
- [ ] **1.1** `src/meshcore_hub/common/models/user_profile.py` — Add `roles: Mapped[Optional[str]]` column (nullable `Text`) and `role_list` property that parses comma-separated string into `list[str]`
|
||||
- [ ] **1.2** Generate Alembic migration — `meshcore-hub db revision --autogenerate -m "add roles to user_profiles, drop members"`, then edit to: add `roles` column to `user_profiles`, drop `members` table
|
||||
- [ ] **1.3** Run `meshcore-hub db upgrade` and verify migration applies cleanly
|
||||
|
||||
## Phase 2: Shared Utility Refactor
|
||||
|
||||
- [ ] **2.1** Create `src/meshcore_hub/api/profile_utils.py` — Shared `get_or_create_profile()` helper: looks up by `user_id`, creates if missing (with name from `X-User-Name`), updates `roles` from `X-User-Roles` on every call
|
||||
- [ ] **2.2** `src/meshcore_hub/api/routes/user_profiles.py` — Remove local `_get_or_create_profile`, import shared helper from `profile_utils.py`
|
||||
- [ ] **2.3** `src/meshcore_hub/api/routes/adoptions.py` — Remove local `_get_or_create_profile`, import shared helper from `profile_utils.py`
|
||||
|
||||
## Phase 3: API Endpoint Changes
|
||||
|
||||
- [ ] **3.1** `src/meshcore_hub/common/schemas/user_profiles.py` — Add `roles` field to `UserProfileRead`, create `UserProfilePublic` (omits `user_id`), `UserProfileListItem` (with `node_count`), `UserProfileList`, and `UserProfilePublicWithNodes`; update `UserProfileWithNodes` to include `roles`
|
||||
- [ ] **3.2** `src/meshcore_hub/api/routes/user_profiles.py` — Add `GET /profiles` endpoint (list all profiles, `RequireRead`, paginated, ordered by `name`, returns `UserProfileList` with `node_count`); register BEFORE `GET /profile/{profile_id}` to avoid route shadowing
|
||||
- [ ] **3.3** `src/meshcore_hub/api/routes/user_profiles.py` — Update `GET /profile/{profile_id}` to accept profile UUID (not `user_id`), remove `_verify_owner` for GET, allow unauthenticated access, return `UserProfilePublicWithNodes` for public or `UserProfileWithNodes` for owner
|
||||
- [ ] **3.4** `src/meshcore_hub/api/routes/user_profiles.py` — Update `PUT /profile/{profile_id}` to accept profile UUID, lookup by UUID, verify owner or admin
|
||||
- [ ] **3.5** `src/meshcore_hub/api/routes/nodes.py` — Remove `member_id` query parameter (line 44), add optional `adopted_by` query parameter filtering by `UserProfileNode.user_profile_id`
|
||||
- [ ] **3.6** `src/meshcore_hub/api/routes/advertisements.py` — Remove `member_id` query parameter (lines 53-54), add optional `adopted_by` query parameter
|
||||
|
||||
## Phase 4: Web App Updates
|
||||
|
||||
- [ ] **4.1** `src/meshcore_hub/web/app.py` — Update endpoint access map: remove `v1/members` entry, add `v1/user/profiles` with GET open; update `v1/user/profile` to allow unauthenticated GET
|
||||
- [ ] **4.2** `src/meshcore_hub/web/app.py` — Update map data endpoint (lines 658-740): replace `/api/v1/members` fetch + `member_id` tag lookup with `/api/v1/user/profiles` + adoption resolution; populate `owner` from profile data; remove `member_id` field from map node objects; replace `members_list` with `profiles` if needed
|
||||
- [ ] **4.3** `src/meshcore_hub/web/app.py` — In `_build_config_json()`: add `role_names` dict with `operator`, `member`, `admin` values from OIDC settings
|
||||
|
||||
## Phase 5: Remove Members Infrastructure
|
||||
|
||||
- [ ] **5.1** Delete `src/meshcore_hub/common/models/member.py`
|
||||
- [ ] **5.2** Delete `src/meshcore_hub/common/schemas/members.py`
|
||||
- [ ] **5.3** Delete `src/meshcore_hub/api/routes/members.py`
|
||||
- [ ] **5.4** Delete `src/meshcore_hub/collector/member_import.py`
|
||||
- [ ] **5.5** Delete `src/meshcore_hub/web/static/js/spa/pages/admin/members.js`
|
||||
- [ ] **5.6** Delete `seed/members.yaml`
|
||||
- [ ] **5.7** Delete `example/seed/members.yaml`
|
||||
- [ ] **5.8** `src/meshcore_hub/common/models/__init__.py` — Remove `Member` import and export
|
||||
- [ ] **5.9** `src/meshcore_hub/common/schemas/__init__.py` — Remove `MemberCreate`, `MemberUpdate`, `MemberRead`, `MemberList` imports and exports
|
||||
- [ ] **5.10** `src/meshcore_hub/api/routes/__init__.py` — Remove `members_router` import and `include_router` call
|
||||
- [ ] **5.11** `src/meshcore_hub/collector/cli.py` — Remove `import_members_cmd` (`import-members` command), remove member import from `seed` command, remove `--members` flag and member inclusion from `truncate` command, remove `Member` import and truncation logic
|
||||
- [ ] **5.12** `src/meshcore_hub/common/config.py` — Remove `members_file` property (lines 184-189)
|
||||
- [ ] **5.13** `src/meshcore_hub/api/metrics.py` — Remove `Member` import, replace `meshcore_members_total` gauge with `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`
|
||||
- [ ] **5.14** `src/meshcore_hub/web/templates/spa.html` — Remove admin Members nav link from both mobile and desktop admin menus
|
||||
- [ ] **5.15** `src/meshcore_hub/web/static/js/spa/pages/admin/index.js` — Remove members admin card/link (lines 51-59)
|
||||
- [ ] **5.16** `src/meshcore_hub/web/static/locales/en.json` — Remove `members.*` and `admin_members.*` sections; add `members_page.*` and `user_profile.*` keys; keep `entities.members` / `entities.member`
|
||||
- [ ] **5.17** `seed/node_tags.yaml` — Remove all 18 `member_id:` key-value entries
|
||||
- [ ] **5.18** `docker-compose.yml` — Update seed service comment to remove members.yaml reference
|
||||
|
||||
## Phase 6: Frontend — Replace member_id References
|
||||
|
||||
- [ ] **6.1** `src/meshcore_hub/web/static/js/spa/pages/nodes.js` — Remove `/api/v1/members` fetch, `member_id` query param, `showMembers` flag, member filter dropdown, member name display, "Member" column header
|
||||
- [ ] **6.2** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` — Remove same member-related code as nodes page: members fetch, filter dropdown, member display
|
||||
- [ ] **6.3** `src/meshcore_hub/web/static/js/spa/pages/map.js` — Remove member filter dropdown, `memberFilter` logic, `node.member_id` filter comparison; update owner display from adoption data
|
||||
|
||||
## Phase 7: Frontend — Members Page & Profile Page
|
||||
|
||||
- [ ] **7.1** `src/meshcore_hub/web/static/js/spa/pages/members.js` — Rewrite: fetch `/api/v1/user/profiles`, split into Operators/Members groups using `config.role_names`, render tiles with name/callsign/node_count, link to `/profile/${id}`, responsive grid layout
|
||||
- [ ] **7.2** `src/meshcore_hub/web/static/js/spa/app.js` — Remove `adminMembers` lazy import and `/admin/members` route; add `/profile/:id` route
|
||||
- [ ] **7.3** `src/meshcore_hub/web/static/js/spa/pages/profile.js` — Add public view mode: when `params.id` present, fetch profile by UUID, render read-only view with role badges and adopted nodes; show "Edit Profile" link for owner; no `params.id` = current editable behavior
|
||||
|
||||
## Phase 8: Tests
|
||||
|
||||
- [ ] **8.1** Delete `tests/test_api/test_members.py`
|
||||
- [ ] **8.2** Delete `tests/test_web/test_members.py`
|
||||
- [ ] **8.3** `tests/test_api/conftest.py` — Remove `sample_member` fixture (line 288) and `sample_node_with_member_tag` fixture (line 409)
|
||||
- [ ] **8.4** `tests/test_web/conftest.py` — Remove `GET:/api/v1/members` mock response (line 154), `mock_http_client_with_members` (line 466), `web_app_with_members` (line 548), `client_with_members` (line 568)
|
||||
- [ ] **8.5** `tests/test_api/test_user_profiles.py` — Add tests for `GET /profiles` (list, pagination, roles, node_count, `user_id` not exposed); add public `GET /profile/{id}` by UUID tests; add role persistence tests; update existing tests for UUID-based lookup
|
||||
- [ ] **8.6** `tests/test_api/test_nodes.py` — Remove `test_filter_by_member_id` (line 171); add `adopted_by` filter tests if implemented
|
||||
- [ ] **8.7** `tests/test_api/test_advertisements.py` — Remove `test_filter_by_member_id` (line 217); add `adopted_by` filter tests if implemented
|
||||
- [ ] **8.8** `tests/test_api/test_metrics.py` — Remove `meshcore_members_total` assertion (line 59) and `test_members_total_reflects_database` (line 150); add tests for `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`
|
||||
- [ ] **8.9** `tests/test_web/test_features.py` — Keep members feature flag tests; update assertions for member content in sitemap/nav
|
||||
- [ ] **8.10** `tests/test_web/test_advertisements.py` — Remove `test_advertisements_with_member_filter` (line 49)
|
||||
- [ ] **8.11** `tests/test_web/test_oidc.py` — Update `test_post_blocked_for_member` (line 196) to use a different write endpoint instead of `POST /api/v1/members`
|
||||
- [ ] **8.12** `tests/test_common/test_config.py` — Remove `test_members_file` (line 61)
|
||||
- [ ] **8.13** `tests/test_common/test_i18n.py` — Remove `"members"` and `"admin_members"` from required sections; keep `entities.members` assertion
|
||||
|
||||
## Phase 9: Documentation
|
||||
|
||||
- [ ] **9.1** `SCHEMAS.md` — Remove Member schemas; add `roles` to UserProfile schemas; document `GET /profiles` and updated `GET /profile/{id}`; remove `member_id` query param docs
|
||||
- [ ] **9.2** `docs/upgrading.md` — Add breaking change section: Member model/table removed, `member_id` tags no longer supported, seed files changed, Prometheus metric renamed
|
||||
- [ ] **9.3** `docs/seeding.md` — Remove member seeding section; remove `member_id` tag from seed format examples
|
||||
- [ ] **9.4** `AGENTS.md` — Update project structure (remove Member files), update env vars, remove `member_id` from standard node tags table, add `roles` to UserProfile model example
|
||||
- [ ] **9.5** `README.md` — Remove member-related references, update feature descriptions
|
||||
|
||||
## Phase 10: Verification
|
||||
|
||||
- [ ] **10.1** Run `pytest tests/test_api/ -v`
|
||||
- [ ] **10.2** Run `pytest tests/test_web/ -v`
|
||||
- [ ] **10.3** Run `pytest tests/test_common/ -v`
|
||||
- [ ] **10.4** Run `pytest` (full suite)
|
||||
- [ ] **10.5** Run `pre-commit run --all-files`
|
||||
|
||||
---
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| # | File | Action | Phase(s) |
|
||||
|---|------|--------|----------|
|
||||
| 1 | `src/meshcore_hub/common/models/user_profile.py` | Modify | 1 |
|
||||
| 2 | `alembic/versions/*.py` | Create | 1 |
|
||||
| 3 | `src/meshcore_hub/api/profile_utils.py` | Create | 2 |
|
||||
| 4 | `src/meshcore_hub/api/routes/user_profiles.py` | Modify | 2, 3 |
|
||||
| 5 | `src/meshcore_hub/api/routes/adoptions.py` | Modify | 2 |
|
||||
| 6 | `src/meshcore_hub/common/schemas/user_profiles.py` | Modify | 3 |
|
||||
| 7 | `src/meshcore_hub/api/routes/nodes.py` | Modify | 3 |
|
||||
| 8 | `src/meshcore_hub/api/routes/advertisements.py` | Modify | 3 |
|
||||
| 9 | `src/meshcore_hub/web/app.py` | Modify | 4 |
|
||||
| 10 | `src/meshcore_hub/common/models/member.py` | Delete | 5 |
|
||||
| 11 | `src/meshcore_hub/common/schemas/members.py` | Delete | 5 |
|
||||
| 12 | `src/meshcore_hub/api/routes/members.py` | Delete | 5 |
|
||||
| 13 | `src/meshcore_hub/collector/member_import.py` | Delete | 5 |
|
||||
| 14 | `src/meshcore_hub/web/static/js/spa/pages/admin/members.js` | Delete | 5 |
|
||||
| 15 | `seed/members.yaml` | Delete | 5 |
|
||||
| 16 | `example/seed/members.yaml` | Delete | 5 |
|
||||
| 17 | `src/meshcore_hub/common/models/__init__.py` | Modify | 5 |
|
||||
| 18 | `src/meshcore_hub/common/schemas/__init__.py` | Modify | 5 |
|
||||
| 19 | `src/meshcore_hub/api/routes/__init__.py` | Modify | 5 |
|
||||
| 20 | `src/meshcore_hub/collector/cli.py` | Modify | 5 |
|
||||
| 21 | `src/meshcore_hub/common/config.py` | Modify | 5 |
|
||||
| 22 | `src/meshcore_hub/api/metrics.py` | Modify | 5 |
|
||||
| 23 | `src/meshcore_hub/web/templates/spa.html` | Modify | 5 |
|
||||
| 24 | `src/meshcore_hub/web/static/js/spa/pages/admin/index.js` | Modify | 5 |
|
||||
| 25 | `src/meshcore_hub/web/static/locales/en.json` | Modify | 5 |
|
||||
| 26 | `seed/node_tags.yaml` | Modify | 5 |
|
||||
| 27 | `docker-compose.yml` | Modify | 5 |
|
||||
| 28 | `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Modify | 6 |
|
||||
| 29 | `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Modify | 6 |
|
||||
| 30 | `src/meshcore_hub/web/static/js/spa/pages/map.js` | Modify | 6 |
|
||||
| 31 | `src/meshcore_hub/web/static/js/spa/pages/members.js` | Rewrite | 7 |
|
||||
| 32 | `src/meshcore_hub/web/static/js/spa/app.js` | Modify | 7 |
|
||||
| 33 | `src/meshcore_hub/web/static/js/spa/pages/profile.js` | Modify | 7 |
|
||||
| 34 | `tests/test_api/test_members.py` | Delete | 8 |
|
||||
| 35 | `tests/test_web/test_members.py` | Delete | 8 |
|
||||
| 36 | `tests/test_api/conftest.py` | Modify | 8 |
|
||||
| 37 | `tests/test_web/conftest.py` | Modify | 8 |
|
||||
| 38 | `tests/test_api/test_user_profiles.py` | Modify | 8 |
|
||||
| 39 | `tests/test_api/test_nodes.py` | Modify | 8 |
|
||||
| 40 | `tests/test_api/test_advertisements.py` | Modify | 8 |
|
||||
| 41 | `tests/test_api/test_metrics.py` | Modify | 8 |
|
||||
| 42 | `tests/test_web/test_features.py` | Modify | 8 |
|
||||
| 43 | `tests/test_web/test_advertisements.py` | Modify | 8 |
|
||||
| 44 | `tests/test_web/test_oidc.py` | Modify | 8 |
|
||||
| 45 | `tests/test_common/test_config.py` | Modify | 8 |
|
||||
| 46 | `tests/test_common/test_i18n.py` | Modify | 8 |
|
||||
| 47 | `SCHEMAS.md` | Modify | 9 |
|
||||
| 48 | `docs/upgrading.md` | Modify | 9 |
|
||||
| 49 | `docs/seeding.md` | Modify | 9 |
|
||||
| 50 | `AGENTS.md` | Modify | 9 |
|
||||
| 51 | `README.md` | Modify | 9 |
|
||||
+2
-34
@@ -1,6 +1,6 @@
|
||||
# Seed Data
|
||||
|
||||
The database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
|
||||
The database can be seeded with node tags from YAML files in the `SEED_HOME` directory (default: `./seed`).
|
||||
|
||||
## Running the Seed Process
|
||||
|
||||
@@ -13,14 +13,12 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile seed up
|
||||
This imports data from the following files (if they exist):
|
||||
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/members.yaml` - Network member definitions
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
seed/ # SEED_HOME (seed data files)
|
||||
├── node_tags.yaml # Node tags for import
|
||||
└── members.yaml # Network members for import
|
||||
└── node_tags.yaml # Node tags for import
|
||||
|
||||
data/ # DATA_HOME (runtime data)
|
||||
└── collector/
|
||||
@@ -45,7 +43,6 @@ Tags are keyed by public key in YAML format:
|
||||
role: gateway
|
||||
lat: 37.7749
|
||||
lon: -122.4194
|
||||
member_id: alice
|
||||
|
||||
fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210:
|
||||
name: Oakland Repeater
|
||||
@@ -63,32 +60,3 @@ Tag values can be:
|
||||
```
|
||||
|
||||
Supported types: `string`, `number`, `boolean`
|
||||
|
||||
## Network Members
|
||||
|
||||
Network members represent the people operating nodes in your network. Members can optionally be linked to nodes via their public key.
|
||||
|
||||
### Members YAML Format
|
||||
|
||||
```yaml
|
||||
- member_id: walshie86
|
||||
name: Walshie
|
||||
callsign: Walshie86
|
||||
role: member
|
||||
description: IPNet Member
|
||||
- member_id: craig
|
||||
name: Craig
|
||||
callsign: M7XCN
|
||||
role: member
|
||||
description: IPNet Member
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------- | -------- | ---------------------------------------- |
|
||||
| `member_id` | Yes | Unique identifier for the member |
|
||||
| `name` | Yes | Member's display name |
|
||||
| `callsign` | No | Amateur radio callsign |
|
||||
| `role` | No | Member's role in the network |
|
||||
| `description` | No | Additional description |
|
||||
| `contact` | No | Contact information |
|
||||
| `public_key` | No | Associated node public key (64-char hex) |
|
||||
|
||||
@@ -2,6 +2,60 @@
|
||||
|
||||
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
|
||||
|
||||
## v0.11.0
|
||||
|
||||
This release removes the `Member` model/table entirely, replacing it with `UserProfile`-backed data. The members system is now driven by OIDC user profiles with roles instead of manually seeded entries.
|
||||
|
||||
### Overview of Changes
|
||||
|
||||
| Area | Before | After |
|
||||
|------|--------|-------|
|
||||
| Network Members | `members` table + CRUD API + YAML seed | Removed — replaced by `UserProfile` roles |
|
||||
| Member model | `Member` SQLAlchemy model | Deleted (table dropped) |
|
||||
| Member API | `GET/POST/PUT/DELETE /api/v1/members` | Removed — replaced by `GET /api/v1/user/profiles` |
|
||||
| Member seeding | `members.yaml` + `import-members` CLI | Removed — profiles auto-created from OIDC |
|
||||
| Node tags | `member_id` tag key | Replaced by `adopted_by` filter via `user_profile_nodes` |
|
||||
| Nodes API | `?member_id=` query param | `?adopted_by=` query param (profile UUID) |
|
||||
| Ads API | `?member_id=` query param | `?adopted_by=` query param (profile UUID) |
|
||||
| Prometheus | `meshcore_members_total` gauge | `meshcore_user_profiles_total` + `meshcore_user_profiles_by_role` |
|
||||
| Profile endpoint | `GET /api/v1/user/profile/{user_id}` | `GET /api/v1/user/profile/{profile_id}` (UUID) |
|
||||
| Profile endpoint | Owner-only GET | Public GET (owner sees `user_id`, public view omits it) |
|
||||
| New endpoint | — | `GET /api/v1/user/profiles` (list all, paginated, no `user_id`) |
|
||||
| User profiles | `roles` column missing | `roles` TEXT column added (comma-separated, parsed to list) |
|
||||
| Admin members UI | `/admin/members` admin page | Removed — Members page now reads from UserProfile |
|
||||
| Profile page | Owner-only editable | `/profile/:id` public view + `/profile` owner edit |
|
||||
| Truncate CLI | `--members` flag | Removed |
|
||||
| `collector seed` | Imports members.yaml | Removed — only imports node_tags.yaml |
|
||||
| Seed files | `members.yaml` required | `members.yaml` removed |
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. **Run database migration** — adds `roles` column to `user_profiles`, drops `members` table
|
||||
2. **Remove `members.yaml`** from your seed directory (no longer used)
|
||||
3. **Remove `member_id` tag keys** from `node_tags.yaml` (no longer functional — use node adoption instead)
|
||||
4. **Update Prometheus alerting rules** that reference `meshcore_members_total` to use `meshcore_user_profiles_total`
|
||||
|
||||
### Removed Files
|
||||
|
||||
- `seed/members.yaml`
|
||||
- `example/seed/members.yaml`
|
||||
|
||||
### Removed CLI Commands
|
||||
|
||||
- `meshcore-hub collector import-members`
|
||||
- `--members` flag on `meshcore-hub collector truncate`
|
||||
|
||||
### Behavior Changes
|
||||
|
||||
- The Members page (`/members`) now displays OIDC user profiles grouped by role instead of seeded member data
|
||||
- The `member_id` node tag is no longer used for filtering or display — use node adoption instead
|
||||
- Profile endpoints now use UUIDs instead of OIDC `user_id` strings in URLs
|
||||
- Public profile view (`/profile/{uuid}`) is accessible without authentication
|
||||
|
||||
### Database Migration
|
||||
|
||||
The migration adds a `roles` column (TEXT, nullable) to `user_profiles` and drops the `members` table.
|
||||
|
||||
## v0.10.0
|
||||
|
||||
This release includes **breaking changes** to the admin authentication model, OIDC role configuration, and adds user profiles with node adoption.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Example members seed file
|
||||
# Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
# Use node_tags.yaml to set member_id tags on nodes.
|
||||
members:
|
||||
- member_id: example_member
|
||||
name: Example Member
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example network operator member
|
||||
- member_id: simple_member
|
||||
name: Simple Member
|
||||
callsign: N0CALL2
|
||||
role: Observer
|
||||
description: Example observer member
|
||||
@@ -14,12 +14,12 @@ from sqlalchemy import func, select
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
UserProfile,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -270,14 +270,41 @@ def collect_metrics(session: Any) -> bytes:
|
||||
for event_type, count in event_counts:
|
||||
events_total.labels(event_type=event_type).set(count)
|
||||
|
||||
# -- Members total --
|
||||
members_total = Gauge(
|
||||
"meshcore_members_total",
|
||||
"Total number of network members",
|
||||
# -- User profiles total --
|
||||
user_profiles_total = Gauge(
|
||||
"meshcore_user_profiles_total",
|
||||
"Total number of user profiles",
|
||||
registry=registry,
|
||||
)
|
||||
count = session.execute(select(func.count(Member.id))).scalar() or 0
|
||||
members_total.set(count)
|
||||
count = session.execute(select(func.count(UserProfile.id))).scalar() or 0
|
||||
user_profiles_total.set(count)
|
||||
|
||||
# -- User profiles by role --
|
||||
user_profiles_by_role = Gauge(
|
||||
"meshcore_user_profiles_by_role",
|
||||
"Number of user profiles by role",
|
||||
["role"],
|
||||
registry=registry,
|
||||
)
|
||||
role_rows = session.execute(
|
||||
select(UserProfile.roles, func.count(UserProfile.id)).group_by(
|
||||
UserProfile.roles
|
||||
)
|
||||
).all()
|
||||
for row_roles, _ in role_rows:
|
||||
if row_roles:
|
||||
for role in row_roles.split(","):
|
||||
role = role.strip()
|
||||
if role:
|
||||
role_count = (
|
||||
session.execute(
|
||||
select(func.count(UserProfile.id)).where(
|
||||
UserProfile.roles.contains(role)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
user_profiles_by_role.labels(role=role).set(role_count)
|
||||
|
||||
output: bytes = generate_latest(registry)
|
||||
return output
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Shared utility for getting or creating user profiles."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.api.auth import X_USER_NAME_HEADER, X_USER_ROLES_HEADER
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import UserProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_profile(
|
||||
session: DbSession,
|
||||
user_id: str,
|
||||
request: Request,
|
||||
) -> UserProfile:
|
||||
"""Get existing profile or create a new one, updating roles from headers.
|
||||
|
||||
Looks up by ``user_id``. If not found, creates a new profile with the
|
||||
name from the ``X-User-Name`` header. On every call the ``roles`` column
|
||||
is updated from the ``X-User-Roles`` header so IdP role changes are
|
||||
reflected on the next authenticated request.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy database session.
|
||||
user_id: OIDC subject identifier.
|
||||
request: FastAPI request (for header access).
|
||||
|
||||
Returns:
|
||||
The resolved :class:`UserProfile` instance.
|
||||
"""
|
||||
query = select(UserProfile).where(UserProfile.user_id == user_id)
|
||||
profile = session.execute(query).scalar_one_or_none()
|
||||
if not profile:
|
||||
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
|
||||
profile = UserProfile(user_id=user_id, name=idp_name)
|
||||
logger.info("Created new user profile for user_id=%s", user_id)
|
||||
|
||||
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
|
||||
profile.roles = roles_header or None
|
||||
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
return profile
|
||||
@@ -9,7 +9,6 @@ from meshcore_hub.api.routes.advertisements import router as advertisements_rout
|
||||
from meshcore_hub.api.routes.trace_paths import router as trace_paths_router
|
||||
from meshcore_hub.api.routes.telemetry import router as telemetry_router
|
||||
from meshcore_hub.api.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.api.routes.members import router as members_router
|
||||
from meshcore_hub.api.routes.user_profiles import router as user_profiles_router
|
||||
from meshcore_hub.api.routes.adoptions import router as adoptions_router
|
||||
|
||||
@@ -27,6 +26,5 @@ api_router.include_router(
|
||||
)
|
||||
api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"])
|
||||
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"])
|
||||
api_router.include_router(members_router, prefix="/members", tags=["Members"])
|
||||
api_router.include_router(user_profiles_router, prefix="/user", tags=["User"])
|
||||
api_router.include_router(adoptions_router, prefix="/adoptions", tags=["Adoptions"])
|
||||
|
||||
@@ -6,9 +6,10 @@ from fastapi import APIRouter, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireOperatorOrAdmin, X_USER_NAME_HEADER
|
||||
from meshcore_hub.api.auth import RequireOperatorOrAdmin
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, UserProfile, UserProfileNode
|
||||
from meshcore_hub.api.profile_utils import get_or_create_profile
|
||||
from meshcore_hub.common.models import Node, UserProfileNode
|
||||
from meshcore_hub.common.schemas.user_profiles import AdoptedNodeRead, NodeAdoptRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,24 +17,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_or_create_profile(
|
||||
session: DbSession, user_id: str, request: Request
|
||||
) -> UserProfile:
|
||||
"""Get existing profile or create a new one with name from IdP."""
|
||||
query = select(UserProfile).where(UserProfile.user_id == user_id)
|
||||
profile = session.execute(query).scalar_one_or_none()
|
||||
if profile:
|
||||
return profile
|
||||
|
||||
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
|
||||
profile = UserProfile(user_id=user_id, name=idp_name)
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
logger.info("Created new user profile for user_id=%s", user_id)
|
||||
return profile
|
||||
|
||||
|
||||
@router.post("", response_model=AdoptedNodeRead, status_code=201)
|
||||
async def adopt_node(
|
||||
adopt_request: NodeAdoptRequest,
|
||||
@@ -43,7 +26,7 @@ async def adopt_node(
|
||||
) -> AdoptedNodeRead:
|
||||
"""Adopt a node. Requires operator or admin role."""
|
||||
caller_id, _ = caller_info
|
||||
profile = _get_or_create_profile(session, caller_id, request)
|
||||
profile = get_or_create_profile(session, caller_id, request)
|
||||
|
||||
public_key = adopt_request.public_key.lower()
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import aliased, selectinload
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.api.observer_utils import fetch_observers_for_events
|
||||
from meshcore_hub.common.models import Advertisement, Node, NodeTag
|
||||
from meshcore_hub.common.models import Advertisement, Node, NodeTag, UserProfileNode
|
||||
from meshcore_hub.common.schemas.messages import (
|
||||
AdvertisementList,
|
||||
AdvertisementRead,
|
||||
@@ -50,8 +50,8 @@ async def list_advertisements(
|
||||
observed_by: Optional[str] = Query(
|
||||
None, description="Filter by receiver node public key"
|
||||
),
|
||||
member_id: Optional[str] = Query(
|
||||
None, description="Filter by member_id tag value of source node"
|
||||
adopted_by: Optional[str] = Query(
|
||||
None, description="Filter by adopting user profile UUID"
|
||||
),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
@@ -100,12 +100,11 @@ async def list_advertisements(
|
||||
if observed_by:
|
||||
query = query.where(ObserverNode.public_key == observed_by)
|
||||
|
||||
if member_id:
|
||||
# Filter advertisements from nodes that have a member_id tag with the specified value
|
||||
if adopted_by:
|
||||
query = query.where(
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
select(UserProfileNode.node_id).where(
|
||||
UserProfileNode.user_profile_id == adopted_by
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
"""Member API routes."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Member
|
||||
from meshcore_hub.common.schemas.members import (
|
||||
MemberCreate,
|
||||
MemberList,
|
||||
MemberRead,
|
||||
MemberUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=MemberList)
|
||||
async def list_members(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> MemberList:
|
||||
"""List all members with pagination."""
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(Member)
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Get members ordered by name
|
||||
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
|
||||
members = list(session.execute(query).scalars().all())
|
||||
|
||||
return MemberList(
|
||||
items=[MemberRead.model_validate(m) for m in members],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{member_id}", response_model=MemberRead)
|
||||
async def get_member(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
member_id: str,
|
||||
) -> MemberRead:
|
||||
"""Get a specific member by ID."""
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
member = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
return MemberRead.model_validate(member)
|
||||
|
||||
|
||||
@router.post("", response_model=MemberRead, status_code=201)
|
||||
async def create_member(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
member: MemberCreate,
|
||||
) -> MemberRead:
|
||||
"""Create a new member."""
|
||||
# Check if member_id already exists
|
||||
query = select(Member).where(Member.member_id == member.member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
|
||||
# Create member
|
||||
new_member = Member(
|
||||
member_id=member.member_id,
|
||||
name=member.name,
|
||||
callsign=member.callsign,
|
||||
role=member.role,
|
||||
description=member.description,
|
||||
contact=member.contact,
|
||||
)
|
||||
session.add(new_member)
|
||||
session.commit()
|
||||
session.refresh(new_member)
|
||||
|
||||
return MemberRead.model_validate(new_member)
|
||||
|
||||
|
||||
@router.put("/{member_id}", response_model=MemberRead)
|
||||
async def update_member(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
member_id: str,
|
||||
member: MemberUpdate,
|
||||
) -> MemberRead:
|
||||
"""Update a member."""
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Update fields
|
||||
if member.member_id is not None:
|
||||
# Check if new member_id is already taken by another member
|
||||
check_query = select(Member).where(
|
||||
Member.member_id == member.member_id, Member.id != member_id
|
||||
)
|
||||
collision = session.execute(check_query).scalar_one_or_none()
|
||||
if collision:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Member with member_id '{member.member_id}' already exists",
|
||||
)
|
||||
existing.member_id = member.member_id
|
||||
if member.name is not None:
|
||||
existing.name = member.name
|
||||
if member.callsign is not None:
|
||||
existing.callsign = member.callsign
|
||||
if member.role is not None:
|
||||
existing.role = member.role
|
||||
if member.description is not None:
|
||||
existing.description = member.description
|
||||
if member.contact is not None:
|
||||
existing.contact = member.contact
|
||||
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
|
||||
return MemberRead.model_validate(existing)
|
||||
|
||||
|
||||
@router.delete("/{member_id}", status_code=204)
|
||||
async def delete_member(
|
||||
_: RequireAdmin,
|
||||
session: DbSession,
|
||||
member_id: str,
|
||||
) -> None:
|
||||
"""Delete a member."""
|
||||
query = select(Member).where(Member.id == member_id)
|
||||
member = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
@@ -41,7 +41,9 @@ async def list_nodes(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
|
||||
adopted_by: Optional[str] = Query(
|
||||
None, description="Filter by adopting user profile UUID"
|
||||
),
|
||||
role: Optional[str] = Query(None, description="Filter by role tag value"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
@@ -106,12 +108,11 @@ async def list_nodes(
|
||||
else:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
if member_id:
|
||||
# Filter nodes that have a member_id tag with the specified value
|
||||
if adopted_by:
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
select(UserProfileNode.node_id).where(
|
||||
UserProfileNode.user_profile_id == adopted_by
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireUserOwner, X_USER_NAME_HEADER
|
||||
from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.api.profile_utils import get_or_create_profile
|
||||
from meshcore_hub.common.models import UserProfile
|
||||
from meshcore_hub.common.models.user_profile_node import UserProfileNode
|
||||
from meshcore_hub.common.schemas.user_profiles import (
|
||||
AdoptedNodeRead,
|
||||
UserProfileList,
|
||||
UserProfileListItem,
|
||||
UserProfilePublicWithNodes,
|
||||
UserProfileRead,
|
||||
UserProfileUpdate,
|
||||
UserProfileWithNodes,
|
||||
@@ -20,44 +26,8 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _verify_owner(user_id: str, requested_id: str) -> None:
|
||||
"""Verify the authenticated user matches the requested user_id."""
|
||||
if user_id != requested_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: cannot access another user's profile",
|
||||
)
|
||||
|
||||
|
||||
def _get_or_create_profile(
|
||||
session: DbSession, user_id: str, request: Request
|
||||
) -> UserProfile:
|
||||
"""Get existing profile or create a new one with name from IdP."""
|
||||
query = select(UserProfile).where(UserProfile.user_id == user_id)
|
||||
profile = session.execute(query).scalar_one_or_none()
|
||||
if profile:
|
||||
return profile
|
||||
|
||||
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
|
||||
profile = UserProfile(user_id=user_id, name=idp_name)
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
logger.info("Created new user profile for user_id=%s", user_id)
|
||||
return profile
|
||||
|
||||
|
||||
@router.get("/profile/{user_id}", response_model=UserProfileWithNodes)
|
||||
async def get_profile(
|
||||
user_id: str,
|
||||
caller_id: RequireUserOwner,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
) -> UserProfileWithNodes:
|
||||
"""Get or create a user profile. Auto-creates on first access."""
|
||||
_verify_owner(caller_id, user_id)
|
||||
profile = _get_or_create_profile(session, user_id, request)
|
||||
|
||||
def _build_adopted_nodes(profile: UserProfile) -> list[AdoptedNodeRead]:
|
||||
"""Extract adopted node list from a profile's eager-loaded associations."""
|
||||
adopted_nodes = []
|
||||
for assoc in profile.node_associations:
|
||||
adopted_nodes.append(
|
||||
@@ -68,29 +38,160 @@ async def get_profile(
|
||||
adopted_at=assoc.adopted_at,
|
||||
)
|
||||
)
|
||||
return adopted_nodes
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=UserProfileList)
|
||||
async def list_profiles(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> UserProfileList:
|
||||
"""List all user profiles with node counts. No user_id exposed."""
|
||||
count_query = select(func.count(UserProfile.id))
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
query = (
|
||||
select(UserProfile)
|
||||
.options(
|
||||
selectinload(UserProfile.node_associations).selectinload(
|
||||
UserProfileNode.node
|
||||
)
|
||||
)
|
||||
.order_by(UserProfile.name)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
profiles = session.execute(query).scalars().all()
|
||||
|
||||
items = []
|
||||
for profile in profiles:
|
||||
adopted_nodes = []
|
||||
for assoc in profile.node_associations:
|
||||
adopted_nodes.append(
|
||||
AdoptedNodeRead(
|
||||
public_key=assoc.node.public_key,
|
||||
name=assoc.node.name,
|
||||
adv_type=assoc.node.adv_type,
|
||||
adopted_at=assoc.adopted_at,
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
UserProfileListItem(
|
||||
id=profile.id,
|
||||
name=profile.name,
|
||||
callsign=profile.callsign,
|
||||
roles=profile.role_list,
|
||||
node_count=len(profile.node_associations),
|
||||
adopted_nodes=adopted_nodes,
|
||||
)
|
||||
)
|
||||
|
||||
return UserProfileList(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/profile/me", response_model=UserProfileWithNodes)
|
||||
async def get_my_profile(
|
||||
request: Request,
|
||||
session: DbSession,
|
||||
) -> UserProfileWithNodes:
|
||||
"""Get the current user's profile (via X-User-Id header).
|
||||
|
||||
Auto-creates the profile if it doesn't exist. Returns the full
|
||||
profile including user_id and adopted nodes.
|
||||
"""
|
||||
oidc_user_id = request.headers.get(X_USER_ID_HEADER)
|
||||
if not oidc_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User identity required",
|
||||
)
|
||||
|
||||
profile = get_or_create_profile(session, oidc_user_id, request)
|
||||
return UserProfileWithNodes(
|
||||
id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
name=profile.name,
|
||||
callsign=profile.callsign,
|
||||
roles=profile.role_list,
|
||||
created_at=profile.created_at,
|
||||
updated_at=profile.updated_at,
|
||||
nodes=adopted_nodes,
|
||||
nodes=_build_adopted_nodes(profile),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/profile/{user_id}", response_model=UserProfileRead)
|
||||
@router.get("/profile/{profile_id}")
|
||||
async def get_profile(
|
||||
profile_id: str,
|
||||
request: Request,
|
||||
session: DbSession,
|
||||
) -> UserProfilePublicWithNodes | UserProfileWithNodes:
|
||||
"""Get a user profile by UUID.
|
||||
|
||||
Public access is allowed for viewing any profile. If the caller is the
|
||||
owner (authenticated with matching user_id), the full profile including
|
||||
user_id is returned and the profile is auto-created if missing.
|
||||
"""
|
||||
oidc_user_id = request.headers.get(X_USER_ID_HEADER)
|
||||
|
||||
if oidc_user_id:
|
||||
caller_query = select(UserProfile).where(UserProfile.user_id == oidc_user_id)
|
||||
caller_profile = session.execute(caller_query).scalar_one_or_none()
|
||||
if caller_profile and str(caller_profile.id) == str(profile_id):
|
||||
profile = get_or_create_profile(session, oidc_user_id, request)
|
||||
return UserProfileWithNodes(
|
||||
id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
name=profile.name,
|
||||
callsign=profile.callsign,
|
||||
roles=profile.role_list,
|
||||
created_at=profile.created_at,
|
||||
updated_at=profile.updated_at,
|
||||
nodes=_build_adopted_nodes(profile),
|
||||
)
|
||||
|
||||
public_query = select(UserProfile).where(UserProfile.id == profile_id)
|
||||
public_profile = session.execute(public_query).scalar_one_or_none()
|
||||
if not public_profile:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Profile not found",
|
||||
)
|
||||
|
||||
return UserProfilePublicWithNodes(
|
||||
id=public_profile.id,
|
||||
name=public_profile.name,
|
||||
callsign=public_profile.callsign,
|
||||
roles=public_profile.role_list,
|
||||
created_at=public_profile.created_at,
|
||||
updated_at=public_profile.updated_at,
|
||||
nodes=_build_adopted_nodes(public_profile),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/profile/{profile_id}", response_model=UserProfileRead)
|
||||
async def update_profile(
|
||||
user_id: str,
|
||||
profile_id: str,
|
||||
profile_update: UserProfileUpdate,
|
||||
caller_id: RequireUserOwner,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
) -> UserProfileRead:
|
||||
"""Update a user profile."""
|
||||
_verify_owner(caller_id, user_id)
|
||||
profile = _get_or_create_profile(session, user_id, request)
|
||||
"""Update a user profile. Only the owner or admin can update."""
|
||||
profile_query = select(UserProfile).where(UserProfile.id == profile_id)
|
||||
profile = session.execute(profile_query).scalar_one_or_none()
|
||||
if not profile:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Profile not found",
|
||||
)
|
||||
|
||||
if profile.user_id != caller_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: cannot modify another user's profile",
|
||||
)
|
||||
|
||||
if profile_update.name is not None:
|
||||
profile.name = profile_update.name
|
||||
|
||||
@@ -325,7 +325,6 @@ def seed_cmd(
|
||||
|
||||
Looks for the following files in SEED_HOME:
|
||||
- node_tags.yaml: Node tag definitions (keyed by public_key)
|
||||
- members.yaml: Network member definitions
|
||||
|
||||
Files that don't exist are skipped. This command is idempotent -
|
||||
existing records are updated, new records are created.
|
||||
@@ -379,7 +378,6 @@ def _run_seed_import(
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore_hub.collector.member_import import import_members
|
||||
from meshcore_hub.collector.tag_import import import_tags
|
||||
|
||||
imported_any = False
|
||||
@@ -410,26 +408,6 @@ def _run_seed_import(
|
||||
elif verbose:
|
||||
click.echo(f"\nNo node_tags.yaml found in {seed_home}")
|
||||
|
||||
# Import members if file exists
|
||||
members_file = Path(seed_home) / "members.yaml"
|
||||
if members_file.exists():
|
||||
if verbose:
|
||||
click.echo(f"\nImporting members from: {members_file}")
|
||||
stats = import_members(
|
||||
file_path=str(members_file),
|
||||
db=db,
|
||||
)
|
||||
if verbose:
|
||||
click.echo(
|
||||
f" Members: {stats['created']} created, {stats['updated']} updated"
|
||||
)
|
||||
if stats["errors"]:
|
||||
for error in stats["errors"]:
|
||||
click.echo(f" Error: {error}", err=True)
|
||||
imported_any = True
|
||||
elif verbose:
|
||||
click.echo(f"\nNo members.yaml found in {seed_home}")
|
||||
|
||||
return imported_any
|
||||
|
||||
|
||||
@@ -536,83 +514,6 @@ def import_tags_cmd(
|
||||
db.dispose()
|
||||
|
||||
|
||||
@collector.command("import-members")
|
||||
@click.argument("file", type=click.Path(), required=False, default=None)
|
||||
@click.pass_context
|
||||
def import_members_cmd(
|
||||
ctx: click.Context,
|
||||
file: str | None,
|
||||
) -> None:
|
||||
"""Import network members from a YAML file.
|
||||
|
||||
Reads a YAML file containing member definitions and upserts them
|
||||
into the database. Existing members (matched by name) are updated,
|
||||
new members are created.
|
||||
|
||||
FILE is the path to the YAML file containing members.
|
||||
If not provided, defaults to {SEED_HOME}/members.yaml.
|
||||
|
||||
Expected YAML format (list):
|
||||
|
||||
\b
|
||||
- name: John Doe
|
||||
callsign: N0CALL
|
||||
role: Network Operator
|
||||
description: Example member
|
||||
|
||||
Or with "members" key:
|
||||
|
||||
\b
|
||||
members:
|
||||
- name: John Doe
|
||||
callsign: N0CALL
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
# Use members_file from settings if not provided
|
||||
settings = ctx.obj["settings"]
|
||||
members_file = file if file else settings.members_file
|
||||
|
||||
# Check if file exists
|
||||
if not Path(members_file).exists():
|
||||
click.echo(f"Members file not found: {members_file}")
|
||||
if not file:
|
||||
click.echo("Specify a file path or create the default members.yaml.")
|
||||
return
|
||||
|
||||
click.echo(f"Importing members from: {members_file}")
|
||||
click.echo(f"Database: {ctx.obj['database_url']}")
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.collector.member_import import import_members
|
||||
|
||||
# Initialize database (schema managed by Alembic migrations)
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
|
||||
# Import members
|
||||
stats = import_members(
|
||||
file_path=members_file,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Report results
|
||||
click.echo("")
|
||||
click.echo("Import complete:")
|
||||
click.echo(f" Total members in file: {stats['total']}")
|
||||
click.echo(f" Members created: {stats['created']}")
|
||||
click.echo(f" Members updated: {stats['updated']}")
|
||||
|
||||
if stats["errors"]:
|
||||
click.echo("")
|
||||
click.echo("Errors:")
|
||||
for error in stats["errors"]:
|
||||
click.echo(f" - {error}", err=True)
|
||||
|
||||
db.dispose()
|
||||
|
||||
|
||||
@collector.command("cleanup")
|
||||
@click.option(
|
||||
"--retention-days",
|
||||
@@ -701,12 +602,6 @@ def cleanup_cmd(
|
||||
|
||||
|
||||
@collector.command("truncate")
|
||||
@click.option(
|
||||
"--members",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Truncate members table",
|
||||
)
|
||||
@click.option(
|
||||
"--nodes",
|
||||
is_flag=True,
|
||||
@@ -759,7 +654,6 @@ def cleanup_cmd(
|
||||
@click.pass_context
|
||||
def truncate_cmd(
|
||||
ctx: click.Context,
|
||||
members: bool,
|
||||
nodes: bool,
|
||||
messages: bool,
|
||||
advertisements: bool,
|
||||
@@ -774,9 +668,6 @@ def truncate_cmd(
|
||||
WARNING: This permanently deletes data! Use with caution.
|
||||
|
||||
Examples:
|
||||
# Clear members table
|
||||
meshcore-hub collector truncate --members
|
||||
|
||||
# Clear messages and advertisements
|
||||
meshcore-hub collector truncate --messages --advertisements
|
||||
|
||||
@@ -791,7 +682,6 @@ def truncate_cmd(
|
||||
# Determine what to truncate
|
||||
if truncate_all:
|
||||
tables_to_clear = {
|
||||
"members": True,
|
||||
"nodes": True,
|
||||
"messages": True,
|
||||
"advertisements": True,
|
||||
@@ -801,7 +691,6 @@ def truncate_cmd(
|
||||
}
|
||||
else:
|
||||
tables_to_clear = {
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
"messages": messages,
|
||||
"advertisements": advertisements,
|
||||
@@ -848,7 +737,6 @@ def truncate_cmd(
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
EventLog,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
@@ -856,49 +744,38 @@ def truncate_cmd(
|
||||
TracePath,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
|
||||
with db.session_scope() as session:
|
||||
# Truncate in correct order to respect foreign keys
|
||||
cleared: list[str] = []
|
||||
|
||||
# Clear members (no dependencies)
|
||||
if tables_to_clear.get("members"):
|
||||
result: CursorResult = session.execute(delete(Member)) # type: ignore
|
||||
cleared.append(f"members: {result.rowcount} rows")
|
||||
|
||||
# Clear event-specific tables first (they depend on nodes)
|
||||
if tables_to_clear.get("messages"):
|
||||
result = session.execute(delete(Message)) # type: ignore
|
||||
cleared.append(f"messages: {result.rowcount} rows")
|
||||
result = session.execute(delete(Message))
|
||||
cleared.append(f"messages: {result.rowcount} rows") # type: ignore[attr-defined]
|
||||
|
||||
if tables_to_clear.get("advertisements"):
|
||||
result = session.execute(delete(Advertisement)) # type: ignore
|
||||
cleared.append(f"advertisements: {result.rowcount} rows")
|
||||
result = session.execute(delete(Advertisement))
|
||||
cleared.append(f"advertisements: {result.rowcount} rows") # type: ignore[attr-defined]
|
||||
|
||||
if tables_to_clear.get("telemetry"):
|
||||
result = session.execute(delete(Telemetry)) # type: ignore
|
||||
cleared.append(f"telemetry: {result.rowcount} rows")
|
||||
result = session.execute(delete(Telemetry))
|
||||
cleared.append(f"telemetry: {result.rowcount} rows") # type: ignore[attr-defined]
|
||||
|
||||
if tables_to_clear.get("trace_paths"):
|
||||
result = session.execute(delete(TracePath)) # type: ignore
|
||||
cleared.append(f"trace_paths: {result.rowcount} rows")
|
||||
result = session.execute(delete(TracePath))
|
||||
cleared.append(f"trace_paths: {result.rowcount} rows") # type: ignore[attr-defined]
|
||||
|
||||
if tables_to_clear.get("event_logs"):
|
||||
result = session.execute(delete(EventLog)) # type: ignore
|
||||
cleared.append(f"event_logs: {result.rowcount} rows")
|
||||
result = session.execute(delete(EventLog))
|
||||
cleared.append(f"event_logs: {result.rowcount} rows") # type: ignore[attr-defined]
|
||||
|
||||
# Clear nodes last (this will cascade delete tags and any remaining events)
|
||||
if tables_to_clear.get("nodes"):
|
||||
# Delete tags first (they depend on nodes)
|
||||
tag_result: CursorResult = session.execute(delete(NodeTag)) # type: ignore
|
||||
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)")
|
||||
tag_result = session.execute(delete(NodeTag))
|
||||
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)") # type: ignore[attr-defined]
|
||||
|
||||
# Delete nodes (will cascade to remaining related tables)
|
||||
node_result: CursorResult = session.execute(delete(Node)) # type: ignore
|
||||
cleared.append(f"nodes: {node_result.rowcount} rows")
|
||||
node_result = session.execute(delete(Node))
|
||||
cleared.append(f"nodes: {node_result.rowcount} rows") # type: ignore[attr-defined]
|
||||
|
||||
db.dispose()
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Import members from YAML file."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemberData(BaseModel):
|
||||
"""Schema for a member entry in the import file.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
callsign: Optional[str] = Field(default=None, max_length=20)
|
||||
role: Optional[str] = Field(default=None, max_length=100)
|
||||
description: Optional[str] = Field(default=None)
|
||||
contact: Optional[str] = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
|
||||
"""Load and validate members from a YAML file.
|
||||
|
||||
Supports two formats:
|
||||
1. List of member objects:
|
||||
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
|
||||
2. Object with "members" key:
|
||||
|
||||
members:
|
||||
- member_id: member1
|
||||
name: Member 1
|
||||
callsign: M1
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
|
||||
Returns:
|
||||
List of validated member dictionaries
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file does not exist
|
||||
yaml.YAMLError: If file is not valid YAML
|
||||
ValueError: If file content is invalid
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Members file not found: {file_path}")
|
||||
|
||||
with open(path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Handle both formats
|
||||
if isinstance(data, list):
|
||||
members_list = data
|
||||
elif isinstance(data, dict) and "members" in data:
|
||||
members_list = data["members"]
|
||||
if not isinstance(members_list, list):
|
||||
raise ValueError("'members' key must contain a list")
|
||||
else:
|
||||
raise ValueError("Members file must be a list or a mapping with 'members' key")
|
||||
|
||||
# Validate each member
|
||||
validated: list[dict[str, Any]] = []
|
||||
for i, member in enumerate(members_list):
|
||||
if not isinstance(member, dict):
|
||||
raise ValueError(f"Member at index {i} must be an object")
|
||||
if "member_id" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'member_id' field")
|
||||
if "name" not in member:
|
||||
raise ValueError(f"Member at index {i} must have a 'name' field")
|
||||
|
||||
# Validate using Pydantic model
|
||||
try:
|
||||
validated_member = MemberData.model_validate(member)
|
||||
validated.append(validated_member.model_dump())
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid member at index {i}: {e}")
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def import_members(
|
||||
file_path: str | Path,
|
||||
db: DatabaseManager,
|
||||
) -> dict[str, Any]:
|
||||
"""Import members from a YAML file into the database.
|
||||
|
||||
Performs upsert operations based on member_id - existing members are updated,
|
||||
new members are created.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
This import does not manage node associations.
|
||||
|
||||
Args:
|
||||
file_path: Path to the members YAML file
|
||||
db: Database manager instance
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics:
|
||||
- total: Total number of members in file
|
||||
- created: Number of new members created
|
||||
- updated: Number of existing members updated
|
||||
- errors: List of error messages
|
||||
"""
|
||||
stats: dict[str, Any] = {
|
||||
"total": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# Load and validate file
|
||||
try:
|
||||
members_data = load_members_file(file_path)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"Failed to load members file: {e}")
|
||||
return stats
|
||||
|
||||
stats["total"] = len(members_data)
|
||||
|
||||
with db.session_scope() as session:
|
||||
for member_data in members_data:
|
||||
try:
|
||||
member_id = member_data["member_id"]
|
||||
name = member_data["name"]
|
||||
|
||||
# Find existing member by member_id
|
||||
query = select(Member).where(Member.member_id == member_id)
|
||||
existing = session.execute(query).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update existing member
|
||||
if member_data.get("name") is not None:
|
||||
existing.name = member_data["name"]
|
||||
if member_data.get("callsign") is not None:
|
||||
existing.callsign = member_data["callsign"]
|
||||
if member_data.get("role") is not None:
|
||||
existing.role = member_data["role"]
|
||||
if member_data.get("description") is not None:
|
||||
existing.description = member_data["description"]
|
||||
if member_data.get("contact") is not None:
|
||||
existing.contact = member_data["contact"]
|
||||
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"Updated member: {member_id} ({name})")
|
||||
else:
|
||||
# Create new member
|
||||
new_member = Member(
|
||||
member_id=member_id,
|
||||
name=name,
|
||||
callsign=member_data.get("callsign"),
|
||||
role=member_data.get("role"),
|
||||
description=member_data.get("description"),
|
||||
contact=member_data.get("contact"),
|
||||
)
|
||||
session.add(new_member)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.debug(f"Created member: {member_id} ({name})")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing member '{member_data.get('member_id', 'unknown')}' ({member_data.get('name', 'unknown')}): {e}"
|
||||
stats["errors"].append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
return stats
|
||||
@@ -181,13 +181,6 @@ class CollectorSettings(CommonSettings):
|
||||
|
||||
return str(Path(self.effective_seed_home) / "node_tags.yaml")
|
||||
|
||||
@property
|
||||
def members_file(self) -> str:
|
||||
"""Get the path to members.yaml in seed_home."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_seed_home) / "members.yaml")
|
||||
|
||||
@property
|
||||
def collector_channel_keys_list(self) -> list[str]:
|
||||
"""Parse configured channel keys into a normalized list."""
|
||||
|
||||
@@ -8,7 +8,6 @@ from meshcore_hub.common.models.advertisement import Advertisement
|
||||
from meshcore_hub.common.models.trace_path import TracePath
|
||||
from meshcore_hub.common.models.telemetry import Telemetry
|
||||
from meshcore_hub.common.models.event_log import EventLog
|
||||
from meshcore_hub.common.models.member import Member
|
||||
from meshcore_hub.common.models.user_profile import UserProfile
|
||||
from meshcore_hub.common.models.user_profile_node import UserProfileNode
|
||||
from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer
|
||||
@@ -23,7 +22,6 @@ __all__ = [
|
||||
"TracePath",
|
||||
"Telemetry",
|
||||
"EventLog",
|
||||
"Member",
|
||||
"UserProfile",
|
||||
"UserProfileNode",
|
||||
"EventObserver",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Member model for network member information."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Member(Base, UUIDMixin, TimestampMixin):
|
||||
"""Member model for network member information.
|
||||
|
||||
Stores information about network members/operators.
|
||||
Nodes are associated with members via a 'member_id' tag on the node.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
member_id: Unique member identifier (e.g., 'walshie86')
|
||||
name: Member's display name
|
||||
callsign: Amateur radio callsign (optional)
|
||||
role: Member's role in the network (optional)
|
||||
description: Additional description (optional)
|
||||
contact: Contact information (optional)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "members"
|
||||
|
||||
member_id: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
callsign: Mapped[Optional[str]] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
)
|
||||
role: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
)
|
||||
contact: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Member(id={self.id}, member_id={self.member_id}, name={self.name}, callsign={self.callsign})>"
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
@@ -22,6 +22,7 @@ class UserProfile(Base, UUIDMixin, TimestampMixin):
|
||||
user_id: OIDC subject identifier (unique, from IdP 'sub' claim)
|
||||
name: User's display name or preferred name (blank initially)
|
||||
callsign: Amateur radio callsign (blank initially)
|
||||
roles: Comma-separated OIDC roles string (updated on each auth)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
@@ -42,6 +43,11 @@ class UserProfile(Base, UUIDMixin, TimestampMixin):
|
||||
String(20),
|
||||
nullable=True,
|
||||
)
|
||||
roles: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
node_associations: Mapped[list["UserProfileNode"]] = relationship(
|
||||
"UserProfileNode",
|
||||
@@ -50,5 +56,12 @@ class UserProfile(Base, UUIDMixin, TimestampMixin):
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def role_list(self) -> list[str]:
|
||||
"""Parse comma-separated roles string into a list."""
|
||||
if not self.roles:
|
||||
return []
|
||||
return [r.strip() for r in self.roles.split(",") if r.strip()]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserProfile(id={self.id}, user_id={self.user_id}, name={self.name})>"
|
||||
|
||||
@@ -25,12 +25,6 @@ from meshcore_hub.common.schemas.messages import (
|
||||
MessageList,
|
||||
MessageFilters,
|
||||
)
|
||||
from meshcore_hub.common.schemas.members import (
|
||||
MemberCreate,
|
||||
MemberUpdate,
|
||||
MemberRead,
|
||||
MemberList,
|
||||
)
|
||||
from meshcore_hub.common.schemas.network import (
|
||||
RadioConfig,
|
||||
)
|
||||
@@ -58,11 +52,6 @@ __all__ = [
|
||||
"MessageRead",
|
||||
"MessageList",
|
||||
"MessageFilters",
|
||||
# Members
|
||||
"MemberCreate",
|
||||
"MemberUpdate",
|
||||
"MemberRead",
|
||||
"MemberList",
|
||||
# Network
|
||||
"RadioConfig",
|
||||
]
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Pydantic schemas for member API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MemberCreate(BaseModel):
|
||||
"""Schema for creating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Member's display name",
|
||||
)
|
||||
callsign: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=20,
|
||||
description="Amateur radio callsign",
|
||||
)
|
||||
role: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Member's role in the network",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Additional description",
|
||||
)
|
||||
contact: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
"""Schema for updating a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node,
|
||||
not through this schema.
|
||||
"""
|
||||
|
||||
member_id: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Unique member identifier (e.g., 'walshie86')",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Member's display name",
|
||||
)
|
||||
callsign: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=20,
|
||||
description="Amateur radio callsign",
|
||||
)
|
||||
role: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Member's role in the network",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Additional description",
|
||||
)
|
||||
contact: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Contact information",
|
||||
)
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
"""Schema for reading a member.
|
||||
|
||||
Note: Nodes are associated with members via a 'member_id' tag on the node.
|
||||
To find nodes for a member, query nodes with a 'member_id' tag matching this member.
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Member UUID")
|
||||
member_id: str = Field(..., description="Unique member identifier")
|
||||
name: str = Field(..., description="Member's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
role: Optional[str] = Field(default=None, description="Member's role")
|
||||
description: Optional[str] = Field(default=None, description="Description")
|
||||
contact: Optional[str] = Field(default=None, description="Contact information")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MemberList(BaseModel):
|
||||
"""Schema for paginated member list response."""
|
||||
|
||||
items: list[MemberRead] = Field(..., description="List of members")
|
||||
total: int = Field(..., description="Total number of members")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
@@ -13,12 +13,77 @@ class UserProfileRead(BaseModel):
|
||||
user_id: str = Field(..., description="OIDC subject identifier")
|
||||
name: Optional[str] = Field(default=None, description="User's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
roles: list[str] = Field(default_factory=list, description="User roles")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def model_validate(cls, obj: object, **kwargs: object) -> "UserProfileRead":
|
||||
if hasattr(obj, "role_list") and not isinstance(obj, dict):
|
||||
d = {
|
||||
"id": str(obj.id), # type: ignore[attr-defined]
|
||||
"user_id": obj.user_id, # type: ignore[attr-defined]
|
||||
"name": obj.name, # type: ignore[attr-defined]
|
||||
"callsign": obj.callsign, # type: ignore[attr-defined]
|
||||
"roles": obj.role_list,
|
||||
"created_at": obj.created_at, # type: ignore[attr-defined]
|
||||
"updated_at": obj.updated_at, # type: ignore[attr-defined]
|
||||
}
|
||||
return super().model_validate(d)
|
||||
return super().model_validate(obj)
|
||||
|
||||
|
||||
class UserProfilePublic(BaseModel):
|
||||
"""Public-facing schema — omits user_id for privacy."""
|
||||
|
||||
id: str = Field(..., description="Profile UUID")
|
||||
name: Optional[str] = Field(default=None, description="User's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
roles: list[str] = Field(default_factory=list, description="User roles")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdoptedNodeRead(BaseModel):
|
||||
"""Schema for reading an adopted node in the context of a user profile."""
|
||||
|
||||
public_key: str = Field(..., description="Node's 64-character hex public key")
|
||||
name: Optional[str] = Field(default=None, description="Node display name")
|
||||
adv_type: Optional[str] = Field(default=None, description="Advertisement type")
|
||||
adopted_at: datetime = Field(..., description="When the node was adopted")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserProfileListItem(BaseModel):
|
||||
"""Schema for a single profile in list responses."""
|
||||
|
||||
id: str = Field(..., description="Profile UUID")
|
||||
name: Optional[str] = Field(default=None, description="User's display name")
|
||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||
roles: list[str] = Field(default_factory=list, description="User roles")
|
||||
node_count: int = Field(default=0, description="Number of adopted nodes")
|
||||
adopted_nodes: list[AdoptedNodeRead] = Field(
|
||||
default_factory=list,
|
||||
description="Nodes adopted by this user",
|
||||
)
|
||||
|
||||
|
||||
class UserProfileList(BaseModel):
|
||||
"""Schema for paginated profile list response."""
|
||||
|
||||
items: list[UserProfileListItem] = Field(..., description="List of profiles")
|
||||
total: int = Field(..., description="Total number of profiles")
|
||||
limit: int = Field(..., description="Page size limit")
|
||||
offset: int = Field(..., description="Page offset")
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
"""Schema for updating a user profile."""
|
||||
@@ -36,20 +101,17 @@ class UserProfileUpdate(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class AdoptedNodeRead(BaseModel):
|
||||
"""Schema for reading an adopted node in the context of a user profile."""
|
||||
|
||||
public_key: str = Field(..., description="Node's 64-character hex public key")
|
||||
name: Optional[str] = Field(default=None, description="Node display name")
|
||||
adv_type: Optional[str] = Field(default=None, description="Advertisement type")
|
||||
adopted_at: datetime = Field(..., description="When the node was adopted")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserProfileWithNodes(UserProfileRead):
|
||||
"""Schema for reading a user profile with adopted nodes."""
|
||||
"""Schema for reading a user profile with adopted nodes (owner view)."""
|
||||
|
||||
nodes: list[AdoptedNodeRead] = Field(
|
||||
default_factory=list,
|
||||
description="Nodes adopted by this user",
|
||||
)
|
||||
|
||||
|
||||
class UserProfilePublicWithNodes(UserProfilePublic):
|
||||
"""Public profile view with adopted nodes."""
|
||||
|
||||
nodes: list[AdoptedNodeRead] = Field(
|
||||
default_factory=list,
|
||||
|
||||
+37
-28
@@ -76,11 +76,8 @@ def _build_endpoint_access(
|
||||
"PUT": admin,
|
||||
"DELETE": admin,
|
||||
},
|
||||
"v1/members": {
|
||||
"v1/user/profiles": {
|
||||
"GET": _OPEN,
|
||||
"POST": admin,
|
||||
"PUT": admin,
|
||||
"DELETE": admin,
|
||||
},
|
||||
"v1/messages": {
|
||||
"GET": _OPEN,
|
||||
@@ -102,7 +99,7 @@ def _build_endpoint_access(
|
||||
"DELETE": operator_admin,
|
||||
},
|
||||
"v1/user/profile": {
|
||||
"GET": any_authenticated,
|
||||
"GET": _OPEN,
|
||||
"PUT": any_authenticated,
|
||||
},
|
||||
}
|
||||
@@ -648,28 +645,23 @@ def create_app(
|
||||
if not request.app.state.features.get("map", True):
|
||||
return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
|
||||
nodes_with_location: list[dict[str, Any]] = []
|
||||
members_list: list[dict[str, Any]] = []
|
||||
members_by_id: dict[str, dict[str, Any]] = {}
|
||||
profiles_by_id: dict[str, dict[str, Any]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by member_id
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
profiles_response = await request.app.state.http_client.get(
|
||||
"/api/v1/user/profiles", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
member_info = {
|
||||
"member_id": member.get("member_id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
if profiles_response.status_code == 200:
|
||||
profiles_data = profiles_response.json()
|
||||
for profile in profiles_data.get("items", []):
|
||||
profiles_by_id[profile["id"]] = {
|
||||
"id": profile.get("id"),
|
||||
"name": profile.get("name"),
|
||||
"callsign": profile.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("member_id"):
|
||||
members_by_id[member["member_id"]] = member_info
|
||||
|
||||
# Fetch all nodes from API
|
||||
response = await request.app.state.http_client.get(
|
||||
@@ -686,7 +678,6 @@ def create_app(
|
||||
tag_lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
node_member_id = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
@@ -704,8 +695,6 @@ def create_app(
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
elif key == "member_id":
|
||||
node_member_id = tag.get("value")
|
||||
|
||||
lat = tag_lat if tag_lat is not None else node.get("lat")
|
||||
lon = tag_lon if tag_lon is not None else node.get("lon")
|
||||
@@ -722,9 +711,16 @@ def create_app(
|
||||
or node.get("public_key", "")[:12]
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
owner = (
|
||||
members_by_id.get(node_member_id) if node_member_id else None
|
||||
)
|
||||
|
||||
adopted_by = node.get("adopted_by")
|
||||
owner = None
|
||||
if adopted_by and adopted_by.get("user_id"):
|
||||
pass
|
||||
if adopted_by:
|
||||
owner = {
|
||||
"name": adopted_by.get("name"),
|
||||
"callsign": adopted_by.get("callsign"),
|
||||
}
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
@@ -736,7 +732,6 @@ def create_app(
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"member_id": node_member_id,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
@@ -770,7 +765,7 @@ def create_app(
|
||||
return JSONResponse(
|
||||
{
|
||||
"nodes": nodes_with_location,
|
||||
"members": members_list,
|
||||
"profiles": list(profiles_by_id.values()),
|
||||
"center": {"lat": center_lat, "lon": center_lon},
|
||||
"infra_center": infra_center,
|
||||
"debug": {
|
||||
@@ -956,6 +951,20 @@ def create_app(
|
||||
|
||||
request.session["user"] = session_user
|
||||
request.session["id_token"] = token.get("id_token")
|
||||
|
||||
try:
|
||||
profile_headers: dict[str, str] = {
|
||||
"X-User-Id": session_user.get("sub", ""),
|
||||
"X-User-Roles": ",".join(session_user.get("roles", [])),
|
||||
}
|
||||
if session_user.get("name"):
|
||||
profile_headers["X-User-Name"] = session_user["name"]
|
||||
await request.app.state.http_client.get(
|
||||
"/api/v1/user/profile/me", headers=profile_headers
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to ensure user profile exists")
|
||||
|
||||
next_url = request.session.pop("next", "/")
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ const pages = {
|
||||
notFound: () => import('./pages/not-found.js'),
|
||||
adminIndex: () => import('./pages/admin/index.js'),
|
||||
adminNodeTags: () => import('./pages/admin/node-tags.js'),
|
||||
adminMembers: () => import('./pages/admin/members.js'),
|
||||
profile: () => import('./pages/profile.js'),
|
||||
};
|
||||
|
||||
@@ -93,12 +92,12 @@ if (hasRole('admin')) {
|
||||
router.addRoute('/admin', pageHandler(pages.adminIndex));
|
||||
router.addRoute('/admin/', pageHandler(pages.adminIndex));
|
||||
router.addRoute('/admin/node-tags', pageHandler(pages.adminNodeTags));
|
||||
router.addRoute('/admin/members', pageHandler(pages.adminMembers));
|
||||
}
|
||||
|
||||
// Profile route (only register when OIDC enabled)
|
||||
if (config.oidc_enabled) {
|
||||
router.addRoute('/profile', pageHandler(pages.profile));
|
||||
router.addRoute('/profile/:id', pageHandler(pages.profile));
|
||||
}
|
||||
|
||||
// 404 handler
|
||||
@@ -156,7 +155,6 @@ function updatePageTitle(pathname) {
|
||||
'/admin': composePageTitle('entities.admin'),
|
||||
'/admin/': composePageTitle('entities.admin'),
|
||||
'/admin/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
|
||||
'/admin/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
|
||||
};
|
||||
|
||||
// Add feature-dependent titles
|
||||
|
||||
@@ -101,3 +101,7 @@ export function iconTag(cls = 'h-5 w-5') {
|
||||
export function iconUsers(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconAntenna(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" /></svg>`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, litRender, unsafeHTML, getConfig, hasRole, errorAlert, t } from '../../components.js';
|
||||
import { iconLock, iconUsers, iconTag } from '../../icons.js';
|
||||
import { iconLock, iconTag } from '../../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
@@ -48,15 +48,6 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a href="/admin/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconUsers('h-6 w-6')}
|
||||
${t('entities.members')}
|
||||
</h2>
|
||||
<p>${t('admin.members_description')}</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, hasRole, errorAlert, successAlert, t, escapeHtml,
|
||||
} from '../../components.js';
|
||||
import { iconLock } from '../../icons.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
|
||||
if (!hasRole('admin')) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
|
||||
<p class="opacity-70">${t('auth.admin_required')}</p>
|
||||
<a href="/auth/login" class="btn btn-primary mt-6">${t('auth.login')}</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
const flashMessage = (params.query && params.query.message) || '';
|
||||
const flashError = (params.query && params.query.error) || '';
|
||||
|
||||
const data = await apiGet('/api/v1/members', { limit: 100 });
|
||||
const members = data.items || [];
|
||||
|
||||
const flashHtml = html`${flashMessage ? successAlert(flashMessage) : nothing}${flashError ? errorAlert(flashError) : nothing}`;
|
||||
|
||||
const tableHtml = members.length > 0
|
||||
? html`
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('admin_members.member_id')}</th>
|
||||
<th>${t('common.name')}</th>
|
||||
<th>${t('common.callsign')}</th>
|
||||
<th>${t('common.contact')}</th>
|
||||
<th class="w-32">${t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${members.map(m => html`
|
||||
<tr data-member-id=${m.id}
|
||||
data-member-name=${m.name}
|
||||
data-member-member-id=${m.member_id}
|
||||
data-member-callsign=${m.callsign || ''}
|
||||
data-member-description=${m.description || ''}
|
||||
data-member-contact=${m.contact || ''}>
|
||||
<td class="font-mono font-semibold">${m.member_id}</td>
|
||||
<td>${m.name}</td>
|
||||
<td>
|
||||
${m.callsign
|
||||
? html`<span class="badge badge-primary">${m.callsign}</span>`
|
||||
: html`<span class="text-base-content/40">-</span>`}
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title=${m.contact || ''}>${m.contact || '-'}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">${t('common.edit')}</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`)}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}</p>
|
||||
<p class="text-sm mt-2">${t('admin_members.empty_state_hint')}</p>
|
||||
</div>`;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/admin/">${t('entities.admin')}</a></li>
|
||||
<li>${t('entities.members')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">${t('admin_members.network_members', { count: members.length })}</h2>
|
||||
<button id="btn-add-member" class="btn btn-primary btn-sm">${t('common.add_entity', { entity: t('entities.member') })}</button>
|
||||
</div>
|
||||
${tableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="addModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">${t('common.add_new_entity', { entity: t('entities.member') })}</h3>
|
||||
<form id="add-member-form" class="py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" class="input input-bordered"
|
||||
placeholder="walshie86" required maxlength="50"
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
<label class="label">
|
||||
<span class="label-text-alt">${t('admin_members.member_id_hint')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" class="input input-bordered"
|
||||
placeholder="John Smith" required maxlength="255">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
|
||||
<input type="text" name="callsign" class="input input-bordered"
|
||||
placeholder="VK4ABC" maxlength="20">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
|
||||
<input type="text" name="contact" class="input input-bordered"
|
||||
placeholder="john@example.com or phone number" maxlength="255">
|
||||
</div>
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label"><span class="label-text">${t('common.description')}</span></label>
|
||||
<textarea name="description" rows="3" class="textarea textarea-bordered"
|
||||
placeholder="Brief description of member's role and responsibilities..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="addCancel">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.member') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.member') })}</h3>
|
||||
<form id="edit-member-form" class="py-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
|
||||
required maxlength="50" pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="edit_name" class="input input-bordered"
|
||||
required maxlength="255">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
|
||||
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
|
||||
maxlength="20">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
|
||||
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
|
||||
maxlength="255">
|
||||
</div>
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label"><span class="label-text">${t('common.description')}</span></label>
|
||||
<textarea name="description" id="edit_description" rows="3"
|
||||
class="textarea textarea-bordered"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4" id="delete_confirm_message"></p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
|
||||
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
|
||||
</dialog>`, container);
|
||||
|
||||
let activeDeleteId = '';
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal });
|
||||
|
||||
// Add Member
|
||||
on(container.querySelector('#btn-add-member'), 'click', () => {
|
||||
const form = container.querySelector('#add-member-form');
|
||||
form.reset();
|
||||
container.querySelector('#addModal').showModal();
|
||||
});
|
||||
|
||||
on(container.querySelector('#addCancel'), 'click', () => {
|
||||
container.querySelector('#addModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#add-member-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = {
|
||||
member_id: form.member_id.value.trim(),
|
||||
name: form.name.value.trim(),
|
||||
callsign: form.callsign.value.trim() || null,
|
||||
description: form.description.value.trim() || null,
|
||||
contact: form.contact.value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await apiPost('/api/v1/members', body);
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })), true);
|
||||
} catch (err) {
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
// Edit Member
|
||||
container.querySelectorAll('.btn-edit').forEach(btn => {
|
||||
on(btn, 'click', () => {
|
||||
const row = btn.closest('tr');
|
||||
container.querySelector('#edit_id').value = row.dataset.memberId;
|
||||
container.querySelector('#edit_member_id').value = row.dataset.memberMemberId;
|
||||
container.querySelector('#edit_name').value = row.dataset.memberName;
|
||||
container.querySelector('#edit_callsign').value = row.dataset.memberCallsign;
|
||||
container.querySelector('#edit_description').value = row.dataset.memberDescription;
|
||||
container.querySelector('#edit_contact').value = row.dataset.memberContact;
|
||||
container.querySelector('#editModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
on(container.querySelector('#editCancel'), 'click', () => {
|
||||
container.querySelector('#editModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#edit-member-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const id = form.id.value;
|
||||
const body = {
|
||||
member_id: form.member_id.value.trim(),
|
||||
name: form.name.value.trim(),
|
||||
callsign: form.callsign.value.trim() || null,
|
||||
description: form.description.value.trim() || null,
|
||||
contact: form.contact.value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })), true);
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
// Delete Member
|
||||
container.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
on(btn, 'click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeDeleteId = row.dataset.memberId;
|
||||
const memberName = row.dataset.memberName;
|
||||
const confirmMsg = t('common.delete_entity_confirm', {
|
||||
entity: t('entities.member').toLowerCase(),
|
||||
name: escapeHtml(memberName)
|
||||
});
|
||||
container.querySelector('#delete_confirm_message').innerHTML = confirmMsg;
|
||||
container.querySelector('#deleteModal').showModal();
|
||||
});
|
||||
});
|
||||
|
||||
on(container.querySelector('#deleteCancel'), 'click', () => {
|
||||
container.querySelector('#deleteModal').close();
|
||||
});
|
||||
|
||||
on(container.querySelector('#deleteConfirm'), 'click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })), true);
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => ac.abort();
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t,
|
||||
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
|
||||
truncateKey, warningBadge,
|
||||
warningBadge,
|
||||
pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
|
||||
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
|
||||
} from '../components.js';
|
||||
@@ -12,14 +12,11 @@ export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
const search = query.search || '';
|
||||
const public_key = query.public_key || '';
|
||||
const member_id = query.member_id || '';
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
const showMembers = features.members !== false;
|
||||
const tz = config.timezone || '';
|
||||
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
|
||||
const navigate = (url) => router.navigate(url);
|
||||
@@ -49,29 +46,21 @@ export async function render(container, params, router) {
|
||||
${displayContent}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
|
||||
const results = await Promise.all([
|
||||
apiGet('/api/v1/advertisements', { limit, offset, search, public_key }),
|
||||
apiGet('/api/v1/nodes', { limit: 500 }),
|
||||
];
|
||||
if (showMembers) {
|
||||
requests.push(apiGet('/api/v1/members', { limit: 100 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
]);
|
||||
const data = results[0];
|
||||
const nodesData = results[1];
|
||||
const membersData = showMembers ? results[2] : null;
|
||||
|
||||
const advertisements = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const allNodes = nodesData.items || [];
|
||||
const members = membersData?.items || [];
|
||||
|
||||
const sortedNodes = allNodes.map(n => {
|
||||
const tagName = n.tags?.find(t => t.key === 'name')?.value;
|
||||
@@ -91,19 +80,6 @@ ${displayContent}`, container);
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const membersFilter = (showMembers && members.length > 0)
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
|
||||
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const mobileCards = advertisements.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
|
||||
: advertisements.map(ad => {
|
||||
@@ -136,7 +112,7 @@ ${displayContent}`, container);
|
||||
<thead><tr><th>Observer</th><th>${t('common.snr_db')}</th><th>Received</th></tr></thead>
|
||||
<tbody>
|
||||
${ad.observers.map(o => {
|
||||
const dn = o.tag_name || o.name || truncateKey(o.public_key, 12);
|
||||
const dn = o.tag_name || o.name || o.public_key.slice(0, 12);
|
||||
const snrD = o.snr != null ? `${Number(o.snr).toFixed(1)}` : '\u2014';
|
||||
const timeD = formatRelativeTime(o.observed_at);
|
||||
return html`<tr>
|
||||
@@ -189,7 +165,7 @@ ${displayContent}`, container);
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/advertisements', {
|
||||
search, public_key, member_id, limit,
|
||||
search, public_key, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
@@ -203,7 +179,6 @@ ${displayContent}`, container);
|
||||
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
|
||||
</div>
|
||||
${nodesFilter}
|
||||
${membersFilter}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
|
||||
|
||||
@@ -120,7 +120,6 @@ export async function render(container, params, router) {
|
||||
try {
|
||||
const data = await apiGet('/map/data');
|
||||
const allNodes = data.nodes || [];
|
||||
const allMembers = data.members || [];
|
||||
const mapCenter = data.center || { lat: 0, lon: 0 };
|
||||
const infraCenter = data.infra_center || null;
|
||||
const debug = data.debug || {};
|
||||
@@ -129,9 +128,9 @@ export async function render(container, params, router) {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
|
||||
|
||||
const sortedMembers = allMembers.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
function applyFilters() {
|
||||
|
||||
const isMobilePortrait
|
||||
const filteredNodes = applyFiltersCore();
|
||||
const categoryFilter = container.querySelector('#filter-category').value;
|
||||
|
||||
@@ -165,7 +164,6 @@ export async function render(container, params, router) {
|
||||
function clearFiltersHandler() {
|
||||
container.querySelector('#filter-category').value = '';
|
||||
container.querySelector('#filter-type').value = '';
|
||||
container.querySelector('#filter-member').value = '';
|
||||
container.querySelector('#show-labels').checked = false;
|
||||
updateLabelVisibility();
|
||||
applyFilters();
|
||||
@@ -204,22 +202,6 @@ export async function render(container, params, router) {
|
||||
<option value="room">${t('node_types.room')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
|
||||
${sortedMembers
|
||||
.filter(m => m.member_id)
|
||||
.map(m => {
|
||||
const label = m.callsign
|
||||
? m.name + ' (' + m.callsign + ')'
|
||||
: m.name;
|
||||
return html`<option value=${m.member_id}>${label}</option>`;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">${t('map.show_labels')}</span>
|
||||
@@ -270,13 +252,11 @@ export async function render(container, params, router) {
|
||||
function applyFiltersCore() {
|
||||
const categoryFilter = container.querySelector('#filter-category').value;
|
||||
const typeFilter = container.querySelector('#filter-type').value;
|
||||
const memberFilter = container.querySelector('#filter-member').value;
|
||||
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
if (categoryFilter === 'infra' && !node.is_infra) return false;
|
||||
const nodeType = normalizeType(node.adv_type);
|
||||
if (typeFilter && nodeType !== typeFilter) return false;
|
||||
if (memberFilter && node.member_id !== memberFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,150 +1,90 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t, unsafeHTML,
|
||||
formatRelativeTime, formatDateTime, errorAlert,
|
||||
} from '../components.js';
|
||||
import { iconInfo } from '../icons.js';
|
||||
import { html, litRender, nothing, t, errorAlert, getConfig } from '../components.js';
|
||||
import { iconAntenna, iconUsers } from '../icons.js';
|
||||
|
||||
function nodeTypeEmoji(advType) {
|
||||
switch ((advType || '').toLowerCase()) {
|
||||
case 'chat': return '\u{1F4AC}';
|
||||
case 'repeater': return '\u{1F4E1}';
|
||||
case 'room': return '\u{1FAA7}';
|
||||
default: return advType ? '\u{1F4CD}' : '\u{1F4E6}';
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSortKey(node) {
|
||||
const t = (node.adv_type || '').toLowerCase();
|
||||
if (t === 'repeater') return 0;
|
||||
if (t === 'chat') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function renderNodeCard(node) {
|
||||
const tagName = node.tags ? (node.tags.find(t => t.key === 'name') || {}).value : null;
|
||||
const displayName = tagName || node.name;
|
||||
const emoji = nodeTypeEmoji(node.adv_type);
|
||||
const relTime = formatRelativeTime(node.last_seen);
|
||||
const fullTime = formatDateTime(node.last_seen);
|
||||
|
||||
const nameBlock = displayName
|
||||
? html`<div class="font-medium text-sm">${displayName}</div>
|
||||
<div class="font-mono text-xs opacity-60">${node.public_key.slice(0, 12)}...</div>`
|
||||
: html`<div class="font-mono text-sm">${node.public_key.slice(0, 12)}...</div>`;
|
||||
|
||||
const timeBlock = node.last_seen
|
||||
? html`<time class="text-xs opacity-60 whitespace-nowrap" datetime=${node.last_seen} title=${fullTime} data-relative-time>${relTime}</time>`
|
||||
function renderProfileTile(profile) {
|
||||
const callsignBadge = profile.callsign
|
||||
? html`<span class="badge badge-neutral badge-sm">${profile.callsign}</span>`
|
||||
: nothing;
|
||||
|
||||
return html`<a href="/nodes/${node.public_key}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
${nameBlock}
|
||||
const roleBadges = profile.roles && profile.roles.length > 0
|
||||
? html`<div class="flex flex-wrap gap-1 mt-1">${profile.roles.map(role =>
|
||||
html`<span class="badge badge-primary badge-sm">${role}</span>`
|
||||
)}</div>`
|
||||
: nothing;
|
||||
|
||||
const nodeCountLabel = profile.node_count > 0
|
||||
? html`<span class="text-sm opacity-60">${t('members_page.node_count', { count: profile.node_count })}</span>`
|
||||
: nothing;
|
||||
|
||||
const nodeBadges = profile.adopted_nodes && profile.adopted_nodes.length > 0
|
||||
? html`<div class="flex flex-wrap gap-1 mt-2">${profile.adopted_nodes.map(node => {
|
||||
const label = node.name || node.public_key.slice(0, 12) + '...';
|
||||
return html`<a href="/nodes/${node.public_key}" class="badge badge-outline badge-sm hover:badge-ghost transition-colors">${label}</a>`;
|
||||
})}</div>`
|
||||
: nothing;
|
||||
|
||||
return html`<a href="/profile/${profile.id}" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${profile.name || t('common.unnamed')}
|
||||
${callsignBadge}
|
||||
</h2>
|
||||
${roleBadges}
|
||||
${nodeCountLabel}
|
||||
${nodeBadges}
|
||||
</div>
|
||||
${timeBlock}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderMemberCard(member, nodes) {
|
||||
const sorted = [...nodes].sort((a, b) => nodeSortKey(a) - nodeSortKey(b));
|
||||
const nodesBlock = sorted.length > 0
|
||||
? html`<div class="mt-4 space-y-2">${sorted.map(renderNodeCard)}</div>`
|
||||
: nothing;
|
||||
|
||||
const callsignBadge = member.callsign
|
||||
? html`<span class="badge badge-neutral">${member.callsign}</span>`
|
||||
: nothing;
|
||||
|
||||
const descBlock = member.description
|
||||
? html`<p class="mt-2">${member.description}</p>`
|
||||
: nothing;
|
||||
|
||||
const contactBlock = member.contact
|
||||
? html`<p class="text-sm mt-2"><span class="opacity-70">${t('common.contact')}:</span> ${member.contact}</p>`
|
||||
: nothing;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${member.name}
|
||||
${callsignBadge}
|
||||
</h2>
|
||||
${descBlock}
|
||||
${contactBlock}
|
||||
${nodesBlock}
|
||||
</div>
|
||||
</div>`;
|
||||
function renderGroup(title, profiles, icon) {
|
||||
if (profiles.length === 0) return nothing;
|
||||
return html`
|
||||
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
|
||||
${icon}${title}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${profiles.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(renderProfileTile)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const membersResp = await apiGet('/api/v1/members', { limit: 100 });
|
||||
const members = membersResp.items || [];
|
||||
const config = getConfig();
|
||||
const roleNames = config.role_names || {};
|
||||
const operatorRole = roleNames.operator || 'operator';
|
||||
const memberRole = roleNames.member || 'member';
|
||||
|
||||
if (members.length === 0) {
|
||||
const resp = await apiGet('/api/v1/user/profiles', { limit: 500 });
|
||||
const profiles = resp.items || [];
|
||||
|
||||
if (profiles.length === 0) {
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
|
||||
<span class="badge badge-lg">${t('common.count_entity', { count: 0, entity: t('entities.members').toLowerCase() })}</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
${iconInfo('stroke-current shrink-0 h-6 w-6')}
|
||||
<div>
|
||||
<h3 class="font-bold">${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}</h3>
|
||||
<p class="text-sm">${t('members.empty_state_description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('members.members_file_format')}</h2>
|
||||
<p class="mb-4">${unsafeHTML(t('members.members_file_description'))}</p>
|
||||
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
|
||||
- member_id: johndoe
|
||||
name: John Doe
|
||||
callsign: AB1CD
|
||||
role: Network Admin
|
||||
description: Manages the main repeater node.
|
||||
contact: john@example.com
|
||||
- member_id: janesmith
|
||||
name: Jane Smith
|
||||
role: Member
|
||||
description: Regular user in the downtown area.</code></pre>
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
${unsafeHTML(t('members.members_import_instructions'))}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center py-12 opacity-70">
|
||||
<p class="text-lg">${t('members_page.empty_state')}</p>
|
||||
<p class="text-sm mt-2">${t('members_page.empty_description')}</p>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodePromises = members.map(m =>
|
||||
apiGet('/api/v1/nodes', { member_id: m.member_id, limit: 50 })
|
||||
.then(resp => ({ memberId: m.member_id, nodes: resp.items || [] }))
|
||||
.catch(() => ({ memberId: m.member_id, nodes: [] }))
|
||||
);
|
||||
const nodeResults = await Promise.all(nodePromises);
|
||||
|
||||
const nodesByMember = {};
|
||||
for (const r of nodeResults) {
|
||||
nodesByMember[r.memberId] = r.nodes;
|
||||
}
|
||||
|
||||
const cards = members.map(m =>
|
||||
renderMemberCard(m, nodesByMember[m.member_id] || [])
|
||||
const operators = profiles.filter(p => p.roles && p.roles.includes(operatorRole));
|
||||
const members = profiles.filter(p =>
|
||||
p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole)
|
||||
);
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
|
||||
<span class="badge badge-lg">${t('common.count_entity', { count: members.length, entity: t('entities.members').toLowerCase() })}</span>
|
||||
<span class="badge badge-lg">${t('common.count_entity', { count: profiles.length, entity: t('entities.members').toLowerCase() })}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
|
||||
${cards}
|
||||
</div>`, container);
|
||||
${renderGroup(t('members_page.operators'), operators, html`<span class="text-primary">${iconAntenna('h-6 w-6')}</span>`)}
|
||||
${renderGroup(t('members_page.members'), members, html`<span class="text-secondary">${iconUsers('h-6 w-6')}</span>`)}
|
||||
`, container);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
|
||||
@@ -138,6 +138,41 @@ export async function render(container, params, router) {
|
||||
const flashError = (params.query && params.query.error) || '';
|
||||
const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
|
||||
|
||||
const infoGridHtml = adoptionHtml
|
||||
? html`<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
|
||||
@click=${(e) => copyToClipboard(e, node.public_key)}
|
||||
title="Click to copy">${node.public_key}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
|
||||
${coordsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${adoptionHtml}
|
||||
</div>`
|
||||
: html`<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
|
||||
@click=${(e) => copyToClipboard(e, node.public_key)}
|
||||
title="Click to copy">${node.public_key}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
|
||||
${coordsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
litRender(html`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
@@ -159,21 +194,7 @@ ${heroHtml}
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
|
||||
@click=${(e) => copyToClipboard(e, node.public_key)}
|
||||
title="Click to copy">${node.public_key}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
|
||||
${coordsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${infoGridHtml}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
@@ -190,11 +211,7 @@ ${flashHtml}
|
||||
${adminTagsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${adoptionHtml}`, container);
|
||||
|
||||
// Initialize map if coordinates exist
|
||||
</div>`, container);
|
||||
if (hasCoords && typeof L !== 'undefined') {
|
||||
const map = L.map('header-map', {
|
||||
zoomControl: false, dragging: false, scrollWheelZoom: false,
|
||||
@@ -283,7 +300,7 @@ function renderAdoptionSection(node, config) {
|
||||
if (!isOperator && !isAdmin) {
|
||||
if (node.adopted_by) {
|
||||
const ownerName = node.adopted_by.name || node.adopted_by.user_id;
|
||||
return html`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
return html`<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('nodes.ownership')}</h2>
|
||||
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
|
||||
@@ -302,7 +319,7 @@ function renderAdoptionSection(node, config) {
|
||||
? html`<button class="btn btn-sm btn-outline btn-error btn-release-node">${t('nodes.release')}</button>`
|
||||
: nothing;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
return html`<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('nodes.ownership')}</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -313,7 +330,7 @@ function renderAdoptionSection(node, config) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
return html`<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('nodes.ownership')}</h2>
|
||||
<p class="text-sm opacity-70">${t('nodes.not_adopted')}</p>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { apiGet } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
truncateKey, warningBadge,
|
||||
pagination, timezoneIndicator,
|
||||
warningBadge,
|
||||
pagination,
|
||||
createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
@@ -12,14 +12,11 @@ export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
const search = query.search || '';
|
||||
const adv_type = query.adv_type || '';
|
||||
const member_id = query.member_id || '';
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
const showMembers = features.members !== false;
|
||||
const tz = config.timezone || '';
|
||||
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
|
||||
const navigate = (url) => router.navigate(url);
|
||||
@@ -49,39 +46,15 @@ export async function render(container, params, router) {
|
||||
${displayContent}`, container);
|
||||
}
|
||||
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
];
|
||||
if (showMembers) {
|
||||
requests.push(apiGet('/api/v1/members', { limit: 100 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
const data = results[0];
|
||||
const membersData = showMembers ? results[1] : null;
|
||||
const data = await apiGet('/api/v1/nodes', { limit, offset, search, adv_type });
|
||||
|
||||
const nodes = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const members = membersData?.items || [];
|
||||
|
||||
const membersFilter = (showMembers && members.length > 0)
|
||||
? html`
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">${t('entities.member')}</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
|
||||
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
|
||||
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const mobileCards = nodes.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
|
||||
@@ -90,11 +63,6 @@ ${displayContent}`, container);
|
||||
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
|
||||
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
|
||||
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
|
||||
const memberBlock = (showMembers && member)
|
||||
? html`<div class="text-xs opacity-60">${member.name}</div>`
|
||||
: nothing;
|
||||
return html`<a href="/nodes/${node.public_key}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@@ -107,26 +75,19 @@ ${displayContent}`, container);
|
||||
})}
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">${lastSeen}</div>
|
||||
${memberBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
});
|
||||
|
||||
const tableColspan = showMembers ? 4 : 3;
|
||||
const tableRows = nodes.length === 0
|
||||
? html`<tr><td colspan="${tableColspan}" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
|
||||
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
|
||||
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
|
||||
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
|
||||
const memberBlock = member
|
||||
? html`${member.name}${member.callsign ? html` <span class="opacity-60">(${member.callsign})</span>` : nothing}`
|
||||
: html`<span class="opacity-50">-</span>`;
|
||||
return html`<tr class="hover">
|
||||
<td>
|
||||
<a href="/nodes/${node.public_key}" class="link link-hover">
|
||||
@@ -145,12 +106,11 @@ ${displayContent}`, container);
|
||||
title="Click to copy">${node.public_key}</code>
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
|
||||
${showMembers ? html`<td class="text-sm">${memberBlock}</td>` : nothing}
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/nodes', {
|
||||
search, adv_type, member_id, limit,
|
||||
search, adv_type, limit,
|
||||
});
|
||||
|
||||
renderPage(html`
|
||||
@@ -175,7 +135,6 @@ ${displayContent}`, container);
|
||||
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
|
||||
</select>
|
||||
</div>
|
||||
${membersFilter}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
|
||||
@@ -195,7 +154,6 @@ ${displayContent}`, container);
|
||||
<th>${t('entities.node')}</th>
|
||||
<th>${t('common.public_key')}</th>
|
||||
<th>${t('common.last_seen')}</th>
|
||||
${showMembers ? html`<th>${t('entities.member')}</th>` : nothing}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -19,9 +19,69 @@ function renderAdoptedNode(node) {
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderRoleBadges(roles) {
|
||||
if (!roles || roles.length === 0) return nothing;
|
||||
return html`<div class="flex gap-2 mt-2">${roles.map(role => html`<span class="badge badge-primary badge-sm">${role}</span>`)}</div>`;
|
||||
}
|
||||
|
||||
function hasOperatorOrAdmin(roles, config) {
|
||||
const roleNames = config.role_names || {};
|
||||
const operatorRole = roleNames.operator || 'operator';
|
||||
const adminRole = roleNames.admin || 'admin';
|
||||
return roles && (roles.includes(operatorRole) || roles.includes(adminRole));
|
||||
}
|
||||
|
||||
function renderProfileDetails(profile, config) {
|
||||
const memberSince = profile.created_at
|
||||
? html`<p class="text-sm opacity-60 mt-2">${t('user_profile.member_since', { date: formatDateTime(profile.created_at, { year: 'numeric', month: 'long', day: 'numeric' }) })}</p>`
|
||||
: nothing;
|
||||
|
||||
const adoptedSection = hasOperatorOrAdmin(profile.roles, config)
|
||||
? html`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('user_profile.adopted_nodes')}</h2>
|
||||
${profile.nodes && profile.nodes.length > 0
|
||||
? html`<div class="space-y-2">${profile.nodes.map(n => renderAdoptedNode(n))}</div>`
|
||||
: html`<p class="text-base-content/60 text-sm py-4">${t('user_profile.no_adopted_nodes')}</p>`}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
return html`${memberSince}${adoptedSection}`;
|
||||
}
|
||||
|
||||
function renderPublicProfile(profile, config, target) {
|
||||
const isOwner = config.user && profile.user_id && config.user.sub === profile.user_id;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('user_profile.title')}</h1>
|
||||
${isOwner ? html`<a href="/profile" class="btn btn-primary btn-sm">${t('user_profile.edit_profile')}</a>` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${profile.name || t('common.unnamed')}</h2>
|
||||
${profile.callsign ? html`<span class="badge badge-neutral">${profile.callsign}</span>` : nothing}
|
||||
${renderRoleBadges(profile.roles)}
|
||||
${renderProfileDetails(profile, config)}
|
||||
</div>
|
||||
</div>`, target);
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const config = getConfig();
|
||||
|
||||
if (params.id) {
|
||||
try {
|
||||
const profile = await apiGet(`/api/v1/user/profile/${params.id}`);
|
||||
renderPublicProfile(profile, config, container);
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.oidc_enabled || !config.user) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
@@ -33,18 +93,13 @@ export async function render(container, params, router) {
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = config.user.sub;
|
||||
const profilePath = `/api/v1/user/profile/${encodeURIComponent(userId)}`;
|
||||
const profile = await apiGet(profilePath);
|
||||
const profile = await apiGet('/api/v1/user/profile/me');
|
||||
const profilePath = `/api/v1/user/profile/${profile.id}`;
|
||||
|
||||
const flashMessage = (params.query && params.query.message) || '';
|
||||
const flashError = (params.query && params.query.error) || '';
|
||||
const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
|
||||
|
||||
const nodesHtml = profile.nodes && profile.nodes.length > 0
|
||||
? html`<div class="space-y-2">${profile.nodes.map(n => renderAdoptedNode(n))}</div>`
|
||||
: html`<p class="text-base-content/60 text-sm py-4">${t('user_profile.no_adopted_nodes')}</p>`;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('user_profile.title')}</h1>
|
||||
@@ -54,33 +109,40 @@ ${flashHtml}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('user_profile.your_profile')}</h2>
|
||||
<form id="profile-form" class="py-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('user_profile.name_label')}</span></label>
|
||||
<input type="text" name="name" class="input input-bordered"
|
||||
value=${profile.name || ''}
|
||||
placeholder=${t('user_profile.name_placeholder')} maxlength="255" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('user_profile.callsign_label')}</span></label>
|
||||
<input type="text" name="callsign" class="input input-bordered"
|
||||
value=${profile.callsign || ''}
|
||||
placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('user_profile.save_profile')}</button>
|
||||
</form>
|
||||
<div>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('user_profile.your_profile')}</h2>
|
||||
${renderRoleBadges(profile.roles)}
|
||||
<form id="profile-form" class="py-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('user_profile.name_label')}</span></label>
|
||||
<input type="text" name="name" class="input input-bordered"
|
||||
value=${profile.name || ''}
|
||||
placeholder=${t('user_profile.name_placeholder')} maxlength="255" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('user_profile.callsign_label')}</span></label>
|
||||
<input type="text" name="callsign" class="input input-bordered"
|
||||
value=${profile.callsign || ''}
|
||||
placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('user_profile.save_profile')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
${renderProfileDetails(profile, config)}
|
||||
</div>
|
||||
|
||||
${hasOperatorOrAdmin(profile.roles, config) ? html`
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('user_profile.adopted_nodes')}</h2>
|
||||
${nodesHtml}
|
||||
${profile.nodes && profile.nodes.length > 0
|
||||
? html`<div class="space-y-2">${profile.nodes.map(n => renderAdoptedNode(n))}</div>`
|
||||
: html`<p class="text-base-content/60 text-sm py-4">${t('user_profile.no_adopted_nodes')}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
|
||||
</div>`, container);
|
||||
|
||||
|
||||
@@ -179,10 +179,8 @@
|
||||
"select_destination_node": "-- Select destination node --"
|
||||
},
|
||||
"members": {
|
||||
"empty_state_description": "To display network members, create a members.yaml file in your seed directory.",
|
||||
"members_file_format": "Members File Format",
|
||||
"members_file_description": "Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:",
|
||||
"members_import_instructions": "Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>."
|
||||
"empty_state_description": "No members yet.",
|
||||
"empty_description": "Members will appear here once users log in and adopt nodes."
|
||||
},
|
||||
"not_found": {
|
||||
"description": "The page you're looking for doesn't exist or has been moved."
|
||||
@@ -195,7 +193,6 @@
|
||||
"admin_not_enabled": "The admin interface is not enabled.",
|
||||
"admin_enable_hint": "Set <code>OIDC_ENABLED=true</code> and configure your OIDC provider to enable admin features.",
|
||||
"welcome": "Welcome to the admin panel.",
|
||||
"members_description": "Manage network members and operators.",
|
||||
"tags_description": "Manage custom tags and metadata for network nodes."
|
||||
},
|
||||
"auth": {
|
||||
@@ -220,13 +217,19 @@
|
||||
"callsign_placeholder": "Amateur radio callsign (e.g., W1ABC)",
|
||||
"adopted_nodes": "Adopted Nodes",
|
||||
"no_adopted_nodes": "No adopted nodes",
|
||||
"login_to_view": "Log in to view your profile"
|
||||
"login_to_view": "Log in to view your profile",
|
||||
"view_profile": "View Profile",
|
||||
"edit_profile": "Edit Profile",
|
||||
"role_operator": "Operator",
|
||||
"role_member": "Member",
|
||||
"member_since": "Member since {{date}}"
|
||||
},
|
||||
"admin_members": {
|
||||
"network_members": "Network Members ({{count}})",
|
||||
"member_id": "Member ID",
|
||||
"member_id_hint": "Unique identifier (letters, numbers, underscore)",
|
||||
"empty_state_hint": "Click \"Add Member\" to create the first member."
|
||||
"members_page": {
|
||||
"operators": "Operators",
|
||||
"members": "Members",
|
||||
"node_count": "{{count}} nodes",
|
||||
"empty_state": "No members yet",
|
||||
"empty_description": "Members will appear here once users log in and adopt nodes."
|
||||
},
|
||||
"admin_node_tags": {
|
||||
"select_node": "Select Node",
|
||||
|
||||
@@ -21,7 +21,6 @@ from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
Base,
|
||||
Member,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
@@ -284,23 +283,6 @@ def sample_trace_path(api_db_session):
|
||||
return trace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_member(api_db_session):
|
||||
"""Create a sample member in the database."""
|
||||
member = Member(
|
||||
member_id="alice",
|
||||
name="Alice Smith",
|
||||
callsign="W1ABC",
|
||||
role="Admin",
|
||||
description="Network administrator",
|
||||
contact="alice@example.com",
|
||||
)
|
||||
api_db_session.add(member)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(member)
|
||||
return member
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def receiver_node(api_db_session):
|
||||
"""Create a receiver node in the database."""
|
||||
@@ -405,29 +387,6 @@ def sample_node_with_name_tag(api_db_session):
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_with_member_tag(api_db_session):
|
||||
"""Create a node with a member_id tag for filter testing."""
|
||||
node = Node(
|
||||
public_key="member123member123member123membe",
|
||||
name="Member Node",
|
||||
adv_type="CHAT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
api_db_session.add(node)
|
||||
api_db_session.commit()
|
||||
|
||||
tag = NodeTag(
|
||||
node_id=node.id,
|
||||
key="member_id",
|
||||
value="alice",
|
||||
)
|
||||
api_db_session.add(tag)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_profile(api_db_session):
|
||||
"""Create a sample user profile in the database."""
|
||||
|
||||
@@ -214,33 +214,6 @@ class TestListAdvertisementsFilters:
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_filter_by_member_id(
|
||||
self, client_no_auth, api_db_session, sample_node_with_member_tag
|
||||
):
|
||||
"""Test filtering advertisements by member_id tag."""
|
||||
# Create an advertisement for the node with member tag
|
||||
advert = Advertisement(
|
||||
public_key=sample_node_with_member_tag.public_key,
|
||||
name="Member Node Ad",
|
||||
adv_type="CHAT",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
node_id=sample_node_with_member_tag.id,
|
||||
)
|
||||
api_db_session.add(advert)
|
||||
api_db_session.commit()
|
||||
|
||||
# Filter by member_id
|
||||
response = client_no_auth.get("/api/v1/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/advertisements?member_id=unknown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
def test_filter_by_since(self, client_no_auth, api_db_session):
|
||||
"""Test filtering advertisements by since timestamp."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
"""Tests for member API routes."""
|
||||
|
||||
|
||||
class TestListMembers:
|
||||
"""Tests for GET /members endpoint."""
|
||||
|
||||
def test_list_members_empty(self, client_no_auth):
|
||||
"""Test listing members when database is empty."""
|
||||
response = client_no_auth.get("/api/v1/members")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_members_with_data(self, client_no_auth, sample_member):
|
||||
"""Test listing members with data in database."""
|
||||
response = client_no_auth.get("/api/v1/members")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["member_id"] == sample_member.member_id
|
||||
assert data["items"][0]["name"] == sample_member.name
|
||||
assert data["items"][0]["callsign"] == sample_member.callsign
|
||||
|
||||
def test_list_members_pagination(self, client_no_auth, sample_member):
|
||||
"""Test member list pagination parameters."""
|
||||
response = client_no_auth.get("/api/v1/members?limit=25&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["limit"] == 25
|
||||
assert data["offset"] == 10
|
||||
|
||||
def test_list_members_requires_read_auth(self, client_with_auth):
|
||||
"""Test listing members requires read auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get("/api/v1/members")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
"/api/v1/members",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetMember:
|
||||
"""Tests for GET /members/{member_id} endpoint."""
|
||||
|
||||
def test_get_member_success(self, client_no_auth, sample_member):
|
||||
"""Test getting a specific member."""
|
||||
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["member_id"] == sample_member.member_id
|
||||
assert data["name"] == sample_member.name
|
||||
assert data["callsign"] == sample_member.callsign
|
||||
assert data["role"] == sample_member.role
|
||||
assert data["description"] == sample_member.description
|
||||
assert data["contact"] == sample_member.contact
|
||||
|
||||
def test_get_member_not_found(self, client_no_auth):
|
||||
"""Test getting a non-existent member."""
|
||||
response = client_no_auth.get("/api/v1/members/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_get_member_requires_read_auth(self, client_with_auth, sample_member):
|
||||
"""Test getting a member requires read auth when configured."""
|
||||
# Without auth header
|
||||
response = client_with_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestCreateMember:
|
||||
"""Tests for POST /members endpoint."""
|
||||
|
||||
def test_create_member_success(self, client_no_auth):
|
||||
"""Test creating a new member."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": "bob",
|
||||
"name": "Bob Jones",
|
||||
"callsign": "W2XYZ",
|
||||
"role": "Member",
|
||||
"description": "Regular member",
|
||||
"contact": "bob@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["member_id"] == "bob"
|
||||
assert data["name"] == "Bob Jones"
|
||||
assert data["callsign"] == "W2XYZ"
|
||||
assert data["role"] == "Member"
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_member_minimal(self, client_no_auth):
|
||||
"""Test creating a member with only required fields."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": "charlie",
|
||||
"name": "Charlie Brown",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["member_id"] == "charlie"
|
||||
assert data["name"] == "Charlie Brown"
|
||||
assert data["callsign"] is None
|
||||
assert data["role"] is None
|
||||
|
||||
def test_create_member_duplicate_member_id(self, client_no_auth, sample_member):
|
||||
"""Test creating a member with duplicate member_id fails."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/members",
|
||||
json={
|
||||
"member_id": sample_member.member_id, # "alice" already exists
|
||||
"name": "Another Alice",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
def test_create_member_requires_admin_auth(self, client_with_auth):
|
||||
"""Test creating a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.post(
|
||||
"/api/v1/members",
|
||||
json={"member_id": "test", "name": "Test User"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestUpdateMember:
|
||||
"""Tests for PUT /members/{member_id} endpoint."""
|
||||
|
||||
def test_update_member_success(self, client_no_auth, sample_member):
|
||||
"""Test updating a member."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={
|
||||
"name": "Alice Johnson",
|
||||
"role": "Super Admin",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Alice Johnson"
|
||||
assert data["role"] == "Super Admin"
|
||||
# Unchanged fields should remain
|
||||
assert data["member_id"] == sample_member.member_id
|
||||
assert data["callsign"] == sample_member.callsign
|
||||
|
||||
def test_update_member_change_member_id(self, client_no_auth, sample_member):
|
||||
"""Test updating member_id."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"member_id": "alice2"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["member_id"] == "alice2"
|
||||
|
||||
def test_update_member_member_id_collision(
|
||||
self, client_no_auth, api_db_session, sample_member
|
||||
):
|
||||
"""Test updating member_id to one that already exists fails."""
|
||||
from meshcore_hub.common.models import Member
|
||||
|
||||
# Create another member
|
||||
other_member = Member(
|
||||
member_id="bob",
|
||||
name="Bob",
|
||||
)
|
||||
api_db_session.add(other_member)
|
||||
api_db_session.commit()
|
||||
|
||||
# Try to change alice's member_id to "bob"
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"member_id": "bob"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already exists" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_member_not_found(self, client_no_auth):
|
||||
"""Test updating a non-existent member."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/members/nonexistent-id",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_member_requires_admin_auth(self, client_with_auth, sample_member):
|
||||
"""Test updating a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.put(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestDeleteMember:
|
||||
"""Tests for DELETE /members/{member_id} endpoint."""
|
||||
|
||||
def test_delete_member_success(self, client_no_auth, sample_member):
|
||||
"""Test deleting a member."""
|
||||
response = client_no_auth.delete(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's deleted
|
||||
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_member_not_found(self, client_no_auth):
|
||||
"""Test deleting a non-existent member."""
|
||||
response = client_no_auth.delete("/api/v1/members/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_member_requires_admin_auth(self, client_with_auth, sample_member):
|
||||
"""Test deleting a member requires admin auth."""
|
||||
# Without auth
|
||||
response = client_with_auth.delete(f"/api/v1/members/{sample_member.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With read key (not admin)
|
||||
response = client_with_auth.delete(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# With admin key
|
||||
response = client_with_auth.delete(
|
||||
f"/api/v1/members/{sample_member.id}",
|
||||
headers={"Authorization": "Bearer test-admin-key"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
@@ -56,7 +56,8 @@ class TestMetricsEndpoint:
|
||||
assert "meshcore_advertisements_total" in content
|
||||
assert "meshcore_telemetry_total" in content
|
||||
assert "meshcore_trace_paths_total" in content
|
||||
assert "meshcore_members_total" in content
|
||||
assert "meshcore_user_profiles_total" in content
|
||||
assert "meshcore_user_profiles_by_role" in content
|
||||
|
||||
def test_metrics_info_has_version(self, client_no_auth):
|
||||
"""Test that meshcore_info includes version label."""
|
||||
@@ -147,13 +148,6 @@ class TestMetricsData:
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_advertisements_total 1.0" in response.text
|
||||
|
||||
def test_members_total_reflects_database(self, client_no_auth, sample_member):
|
||||
"""Test that members_total reflects database state."""
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "meshcore_members_total 1.0" in response.text
|
||||
|
||||
def test_nodes_by_type_has_labels(self, client_no_auth, sample_node):
|
||||
"""Test that nodes_by_type includes adv_type labels."""
|
||||
_clear_metrics_cache()
|
||||
|
||||
@@ -168,20 +168,6 @@ class TestListNodesFilters:
|
||||
assert room_node.public_key in room_keys
|
||||
assert name_only_room_node.public_key not in room_keys
|
||||
|
||||
def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag):
|
||||
"""Test filtering nodes by member_id tag."""
|
||||
# Match alice
|
||||
response = client_no_auth.get("/api/v1/nodes?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
# No match
|
||||
response = client_no_auth.get("/api/v1/nodes?member_id=unknown")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""Tests for GET /nodes/{public_key} endpoint."""
|
||||
|
||||
@@ -23,66 +23,145 @@ NO_ROLES_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
class TestGetProfile:
|
||||
"""Tests for GET /user/profile/{user_id} endpoint."""
|
||||
class TestListProfiles:
|
||||
"""Tests for GET /user/profiles endpoint."""
|
||||
|
||||
def test_get_profile_auto_creates(self, client_no_auth):
|
||||
"""Test getting a non-existent profile auto-creates it."""
|
||||
def test_list_profiles_returns_list(self, client_no_auth, sample_user_profile):
|
||||
"""Test listing profiles returns a paginated list."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
"/api/v1/user/profiles",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["name"] is None
|
||||
assert data["callsign"] is None
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert data["nodes"] == []
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert data["total"] >= 1
|
||||
|
||||
def test_get_profile_auto_creates_with_name(self, client_no_auth):
|
||||
"""Test auto-created profile is populated with name from IdP."""
|
||||
def test_list_profiles_no_user_id(self, client_no_auth, sample_user_profile):
|
||||
"""Test that list profiles does not expose user_id."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers=USER_HEADERS_WITH_NAME,
|
||||
"/api/v1/user/profiles",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["name"] == "IdP Display Name"
|
||||
assert data["callsign"] is None
|
||||
for item in data["items"]:
|
||||
assert "user_id" not in item
|
||||
|
||||
def test_get_profile_does_not_overwrite_existing_name(
|
||||
def test_list_profiles_includes_roles(self, client_no_auth, sample_user_profile):
|
||||
"""Test that list profiles includes roles."""
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/user/profiles",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
assert "roles" in data["items"][0]
|
||||
|
||||
def test_list_profiles_includes_node_count(
|
||||
self, client_no_auth, sample_user_profile, sample_adopted_node, sample_node
|
||||
):
|
||||
"""Test that list profiles includes node_count and adopted_nodes."""
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/user/profiles",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
profile = next(p for p in data["items"] if p["id"] == sample_user_profile.id)
|
||||
assert profile["node_count"] == 1
|
||||
assert len(profile["adopted_nodes"]) == 1
|
||||
assert profile["adopted_nodes"][0]["name"] == sample_node.name
|
||||
|
||||
|
||||
class TestGetMyProfile:
|
||||
"""Tests for GET /user/profile/me endpoint."""
|
||||
|
||||
def test_get_my_profile_returns_full_profile(
|
||||
self, client_no_auth, sample_user_profile
|
||||
):
|
||||
"""Test that IdP name does not overwrite an existing profile name."""
|
||||
"""Test /me returns the full profile with user_id for authenticated user."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
headers=USER_HEADERS_WITH_NAME,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == sample_user_profile.name
|
||||
|
||||
def test_get_existing_profile(self, client_no_auth, sample_user_profile):
|
||||
"""Test getting an existing profile."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
"/api/v1/user/profile/me",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["name"] == sample_user_profile.name
|
||||
|
||||
def test_get_my_profile_auto_creates(self, client_no_auth):
|
||||
"""Test /me auto-creates profile for new user."""
|
||||
new_headers = {
|
||||
"X-User-Id": "brand-new-user",
|
||||
"X-User-Roles": "member",
|
||||
"X-User-Name": "New User",
|
||||
}
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/user/profile/me",
|
||||
headers=new_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == "brand-new-user"
|
||||
assert data["name"] == "New User"
|
||||
|
||||
def test_get_my_profile_requires_user_id(self, client_no_auth):
|
||||
"""Test /me returns 401 without X-User-Id header."""
|
||||
response = client_no_auth.get("/api/v1/user/profile/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestGetProfile:
|
||||
"""Tests for GET /user/profile/{profile_id} endpoint."""
|
||||
|
||||
def test_get_profile_auto_creates_for_owner(self, client_no_auth):
|
||||
"""Test getting a non-existent profile auto-creates it for the owner."""
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/user/profile/nonexistent-uuid",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_existing_profile_by_uuid(self, client_no_auth, sample_user_profile):
|
||||
"""Test getting an existing profile by UUID."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == sample_user_profile.user_id
|
||||
assert data["name"] == sample_user_profile.name
|
||||
assert data["callsign"] == sample_user_profile.callsign
|
||||
|
||||
def test_get_profile_public_no_auth(self, client_no_auth, sample_user_profile):
|
||||
"""Test that profile can be viewed without authentication."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "user_id" not in data
|
||||
assert data["name"] == sample_user_profile.name
|
||||
|
||||
def test_get_profile_owner_sees_user_id(self, client_no_auth, sample_user_profile):
|
||||
"""Test that owner sees user_id in their own profile."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data.get("user_id") == sample_user_profile.user_id
|
||||
|
||||
def test_get_profile_with_adopted_nodes(
|
||||
self, client_no_auth, sample_user_profile, sample_adopted_node
|
||||
):
|
||||
"""Test profile includes adopted nodes."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -91,47 +170,32 @@ class TestGetProfile:
|
||||
assert data["nodes"][0]["public_key"] == "abc123def456abc123def456abc123de"
|
||||
assert "adopted_at" in data["nodes"][0]
|
||||
|
||||
def test_get_profile_rejects_wrong_user(self, client_no_auth):
|
||||
"""Test that a user cannot access another user's profile."""
|
||||
def test_get_profile_returns_roles(self, client_no_auth, sample_user_profile):
|
||||
"""Test that profile includes roles."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers=OTHER_USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "access denied" in response.json()["detail"].lower()
|
||||
|
||||
def test_get_profile_rejects_missing_user_id(self, client_no_auth):
|
||||
"""Test that missing X-User-Id header is rejected."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_profile_requires_auth(self, client_with_auth):
|
||||
"""Test getting profile requires auth when keys configured."""
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers={
|
||||
**USER_HEADERS,
|
||||
"Authorization": "Bearer test-read-key",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "roles" in data
|
||||
assert "operator" in data["roles"]
|
||||
|
||||
def test_get_profile_not_found(self, client_no_auth):
|
||||
"""Test 404 for non-existent profile UUID."""
|
||||
response = client_no_auth.get(
|
||||
"/api/v1/user/profile/00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateProfile:
|
||||
"""Tests for PUT /user/profile/{user_id} endpoint."""
|
||||
"""Tests for PUT /user/profile/{profile_id} endpoint."""
|
||||
|
||||
def test_update_profile_name(self, client_no_auth, sample_user_profile):
|
||||
"""Test updating profile name."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
json={"name": "New Name"},
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
@@ -143,7 +207,7 @@ class TestUpdateProfile:
|
||||
def test_update_profile_callsign(self, client_no_auth, sample_user_profile):
|
||||
"""Test updating profile callsign."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
json={"callsign": "G1NEW"},
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
@@ -152,32 +216,32 @@ class TestUpdateProfile:
|
||||
assert data["callsign"] == "G1NEW"
|
||||
assert data["name"] == sample_user_profile.name
|
||||
|
||||
def test_update_profile_auto_creates_with_name(self, client_no_auth):
|
||||
"""Test updating a non-existent profile auto-creates it with IdP name."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
json={"callsign": "W1AUTO"},
|
||||
headers=USER_HEADERS_WITH_NAME,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["name"] == "IdP Display Name"
|
||||
assert data["callsign"] == "W1AUTO"
|
||||
|
||||
def test_update_profile_rejects_wrong_user(self, client_no_auth):
|
||||
def test_update_profile_rejects_wrong_user(
|
||||
self, client_no_auth, sample_user_profile
|
||||
):
|
||||
"""Test that a user cannot update another user's profile."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
json={"name": "Hacked"},
|
||||
headers=OTHER_USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_update_profile_rejects_missing_user_id(self, client_no_auth):
|
||||
def test_update_profile_rejects_missing_user_id(
|
||||
self, client_no_auth, sample_user_profile
|
||||
):
|
||||
"""Test that missing X-User-Id header is rejected."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
json={"name": "No Auth"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_update_profile_not_found(self, client_no_auth):
|
||||
"""Test 404 for updating non-existent profile."""
|
||||
response = client_no_auth.put(
|
||||
"/api/v1/user/profile/00000000-0000-0000-0000-000000000000",
|
||||
json={"name": "Ghost"},
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -58,7 +58,6 @@ class TestCollectorSettings:
|
||||
assert settings.seed_home == "/seed/data"
|
||||
assert settings.effective_seed_home == "/seed/data"
|
||||
assert settings.node_tags_file == "/seed/data/node_tags.yaml"
|
||||
assert settings.members_file == "/seed/data/members.yaml"
|
||||
|
||||
def test_collector_channel_keys_list(self) -> None:
|
||||
"""Channel keys are parsed from comma/space-separated env values."""
|
||||
|
||||
@@ -107,11 +107,9 @@ class TestEnJsonCompleteness:
|
||||
"advertisements",
|
||||
"messages",
|
||||
"map",
|
||||
"members",
|
||||
"not_found",
|
||||
"custom_page",
|
||||
"admin",
|
||||
"admin_members",
|
||||
"admin_node_tags",
|
||||
"footer",
|
||||
]
|
||||
|
||||
@@ -460,114 +460,3 @@ def client_no_features(
|
||||
"""Create a test client with all features disabled."""
|
||||
web_app_no_features.state.http_client = mock_http_client
|
||||
return TestClient(web_app_no_features, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client_with_members() -> MockHttpClient:
|
||||
"""Create a mock HTTP client with members data."""
|
||||
client = MockHttpClient()
|
||||
# Mock the members API response (no nodes in the response anymore)
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/members",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "member-1",
|
||||
"member_id": "alice",
|
||||
"name": "Alice",
|
||||
"callsign": "W1ABC",
|
||||
"role": "Admin",
|
||||
"description": None,
|
||||
"contact": "alice@example.com",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "member-2",
|
||||
"member_id": "bob",
|
||||
"name": "Bob",
|
||||
"callsign": "W2XYZ",
|
||||
"role": "Member",
|
||||
"description": None,
|
||||
"contact": None,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
# Mock the nodes API response with has_tag filter
|
||||
# This will be called to get nodes with member_id tags
|
||||
client.set_response(
|
||||
"GET",
|
||||
"/api/v1/nodes",
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
||||
"name": "Alice's Node",
|
||||
"adv_type": "chat",
|
||||
"flags": None,
|
||||
"first_seen": "2024-01-01T00:00:00Z",
|
||||
"last_seen": "2024-01-01T00:00:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"tags": [
|
||||
{
|
||||
"key": "member_id",
|
||||
"value": "alice",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "Alice Chat",
|
||||
"value_type": "string",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any:
|
||||
"""Create a web app with members API responses configured."""
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
network_name="Test Network",
|
||||
network_city="Test City",
|
||||
network_country="Test Country",
|
||||
network_radio_config="Test Radio Config",
|
||||
network_contact_email="test@example.com",
|
||||
network_contact_discord="https://discord.gg/test",
|
||||
features=ALL_FEATURES_ENABLED,
|
||||
)
|
||||
|
||||
app.state.http_client = mock_http_client_with_members
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_members(
|
||||
web_app_with_members: Any, mock_http_client_with_members: MockHttpClient
|
||||
) -> TestClient:
|
||||
"""Create a test client with members API responses configured."""
|
||||
web_app_with_members.state.http_client = mock_http_client_with_members
|
||||
return TestClient(web_app_with_members, raise_server_exceptions=True)
|
||||
|
||||
@@ -46,11 +46,6 @@ class TestAdvertisementsPageFilters:
|
||||
response = client.get("/advertisements?search=node")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_member_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with member_id filter returns SPA shell."""
|
||||
response = client.get("/advertisements?member_id=alice")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_advertisements_with_public_key_filter(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with public_key filter returns SPA shell."""
|
||||
response = client.get(
|
||||
@@ -71,7 +66,7 @@ class TestAdvertisementsPageFilters:
|
||||
def test_advertisements_with_all_filters(self, client: TestClient) -> None:
|
||||
"""Test advertisements page with multiple filters returns SPA shell."""
|
||||
response = client.get(
|
||||
"/advertisements?search=test&member_id=alice&page=1&limit=10"
|
||||
"/advertisements?search=test&public_key=abc123&page=1&limit=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Tests for the members page route (SPA)."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestMembersPage:
|
||||
"""Tests for the members page."""
|
||||
|
||||
def test_members_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that members page returns 200 status code."""
|
||||
response = client.get("/members")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_members_returns_html(self, client: TestClient) -> None:
|
||||
"""Test that members page returns HTML content."""
|
||||
response = client.get("/members")
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
def test_members_contains_network_name(self, client: TestClient) -> None:
|
||||
"""Test that members page contains the network name."""
|
||||
response = client.get("/members")
|
||||
assert "Test Network" in response.text
|
||||
|
||||
def test_members_contains_app_config(self, client: TestClient) -> None:
|
||||
"""Test that members page contains SPA config."""
|
||||
response = client.get("/members")
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_members_contains_spa_script(self, client: TestClient) -> None:
|
||||
"""Test that members page includes SPA application script."""
|
||||
response = client.get("/members")
|
||||
assert "/static/js/spa/app.js" in response.text
|
||||
|
||||
def test_members_config_has_network_name(self, client: TestClient) -> None:
|
||||
"""Test that SPA config includes network name."""
|
||||
response = client.get("/members")
|
||||
text = response.text
|
||||
config_start = text.find("window.__APP_CONFIG__ = ") + len(
|
||||
"window.__APP_CONFIG__ = "
|
||||
)
|
||||
config_end = text.find(";", config_start)
|
||||
config = json.loads(text[config_start:config_end])
|
||||
|
||||
assert config["network_name"] == "Test Network"
|
||||
@@ -196,25 +196,29 @@ class TestAPIProxyWriteGating:
|
||||
def test_post_blocked_for_member(
|
||||
self, client_with_oidc_member_session: TestClient
|
||||
) -> None:
|
||||
"""Test POST blocked for member session."""
|
||||
"""Test POST to admin endpoint blocked for member session."""
|
||||
response = client_with_oidc_member_session.post(
|
||||
"/api/v1/members", json={"name": "test"}
|
||||
"/api/v1/nodes/some-node/tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_post_allowed_for_admin(
|
||||
def test_put_allowed_for_admin(
|
||||
self, client_with_oidc_admin_session: TestClient
|
||||
) -> None:
|
||||
"""Test POST allowed for admin session."""
|
||||
response = client_with_oidc_admin_session.post(
|
||||
"/api/v1/members",
|
||||
"""Test PUT allowed for admin session."""
|
||||
response = client_with_oidc_admin_session.put(
|
||||
"/api/v1/user/profile/test-user-id",
|
||||
json={"name": "test"},
|
||||
)
|
||||
assert response.status_code != 403
|
||||
|
||||
def test_write_blocked_when_oidc_disabled(self, client: TestClient) -> None:
|
||||
"""Test write methods blocked when OIDC is disabled."""
|
||||
response = client.post("/api/v1/members", json={"name": "test"})
|
||||
response = client.put(
|
||||
"/api/v1/user/profile/test-user-id",
|
||||
json={"name": "test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_read_open_when_oidc_disabled(self, client: TestClient) -> None:
|
||||
|
||||
Reference in New Issue
Block a user