From db1a6aa3d5ec8fc3766f1158577c9c66348bde7c Mon Sep 17 00:00:00 2001 From: Louis King Date: Sat, 2 May 2026 19:06:50 +0100 Subject: [PATCH 1/2] Link adopted-by user name to profile page on node detail Add profile_id to AdoptedByUser API schema and populate it from the backend so the SPA can link the adopter's name to their /profile/{id} page. Style the link with text-primary to stand out from the surrounding opacity-70 text. --- src/meshcore_hub/api/routes/nodes.py | 1 + src/meshcore_hub/common/schemas/nodes.py | 1 + .../web/static/js/spa/pages/node-detail.js | 10 ++++++++-- src/meshcore_hub/web/static/locales/en.json | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 5002104..9725f28 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -22,6 +22,7 @@ def _get_adopted_by(node: Node) -> Optional[AdoptedByUser]: user_id=profile.user_id, name=profile.name, callsign=profile.callsign, + profile_id=profile.id, ) return None diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py index 3f0ef87..7952b9b 100644 --- a/src/meshcore_hub/common/schemas/nodes.py +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -78,6 +78,7 @@ class AdoptedByUser(BaseModel): user_id: str = Field(..., description="OIDC subject identifier") name: Optional[str] = Field(default=None, description="User display name") callsign: Optional[str] = Field(default=None, description="Amateur radio callsign") + profile_id: str = Field(..., description="User profile UUID for profile links") class Config: from_attributes = True diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index 5fca300..7857fe9 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -303,7 +303,10 @@ function renderAdoptionSection(node, config) { return html`

${t('nodes.ownership')}

-

${t('nodes.adopted_by', { name: ownerName })}

+

+ ${t('nodes.adopted_by_prefix')} + ${ownerName} +

