Skip to content

Commit bb20e54

Browse files
authored
Rationalize account names as always lowercase and add display name field (#559)
1 parent 8eda159 commit bb20e54

14 files changed

Lines changed: 171 additions & 26 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Lowercase account names and add display_name
2+
3+
Revision ID: af024967630d
4+
Revises: c1f9a3e27d48
5+
Create Date: 2026-04-15 12:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
revision = "af024967630d"
13+
down_revision = "c1f9a3e27d48"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
op.add_column(
20+
"account",
21+
sa.Column("display_name", sa.String(length=64), nullable=True),
22+
)
23+
conn = op.get_bind()
24+
collisions = conn.execute(
25+
sa.text(
26+
"SELECT lower(name) AS lname, array_agg(name) AS names "
27+
"FROM account GROUP BY lower(name) HAVING count(*) > 1"
28+
)
29+
).fetchall()
30+
if collisions:
31+
raise RuntimeError(
32+
"Cannot lowercase account.name: case-insensitive collisions "
33+
f"exist: {collisions}. Resolve these rows before re-running."
34+
)
35+
# For org accounts, pull the nicely-cased display_name from the org row
36+
# (account.name was already lowercased on org creation). For user accounts,
37+
# the existing account.name preserves whatever casing the user signed up
38+
# with.
39+
conn.execute(
40+
sa.text(
41+
"UPDATE account SET display_name = COALESCE("
42+
"(SELECT display_name FROM org WHERE org.id = account.org_id), "
43+
"name) "
44+
"WHERE display_name IS NULL"
45+
)
46+
)
47+
conn.execute(sa.text("UPDATE account SET name = lower(name)"))
48+
op.create_check_constraint(
49+
"ck_account_name_lowercase", "account", "name = lower(name)"
50+
)
51+
op.drop_column("org", "display_name")
52+
53+
54+
def downgrade():
55+
op.add_column(
56+
"org",
57+
sa.Column("display_name", sa.String(length=255), nullable=True),
58+
)
59+
op.execute(
60+
"UPDATE org SET display_name = account.display_name "
61+
"FROM account WHERE account.org_id = org.id"
62+
)
63+
op.alter_column("org", "display_name", nullable=False)
64+
op.drop_constraint("ck_account_name_lowercase", "account", type_="check")
65+
op.drop_column("account", "display_name")

backend/app/api/routes/accounts.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,5 @@ def get_account(
5151
role = org_membership.role_name
5252
break
5353
account_dict["role"] = role
54-
# Determine display name
55-
if account.org is not None:
56-
account_dict["display_name"] = account.org.display_name
57-
else:
58-
account_dict["display_name"] = account.name
54+
account_dict["display_name"] = account.display_name or account.name
5955
return AccountPublic.model_validate(account_dict)

backend/app/api/routes/misc.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ def post_discount_code(
119119
created_for_account_id = None
120120
if req.created_for_account_name is not None:
121121
account = session.exec(
122-
select(Account).where(Account.name == req.created_for_account_name)
122+
select(Account).where(
123+
Account.name == req.created_for_account_name.lower()
124+
)
123125
).first()
124126
if account is None:
125127
raise HTTPException(400, "Account does not exist")
@@ -310,12 +312,7 @@ def global_search(
310312
org_results = session.exec(
311313
select(Org)
312314
.join(Account, Account.org_id == Org.id) # type: ignore
313-
.where(
314-
or_(
315-
Account.name.ilike(pattern), # type: ignore
316-
Org.display_name.ilike(pattern), # type: ignore
317-
)
318-
)
315+
.where(Account.name.ilike(pattern)) # type: ignore
319316
.limit(limit)
320317
).all()
321318
for o in org_results:

backend/app/api/routes/orgs.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,27 @@ def get_orgs(
8484
search_for: str | None = None,
8585
) -> OrgsResponse:
8686
"""Get a list of orgs."""
87-
query = select(Org).offset(offset).limit(limit).order_by(Org.display_name)
87+
query = (
88+
select(Org)
89+
.join(Account, Account.org_id == Org.id) # type: ignore
90+
.offset(offset)
91+
.limit(limit)
92+
.order_by(Account.display_name)
93+
)
8894
if search_for is not None:
8995
search_for = f"%{search_for}%"
90-
query = query.where(Org.display_name.ilike(search_for))
96+
query = query.where(Account.display_name.ilike(search_for))
9197
orgs = session.exec(query).all()
92-
count_query = select(func.count()).select_from(Org)
98+
count_query = (
99+
select(func.count())
100+
.select_from(Org)
101+
.join(
102+
Account,
103+
Account.org_id == Org.id, # type: ignore
104+
)
105+
)
93106
if search_for is not None:
94-
count_query = count_query.where(Org.display_name.ilike(search_for))
107+
count_query = count_query.where(Account.display_name.ilike(search_for))
95108
count = session.exec(count_query).one()
96109
resp = []
97110
for org in orgs:
@@ -176,8 +189,11 @@ def post_org(
176189
400, "Cannot detect display name; one must be provided"
177190
)
178191
org = Org(
179-
display_name=display_name,
180-
account=Account(name=org_name, github_name=req.github_name),
192+
account=Account(
193+
name=org_name,
194+
display_name=display_name,
195+
github_name=req.github_name,
196+
),
181197
user_memberships=[
182198
UserOrgMembership(user=current_user, role_id=ROLE_IDS["owner"])
183199
],

backend/app/api/routes/projects/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def get_projects(
172172
if owner_name is not None:
173173
where_clause = and_(
174174
where_clause,
175-
Project.owner_account.has(Account.name == owner_name), # type: ignore
175+
Project.owner_account.has(Account.name == owner_name.lower()), # type: ignore
176176
)
177177
if search_for is not None:
178178
search_for = f"%{search_for}%"
@@ -315,7 +315,7 @@ def post_project(
315315
# is private
316316
if not project_in.git_repo_exists and not project_in.is_public:
317317
logger.info(f"Checking private project count for {owner_name}")
318-
if current_user.account.name == owner_name:
318+
if current_user.account.name == owner_name.lower():
319319
# Count private projects for user
320320
account_id = current_user.account.id
321321
subscription = current_user.subscription

backend/app/models/core.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
class Account(SQLModel, table=True):
2020
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
2121
name: str = Field(min_length=2, max_length=64, unique=True)
22+
display_name: str | None = Field(default=None, max_length=64)
2223
created: datetime = Field(
2324
default_factory=utcnow,
2425
sa_column_kwargs=dict(
@@ -241,14 +242,18 @@ class UsersPublic(SQLModel):
241242

242243
class Org(SQLModel, table=True):
243244
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
244-
display_name: str = Field(min_length=2, max_length=255)
245245
# Relationships
246246
account: Account = Relationship(back_populates="org")
247247
user_memberships: list["UserOrgMembership"] = Relationship(
248248
back_populates="org"
249249
)
250250
subscription: "OrgSubscription" = Relationship(back_populates="org")
251251

252+
@computed_field
253+
@property
254+
def display_name(self) -> str:
255+
return self.account.display_name or self.account.name
256+
252257
@computed_field
253258
@property
254259
def github_name(self) -> str:
@@ -488,6 +493,11 @@ class Project(ProjectBase, table=True):
488493
def owner_account_name(self) -> str:
489494
return self.owner_account.name
490495

496+
@computed_field
497+
@property
498+
def owner_account_display_name(self) -> str:
499+
return self.owner_account.display_name or self.owner_account.name
500+
491501
@computed_field
492502
@property
493503
def owner_account_type(self) -> str:
@@ -530,6 +540,7 @@ class ProjectPublic(ProjectBase):
530540
id: uuid.UUID
531541
owner_account_id: uuid.UUID
532542
owner_account_name: str
543+
owner_account_display_name: str
533544
owner_account_type: str
534545
current_user_access: Literal["read", "write", "admin", "owner"] | None = (
535546
None

backend/app/projects.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def _yaml_load(data: bytes | str):
3434
from app.dvc import get_data_fpath_for_md5
3535
from app.git import get_ck_info_from_repo, get_zip_path_map_from_repo
3636
from app.models import (
37+
Account,
3738
ContentsItem,
3839
Figure,
3940
ItemLock,

backend/app/tests/test_projects.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,49 @@ def test_get_project_case_insensitive(db: Session) -> None:
7878
db.commit()
7979

8080

81+
def test_get_project_with_caps_in_account_name(db: Session) -> None:
82+
from app import users
83+
from app.models import UserCreate
84+
85+
suffix = uuid.uuid4().hex[:8]
86+
account_name = f"CapsUser-{suffix}"
87+
caps_user = users.create_user(
88+
session=db,
89+
user_create=UserCreate(
90+
email=f"{account_name}@example.com",
91+
password="CapsPassword123",
92+
account_name=account_name,
93+
github_username=account_name,
94+
),
95+
)
96+
project = Project(
97+
name="caps-project",
98+
title="Caps Project",
99+
git_repo_url=f"https://github.com/{account_name}/caps-project",
100+
owner_account_id=caps_user.account.id,
101+
)
102+
db.add(project)
103+
db.commit()
104+
try:
105+
# The stored name is lowercased; display_name preserves original casing.
106+
assert caps_user.account.name == account_name.lower()
107+
assert caps_user.account.display_name == account_name
108+
found = app.projects.get_project(
109+
session=db, owner_name=account_name, project_name="caps-project"
110+
)
111+
assert found.owner_account.display_name == account_name
112+
found_lower = app.projects.get_project(
113+
session=db,
114+
owner_name=account_name.lower(),
115+
project_name="caps-project",
116+
)
117+
assert found_lower.id == found.id
118+
finally:
119+
db.delete(project)
120+
db.delete(caps_user)
121+
db.commit()
122+
123+
81124
def test_get_contents_from_repo_at_given_ref(
82125
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
83126
) -> None:

backend/app/users.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,20 @@ def create_user(*, session: Session, user_create: UserCreate) -> User:
121121
if not account_name:
122122
account_name = user_create.email.split("@")[0]
123123
github_name = user_create.github_username or account_name
124-
if account_name in INVALID_ACCOUNT_NAMES:
124+
if account_name.lower() in INVALID_ACCOUNT_NAMES:
125125
raise HTTPException(422, "Invalid account name")
126+
existing = session.exec(
127+
select(Account).where(Account.name == account_name.lower())
128+
).first()
129+
if existing is not None:
130+
raise HTTPException(422, "Account name is already taken")
126131
user = User.model_validate(
127132
user_create,
128133
update={
129134
"hashed_password": get_password_hash(user_create.password),
130135
"account": Account(
131-
name=account_name,
136+
name=account_name.lower(),
137+
display_name=account_name,
132138
github_name=github_name,
133139
), # type: ignore
134140
},

frontend/src/client/models.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ export type ProjectOptionalExtended = {
701701
id: string
702702
owner_account_id: string
703703
owner_account_name: string
704+
owner_account_display_name: string
704705
owner_account_type: string
705706
current_user_access?: "read" | "write" | "admin" | "owner" | null
706707
calkit_info_keys?: Array<string> | null
@@ -744,6 +745,7 @@ export type ProjectPublic = {
744745
id: string
745746
owner_account_id: string
746747
owner_account_name: string
748+
owner_account_display_name: string
747749
owner_account_type: string
748750
current_user_access?: "read" | "write" | "admin" | "owner" | null
749751
}

0 commit comments

Comments
 (0)