diff --git a/AGENTS.md b/AGENTS.md index f9daac2..1a68b0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 8f3ac02..783ad9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/alembic/versions/20260430_0924_a7eaa878e58b_add_roles_to_user_profiles_drop_members.py b/alembic/versions/20260430_0924_a7eaa878e58b_add_roles_to_user_profiles_drop_members.py new file mode 100644 index 0000000..fb6593e --- /dev/null +++ b/alembic/versions/20260430_0924_a7eaa878e58b_add_roles_to_user_profiles_drop_members.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 02df9c2..a4c83e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] # ========================================================================== diff --git a/docs/plans/20260430-0805-members-refactor/plan.md b/docs/plans/20260430-0805-members-refactor/plan.md new file mode 100644 index 0000000..71d4f96 --- /dev/null +++ b/docs/plans/20260430-0805-members-refactor/plan.md @@ -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` diff --git a/docs/plans/20260430-0805-members-refactor/tasks.md b/docs/plans/20260430-0805-members-refactor/tasks.md new file mode 100644 index 0000000..5a3892f --- /dev/null +++ b/docs/plans/20260430-0805-members-refactor/tasks.md @@ -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 | diff --git a/docs/seeding.md b/docs/seeding.md index 896c6fc..500bd2a 100644 --- a/docs/seeding.md +++ b/docs/seeding.md @@ -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) | diff --git a/docs/upgrading.md b/docs/upgrading.md index 3b6749b..355a2f8 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -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. diff --git a/example/seed/members.yaml b/example/seed/members.yaml deleted file mode 100644 index da16900..0000000 --- a/example/seed/members.yaml +++ /dev/null @@ -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 diff --git a/src/meshcore_hub/api/metrics.py b/src/meshcore_hub/api/metrics.py index 53f13b8..f4046cb 100644 --- a/src/meshcore_hub/api/metrics.py +++ b/src/meshcore_hub/api/metrics.py @@ -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 diff --git a/src/meshcore_hub/api/profile_utils.py b/src/meshcore_hub/api/profile_utils.py new file mode 100644 index 0000000..d617b42 --- /dev/null +++ b/src/meshcore_hub/api/profile_utils.py @@ -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 diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py index b7f26b7..8008d26 100644 --- a/src/meshcore_hub/api/routes/__init__.py +++ b/src/meshcore_hub/api/routes/__init__.py @@ -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"]) diff --git a/src/meshcore_hub/api/routes/adoptions.py b/src/meshcore_hub/api/routes/adoptions.py index b284d1d..65336f3 100644 --- a/src/meshcore_hub/api/routes/adoptions.py +++ b/src/meshcore_hub/api/routes/adoptions.py @@ -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() diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 68d7548..7ff5184 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -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 ) ) ) diff --git a/src/meshcore_hub/api/routes/members.py b/src/meshcore_hub/api/routes/members.py deleted file mode 100644 index e1c544c..0000000 --- a/src/meshcore_hub/api/routes/members.py +++ /dev/null @@ -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() diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index febfd95..5002104 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -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 ) ) ) diff --git a/src/meshcore_hub/api/routes/user_profiles.py b/src/meshcore_hub/api/routes/user_profiles.py index 8bdc126..4eb66ab 100644 --- a/src/meshcore_hub/api/routes/user_profiles.py +++ b/src/meshcore_hub/api/routes/user_profiles.py @@ -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 diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index 5328f73..a7ec581 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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() diff --git a/src/meshcore_hub/collector/member_import.py b/src/meshcore_hub/collector/member_import.py deleted file mode 100644 index d0c0cf9..0000000 --- a/src/meshcore_hub/collector/member_import.py +++ /dev/null @@ -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 diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 55b6afa..af19d51 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -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.""" diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index 4872e09..9e9cb54 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -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", diff --git a/src/meshcore_hub/common/models/member.py b/src/meshcore_hub/common/models/member.py deleted file mode 100644 index fdf7c91..0000000 --- a/src/meshcore_hub/common/models/member.py +++ /dev/null @@ -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"" diff --git a/src/meshcore_hub/common/models/user_profile.py b/src/meshcore_hub/common/models/user_profile.py index 5ec284e..1fb7734 100644 --- a/src/meshcore_hub/common/models/user_profile.py +++ b/src/meshcore_hub/common/models/user_profile.py @@ -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"" diff --git a/src/meshcore_hub/common/schemas/__init__.py b/src/meshcore_hub/common/schemas/__init__.py index e202eaf..9664c35 100644 --- a/src/meshcore_hub/common/schemas/__init__.py +++ b/src/meshcore_hub/common/schemas/__init__.py @@ -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", ] diff --git a/src/meshcore_hub/common/schemas/members.py b/src/meshcore_hub/common/schemas/members.py deleted file mode 100644 index a444127..0000000 --- a/src/meshcore_hub/common/schemas/members.py +++ /dev/null @@ -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") diff --git a/src/meshcore_hub/common/schemas/user_profiles.py b/src/meshcore_hub/common/schemas/user_profiles.py index 0db925b..4498f5b 100644 --- a/src/meshcore_hub/common/schemas/user_profiles.py +++ b/src/meshcore_hub/common/schemas/user_profiles.py @@ -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, diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 3ebce7b..97d79c2 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -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 diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index 1d93414..1aadcd4 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -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 diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js index a976677..69bb0c3 100644 --- a/src/meshcore_hub/web/static/js/spa/icons.js +++ b/src/meshcore_hub/web/static/js/spa/icons.js @@ -101,3 +101,7 @@ export function iconTag(cls = 'h-5 w-5') { export function iconUsers(cls = 'h-5 w-5') { return html``; } + +export function iconAntenna(cls = 'h-5 w-5') { + return html``; +} diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js index d39702b..fe47550 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js @@ -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) {
- -
-

- ${iconUsers('h-6 w-6')} - ${t('entities.members')} -

-

${t('admin.members_description')}

-
-

diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js deleted file mode 100644 index 54ebf09..0000000 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js +++ /dev/null @@ -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` -`, 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` -
- - - - - - - - - - - ${members.map(m => html` - - - - - - - `)} -
${t('admin_members.member_id')}${t('common.name')}${t('common.callsign')}${t('common.contact')}${t('common.actions')}
${m.member_id}${m.name} - ${m.callsign - ? html`${m.callsign}` - : html`-`} - ${m.contact || '-'} -
- - -
-
-
` - : html` -
-

${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}

-

${t('admin_members.empty_state_hint')}

-
`; - - litRender(html` -
-
-

${t('entities.members')}

- -
-
- -${flashHtml} - -
-
-
-

${t('admin_members.network_members', { count: members.length })}

- -
- ${tableHtml} -
-
- - - - - - - - - - - - - - -`, 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); - } -} diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 7eb58a8..80a40e7 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -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`${tz}` : 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);

` : nothing; - const membersFilter = (showMembers && members.length > 0) - ? html` -
- - -
` - : nothing; - const mobileCards = advertisements.length === 0 ? html`
${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
` : advertisements.map(ad => { @@ -136,7 +112,7 @@ ${displayContent}`, container); Observer${t('common.snr_db')}Received ${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` @@ -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);
${nodesFilter} - ${membersFilter}
${t('common.clear')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index b72ee52..6639058 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -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) {
-
- - -