`; } @@ -323,7 +326,10 @@ function renderAdoptionSection(node, config) {

${t('nodes.ownership')}

-

${t('nodes.adopted_by', { name: ownerName })}

+

+ ${t('nodes.adopted_by_prefix')} + ${ownerName} +

${releaseBtnHtml}
diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 4c5a94e..5ae02b4 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -155,6 +155,7 @@ "adopt": "Adopt", "release": "Release", "adopted_by": "Adopted by {{name}}", + "adopted_by_prefix": "Adopted by", "not_adopted": "This node has not been adopted by any operator.", "adopt_success": "Node adopted successfully", "release_success": "Node released successfully", From f2ea530c0fc27ed23e3c469bccfa9842ee44c490 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sat, 2 May 2026 23:33:25 +0100 Subject: [PATCH 2/2] feat: add description and url fields to user profiles, fix nullable field clearing - Add description (Text) and url (String 2048) columns to user_profiles - Expose in all API schemas (Read, Public, Update, ListItem) and list/get/profile endpoints - Update profile.js form: add description/url inputs, render on view page - Update members.js: render description and URL link in member tiles - Fix update handler: use model_dump(exclude_unset=True) for nullable fields while protecting name (set by IdP) from being cleared - AnyUrl validation on update, converted to str for SQLite compatibility - Add i18n keys (description_label/placeholder, url_label/placeholder) - 7 new API tests covering description/url CRUD, URL validation, null-clearing, and name non-nullability --- ...dd_description_and_url_to_user_profiles.py | 36 +++++++ docs/i18n.md | 4 + src/meshcore_hub/api/routes/user_profiles.py | 17 +++- .../common/models/user_profile.py | 10 ++ .../common/schemas/user_profiles.py | 38 +++++++- .../web/static/js/spa/pages/members.js | 10 ++ .../web/static/js/spa/pages/profile.js | 16 ++++ src/meshcore_hub/web/static/locales/en.json | 4 + tests/test_api/test_user_profiles.py | 95 +++++++++++++++++++ 9 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/20260502_2224_d7a9bbe85a9e_add_description_and_url_to_user_profiles.py diff --git a/alembic/versions/20260502_2224_d7a9bbe85a9e_add_description_and_url_to_user_profiles.py b/alembic/versions/20260502_2224_d7a9bbe85a9e_add_description_and_url_to_user_profiles.py new file mode 100644 index 0000000..1460aa1 --- /dev/null +++ b/alembic/versions/20260502_2224_d7a9bbe85a9e_add_description_and_url_to_user_profiles.py @@ -0,0 +1,36 @@ +"""add description and url to user_profiles + +Revision ID: d7a9bbe85a9e +Revises: a7eaa878e58b +Create Date: 2026-05-02 22:24:29.035729+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "d7a9bbe85a9e" +down_revision: Union[str, None] = "a7eaa878e58b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user_profiles", schema=None) as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + batch_op.add_column(sa.Column("url", sa.String(length=2048), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user_profiles", schema=None) as batch_op: + batch_op.drop_column("url") + batch_op.drop_column("description") + + # ### end Alembic commands ### diff --git a/docs/i18n.md b/docs/i18n.md index 655ff48..8e560ae 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -429,6 +429,10 @@ User profile page (OIDC authenticated users): | `name_placeholder` | Your name or preferred name | Input placeholder | | `callsign_label` | Callsign | Form label | | `callsign_placeholder` | Amateur radio callsign (e.g., W1ABC) | Input placeholder | +| `description_label` | Description | Form label | +| `description_placeholder` | A short bio or description | Input placeholder | +| `url_label` | Website / Profile Link | Form label | +| `url_placeholder` | https://... | Input placeholder | | `adopted_nodes` | Adopted Nodes | Adopted nodes card heading | | `no_adopted_nodes` | No adopted nodes | Empty state | | `login_to_view` | Log in to view your profile | Unauthenticated notice | diff --git a/src/meshcore_hub/api/routes/user_profiles.py b/src/meshcore_hub/api/routes/user_profiles.py index 4eb66ab..2024082 100644 --- a/src/meshcore_hub/api/routes/user_profiles.py +++ b/src/meshcore_hub/api/routes/user_profiles.py @@ -3,6 +3,7 @@ import logging from fastapi import APIRouter, HTTPException, Query, Request, status +from pydantic import AnyUrl from sqlalchemy import func, select from sqlalchemy.orm import selectinload @@ -83,6 +84,8 @@ async def list_profiles( name=profile.name, callsign=profile.callsign, roles=profile.role_list, + description=profile.description, + url=profile.url, node_count=len(profile.node_associations), adopted_nodes=adopted_nodes, ) @@ -115,6 +118,8 @@ async def get_my_profile( name=profile.name, callsign=profile.callsign, roles=profile.role_list, + description=profile.description, + url=profile.url, created_at=profile.created_at, updated_at=profile.updated_at, nodes=_build_adopted_nodes(profile), @@ -146,6 +151,8 @@ async def get_profile( name=profile.name, callsign=profile.callsign, roles=profile.role_list, + description=profile.description, + url=profile.url, created_at=profile.created_at, updated_at=profile.updated_at, nodes=_build_adopted_nodes(profile), @@ -164,6 +171,8 @@ async def get_profile( name=public_profile.name, callsign=public_profile.callsign, roles=public_profile.role_list, + description=public_profile.description, + url=public_profile.url, created_at=public_profile.created_at, updated_at=public_profile.updated_at, nodes=_build_adopted_nodes(public_profile), @@ -195,8 +204,12 @@ async def update_profile( if profile_update.name is not None: profile.name = profile_update.name - if profile_update.callsign is not None: - profile.callsign = profile_update.callsign + + update_data = profile_update.model_dump(exclude_unset=True, exclude={"name"}) + for field, value in update_data.items(): + if isinstance(value, AnyUrl): + value = str(value) + setattr(profile, field, value) session.commit() session.refresh(profile) diff --git a/src/meshcore_hub/common/models/user_profile.py b/src/meshcore_hub/common/models/user_profile.py index 1fb7734..9888771 100644 --- a/src/meshcore_hub/common/models/user_profile.py +++ b/src/meshcore_hub/common/models/user_profile.py @@ -23,6 +23,8 @@ class UserProfile(Base, UUIDMixin, TimestampMixin): 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) + description: User-defined short description/bio + url: Personal website or profile link created_at: Record creation timestamp updated_at: Record update timestamp """ @@ -48,6 +50,14 @@ class UserProfile(Base, UUIDMixin, TimestampMixin): nullable=True, default=None, ) + description: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + ) + url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + ) node_associations: Mapped[list["UserProfileNode"]] = relationship( "UserProfileNode", diff --git a/src/meshcore_hub/common/schemas/user_profiles.py b/src/meshcore_hub/common/schemas/user_profiles.py index 4498f5b..242ee19 100644 --- a/src/meshcore_hub/common/schemas/user_profiles.py +++ b/src/meshcore_hub/common/schemas/user_profiles.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel, Field +from pydantic import AnyUrl, BaseModel, Field class UserProfileRead(BaseModel): @@ -14,6 +14,14 @@ class UserProfileRead(BaseModel): 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") + description: Optional[str] = Field( + default=None, max_length=500, description="User's short description/bio" + ) + url: Optional[str] = Field( + default=None, + max_length=2048, + description="User's personal website or profile link", + ) created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") @@ -29,6 +37,8 @@ class UserProfileRead(BaseModel): "name": obj.name, # type: ignore[attr-defined] "callsign": obj.callsign, # type: ignore[attr-defined] "roles": obj.role_list, + "description": obj.description, # type: ignore[attr-defined] + "url": obj.url, # type: ignore[attr-defined] "created_at": obj.created_at, # type: ignore[attr-defined] "updated_at": obj.updated_at, # type: ignore[attr-defined] } @@ -43,6 +53,14 @@ class UserProfilePublic(BaseModel): 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") + description: Optional[str] = Field( + default=None, max_length=500, description="User's short description/bio" + ) + url: Optional[str] = Field( + default=None, + max_length=2048, + description="User's personal website or profile link", + ) created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") @@ -69,6 +87,14 @@ class UserProfileListItem(BaseModel): 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") + description: Optional[str] = Field( + default=None, max_length=500, description="User's short description/bio" + ) + url: Optional[str] = Field( + default=None, + max_length=2048, + description="User's personal website or profile link", + ) node_count: int = Field(default=0, description="Number of adopted nodes") adopted_nodes: list[AdoptedNodeRead] = Field( default_factory=list, @@ -99,6 +125,16 @@ class UserProfileUpdate(BaseModel): max_length=20, description="Amateur radio callsign", ) + description: Optional[str] = Field( + default=None, + max_length=500, + description="User's short description/bio", + ) + url: Optional[AnyUrl] = Field( + default=None, + max_length=2048, + description="User's personal website or profile link", + ) class UserProfileWithNodes(UserProfileRead): diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js index a39411e..f30c239 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/members.js @@ -24,6 +24,14 @@ function renderProfileTile(profile) { })}` : nothing; + const descriptionText = profile.description + ? html`

${profile.description}

` + : nothing; + + const urlLink = profile.url + ? html`${profile.url}` + : nothing; + return html`

@@ -31,6 +39,8 @@ function renderProfileTile(profile) { ${callsignBadge}

${roleBadges} + ${descriptionText} + ${urlLink} ${nodeCountLabel} ${nodeBadges}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/profile.js b/src/meshcore_hub/web/static/js/spa/pages/profile.js index 3b7f3c4..fa5d310 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/profile.js +++ b/src/meshcore_hub/web/static/js/spa/pages/profile.js @@ -66,6 +66,8 @@ function renderPublicProfile(profile, config, target) {

${profile.name || t('common.unnamed')}

${profile.callsign ? html`${profile.callsign}` : nothing} ${renderRoleBadges(profile.roles)} + ${profile.description ? html`

