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
This commit is contained in:
Louis King
2026-05-02 23:33:25 +01:00
parent db1a6aa3d5
commit f2ea530c0f
9 changed files with 227 additions and 3 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 |
+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",
@@ -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>
@@ -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);
@@ -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",
+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
):