mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-05 01:11:02 +02:00
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:
+36
@@ -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 ###
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user