${profile.description}

` : nothing} + ${profile.url ? html`
${profile.url}` : nothing} ${renderProfileDetails(profile, config)} `, target); @@ -129,6 +131,18 @@ ${flashHtml} value=${profile.callsign || ''} placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" /> + + ${renderMemberSince(profile)} @@ -157,6 +171,8 @@ ${flashHtml} const body = { name: form.name.value.trim() || null, callsign: form.callsign.value.trim() || null, + description: form.description.value.trim() || null, + url: form.url.value.trim() || null, }; try { await apiPut(profilePath, body); diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 5ae02b4..3300843 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -218,6 +218,10 @@ "name_placeholder": "Your name or preferred name", "callsign_label": "Callsign", "callsign_placeholder": "Amateur radio callsign (e.g., W1ABC)", + "description_label": "Description", + "description_placeholder": "A short bio or description", + "url_label": "Website / Profile Link", + "url_placeholder": "https://...", "adopted_nodes": "Adopted Nodes", "no_adopted_nodes": "No adopted nodes", "login_to_view": "Log in to view your profile", diff --git a/tests/test_api/test_user_profiles.py b/tests/test_api/test_user_profiles.py index fab60c3..8b98de6 100644 --- a/tests/test_api/test_user_profiles.py +++ b/tests/test_api/test_user_profiles.py @@ -216,6 +216,101 @@ class TestUpdateProfile: assert data["callsign"] == "G1NEW" assert data["name"] == sample_user_profile.name + def test_update_profile_description(self, client_no_auth, sample_user_profile): + """Test updating profile description.""" + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"description": "Operator of IP2 repeaters"}, + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["description"] == "Operator of IP2 repeaters" + + def test_update_profile_url(self, client_no_auth, sample_user_profile): + """Test updating profile url.""" + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"url": "https://qrz.com/db/W1TEST"}, + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["url"] == "https://qrz.com/db/W1TEST" + + def test_update_profile_url_rejects_invalid( + self, client_no_auth, sample_user_profile + ): + """Test that invalid URLs are rejected.""" + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"url": "not-a-valid-url"}, + headers=USER_HEADERS, + ) + assert response.status_code == 422 + + def test_update_profile_clear_callsign(self, client_no_auth, sample_user_profile): + """Test clearing callsign via explicit null.""" + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"callsign": None}, + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["callsign"] is None + + def test_update_profile_clear_description( + self, client_no_auth, sample_user_profile + ): + """Test clearing description via explicit null.""" + # First set a description + client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"description": "Initial description"}, + headers=USER_HEADERS, + ) + # Then clear it + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"description": None}, + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["description"] is None + + def test_update_profile_clear_url(self, client_no_auth, sample_user_profile): + """Test clearing url via explicit null.""" + # First set a url + client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"url": "https://qrz.com/db/W1TEST"}, + headers=USER_HEADERS, + ) + # Then clear it + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"url": None}, + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["url"] is None + + def test_update_profile_name_cannot_be_cleared( + self, client_no_auth, sample_user_profile + ): + """Test that name cannot be cleared (set by IdP on login).""" + response = client_no_auth.put( + f"/api/v1/user/profile/{sample_user_profile.id}", + json={"name": None}, + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == sample_user_profile.name + def test_update_profile_rejects_wrong_user( self, client_no_auth, sample_user_profile ):