Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion api/app/app/modules/users/presenter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from app.config import settings
from app.core.storage import is_enabled as storage_enabled, presign_get_url
from app.modules.users.model import User
from app.modules.users.schemas import UserResponse
from app.modules.users.schemas import PublicUserResponse, UserResponse


def display_name_of(user: User) -> str:
Expand All @@ -16,6 +16,16 @@ def display_name_of(user: User) -> str:
return user.username or "Unknown"


def public_display_name_of(user: User) -> str:
"""Like display_name_of, but never falls back to the email local part —
used for PublicUserResponse where email must not be inferable."""
first = (user.first_name or "").strip()
last = (user.last_name or "").strip()
if first or last:
return f"{first} {last}".strip()
return user.username or "Unknown"


def avatar_url_of(user: User) -> str | None:
if user.avatar_key and storage_enabled():
try:
Expand All @@ -38,3 +48,12 @@ def to_user_response(user: User) -> UserResponse:
created_at=user.created_at,
updated_at=user.updated_at,
)


def to_public_user_response(user: User) -> PublicUserResponse:
return PublicUserResponse(
id=user.id,
username=user.username,
display_name=public_display_name_of(user),
avatar_url=avatar_url_of(user),
)
21 changes: 15 additions & 6 deletions api/app/app/modules/users/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import secrets
from typing import Union
from uuid import UUID

from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
Expand All @@ -15,9 +16,15 @@
)
from app.db.session import get_db
from app.modules.users.model import User
from app.modules.users.presenter import to_user_response
from app.modules.users.schemas import UserCreate, UserResponse, UserUpdate
from app.modules.users.presenter import to_user_response, to_public_user_response
from app.modules.users.schemas import (
PublicUserResponse,
UserCreate,
UserResponse,
UserUpdate,
)
from app.modules.users.service import get_user_by_id, register_user
from app.modules.workspaces.repository import shares_workspace

logger = logging.getLogger(__name__)

Expand All @@ -33,18 +40,20 @@
}


@router.get("/{user_id}", response_model=UserResponse)
@router.get("/{user_id}", response_model=Union[UserResponse, PublicUserResponse])
def get_user(
user_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserResponse:
) -> UserResponse | PublicUserResponse:
user = get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return to_user_response(user)
if shares_workspace(db, current_user.id, user_id):
return to_user_response(user)
return to_public_user_response(user)
Comment thread
nazarli-shabnam marked this conversation as resolved.


@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -152,4 +161,4 @@ def delete_avatar(
db.refresh(current_user)
if previous_key:
delete_object(bucket=settings.s3_bucket, key=previous_key)
return to_user_response(current_user)
return to_user_response(current_user)
13 changes: 13 additions & 0 deletions api/app/app/modules/users/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,16 @@ class UserResponse(BaseModel):
updated_at: datetime

model_config = {"from_attributes": True}


class PublicUserResponse(BaseModel):
"""Reduced profile shape returned to users who don't share a workspace
with the target user. Deliberately omits email / email_verified / names
to avoid PII leakage (see issue #25)."""

id: UUID
username: str
display_name: str
avatar_url: str | None = None

model_config = {"from_attributes": True}
20 changes: 20 additions & 0 deletions api/app/app/modules/workspaces/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,23 @@ def list_workspace_invitations(
if pending_only:
query = query.filter(WorkspaceInvitation.accepted_at.is_(None))
return query.order_by(WorkspaceInvitation.created_at.desc()).all()


def shares_workspace(db: Session, user_id_a: UUID, user_id_b: UUID) -> bool:
"""True if the two users are the same person or are both members of at
least one common workspace (owners are members too, via `create()`)."""
if user_id_a == user_id_b:
return True
member_workspace_ids = (
db.query(WorkspaceMember.workspace_id)
.filter(WorkspaceMember.user_id == user_id_b)
)
return (
db.query(WorkspaceMember)
.filter(
WorkspaceMember.user_id == user_id_a,
WorkspaceMember.workspace_id.in_(member_workspace_ids),
)
.first()
is not None
)
Loading