Merge pull request #181 from ipnet-mesh/chore/ui-tweaks

feat: add description and url fields to user profiles, fix nullable field clearing
This commit is contained in:
JingleManSweep
2026-05-02 23:35:45 +01:00
committed by GitHub
12 changed files with 238 additions and 5 deletions
@@ -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 ###
+4
View File
@@ -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 |
+1
View File
@@ -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
+15 -2
View File
@@ -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)
@@ -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",
+1
View File
@@ -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
@@ -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):
@@ -24,6 +24,14 @@ function renderProfileTile(profile) {
})}</div>`
: nothing;
const descriptionText = profile.description
? html`<p class="text-sm opacity-70 mt-1 truncate">${profile.description}</p>`
: nothing;
const urlLink = profile.url
? html`<a href="${profile.url}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1 inline-block truncate">${profile.url}</a>`
: nothing;
return html`<a href="/profile/${profile.id}" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
@@ -31,6 +39,8 @@ function renderProfileTile(profile) {
${callsignBadge}
</h2>
${roleBadges}
${descriptionText}
${urlLink}
${nodeCountLabel}
${nodeBadges}
</div>
@@ -303,7 +303,10 @@ function renderAdoptionSection(node, config) {
return html`<div class="card bg-base-100 shadow-xl h-full">
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
<p class="text-sm opacity-70">
${t('nodes.adopted_by_prefix')}
<a href="/profile/${node.adopted_by.profile_id}" class="link link-hover text-primary">${ownerName}</a>
</p>
</div>
</div>`;
}
@@ -323,7 +326,10 @@ function renderAdoptionSection(node, config) {
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<div class="flex items-center justify-between">
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
<p class="text-sm opacity-70">
${t('nodes.adopted_by_prefix')}
<a href="/profile/${node.adopted_by.profile_id}" class="link link-hover text-primary">${ownerName}</a>
</p>
${releaseBtnHtml}
</div>
</div>
@@ -66,6 +66,8 @@ function renderPublicProfile(profile, config, target) {
<h2 class="card-title">${profile.name || t('common.unnamed')}</h2>
${profile.callsign ? html`<span class="badge badge-neutral">${profile.callsign}</span>` : nothing}
${renderRoleBadges(profile.roles)}
${profile.description ? html`<p class="text-sm opacity-80 mt-2">${profile.description}</p>` : nothing}
${profile.url ? html`<a href="${profile.url}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm mt-1 inline-block">${profile.url}</a>` : nothing}
${renderProfileDetails(profile, config)}
</div>
</div>`, target);
@@ -129,6 +131,18 @@ ${flashHtml}
value=${profile.callsign || ''}
placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" />
</label>
<label class="flex items-center gap-3 py-1">
<span class="text-sm font-medium shrink-0 w-24">${t('user_profile.description_label')}</span>
<input type="text" name="description" class="input input-bordered flex-1"
value=${profile.description || ''}
placeholder=${t('user_profile.description_placeholder')} maxlength="500" />
</label>
<label class="flex items-center gap-3 py-1">
<span class="text-sm font-medium shrink-0 w-24">${t('user_profile.url_label')}</span>
<input type="url" name="url" class="input input-bordered flex-1"
value=${profile.url || ''}
placeholder=${t('user_profile.url_placeholder')} maxlength="2048" />
</label>
<button type="submit" class="btn btn-primary btn-sm">${t('user_profile.save_profile')}</button>
</form>
${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);
@@ -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",
@@ -217,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",
+95
View File
@@ -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
):