Skip to content

Commit 70327c8

Browse files
authored
Constraint on normalized organization names (#17998)
* Constraint on normalized organization names * Update tests * Add test against org catalog name reuse prevention
1 parent 1938535 commit 70327c8

File tree

5 files changed

+93
-9
lines changed

5 files changed

+93
-9
lines changed

tests/unit/admin/views/test_organizations.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ def test_rename(self, db_request):
372372
@pytest.mark.usefixtures("_enable_organizations")
373373
def test_rename_fails_on_conflict(self, db_request):
374374
admin = UserFactory.create()
375-
organization = OrganizationFactory.create(name="widget")
375+
OrganizationFactory.create(name="widget")
376376
organization = OrganizationFactory.create(name="example")
377377

378378
db_request.matchdict = {"organization_id": organization.id}

tests/unit/organizations/test_services.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ def test_rename_organization_back(self, organization_service, db_request):
708708
.count()
709709
) == 1
710710

711-
def test_rename_fails_if_entry_exists_for_another_org(
711+
def test_rename_fails_if_organization_name_in_use(
712712
self, organization_service, db_request
713713
):
714714
conflicting_org = OrganizationFactory.create()
@@ -719,6 +719,17 @@ def test_rename_fails_if_entry_exists_for_another_org(
719719
organization.id, conflicting_org.name
720720
)
721721

722+
def test_rename_fails_if_organization_name_previously_used(
723+
self, organization_service, db_request
724+
):
725+
conflicting_org = OrganizationFactory.create()
726+
original_name = conflicting_org.name
727+
organization_service.rename_organization(conflicting_org.id, "some_new_name")
728+
organization = OrganizationFactory.create()
729+
730+
with pytest.raises(ValueError): # noqa: PT011
731+
organization_service.rename_organization(organization.id, original_name)
732+
722733
def test_update_organization(self, organization_service, db_request):
723734
organization = OrganizationFactory.create()
724735

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
"""
13+
Unique normalized organization names
14+
15+
Revision ID: f609b35e981b
16+
Revises: 4f8982e60deb
17+
Create Date: 2025-04-21 16:23:05.015207
18+
"""
19+
20+
import sqlalchemy as sa
21+
22+
from alembic import op
23+
24+
revision = "f609b35e981b"
25+
down_revision = "4f8982e60deb"
26+
27+
28+
def upgrade():
29+
op.add_column(
30+
"organizations", sa.Column("normalized_name", sa.String(), nullable=True)
31+
)
32+
op.execute(
33+
"""
34+
UPDATE organizations
35+
SET normalized_name = normalize_pep426_name(name)
36+
"""
37+
)
38+
op.alter_column("organizations", "normalized_name", nullable=False)
39+
op.create_unique_constraint(None, "organizations", ["normalized_name"])
40+
41+
op.execute(
42+
""" CREATE OR REPLACE FUNCTION maintain_organizations_normalized_name()
43+
RETURNS TRIGGER AS $$
44+
BEGIN
45+
NEW.normalized_name := normalize_pep426_name(NEW.name);
46+
RETURN NEW;
47+
END;
48+
$$
49+
LANGUAGE plpgsql
50+
"""
51+
)
52+
53+
op.execute(
54+
""" CREATE TRIGGER organizations_update_normalized_name
55+
BEFORE INSERT OR UPDATE OF name ON organizations
56+
FOR EACH ROW
57+
EXECUTE PROCEDURE maintain_organizations_normalized_name()
58+
"""
59+
)
60+
61+
62+
def downgrade():
63+
op.drop_constraint(None, "organizations", type_="unique")
64+
op.drop_column("organizations", "normalized_name")

warehouse/organizations/models.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from sqlalchemy import (
2323
CheckConstraint,
2424
Enum,
25+
FetchedValue,
2526
ForeignKey,
2627
Index,
2728
UniqueConstraint,
@@ -276,10 +277,6 @@ def __table_args__(cls): # noqa: N805
276277

277278
name: Mapped[str] = mapped_column(comment="The account name used in URLS")
278279

279-
@declared_attr
280-
def normalized_name(cls): # noqa: N805
281-
return column_property(func.normalize_pep426_name(cls.name))
282-
283280
display_name: Mapped[str] = mapped_column(comment="Display name used in UI")
284281
orgtype: Mapped[enum.Enum] = mapped_column(
285282
Enum(OrganizationType, values_callable=lambda x: [e.value for e in x]),
@@ -299,6 +296,11 @@ class Organization(OrganizationMixin, HasEvents, db.Model):
299296

300297
__repr__ = make_repr("name")
301298

299+
normalized_name: Mapped[str] = mapped_column(
300+
unique=True,
301+
server_default=FetchedValue(),
302+
server_onupdate=FetchedValue(),
303+
)
302304
is_active: Mapped[bool_false] = mapped_column(
303305
comment="When True, the organization is active and all features are available.",
304306
)
@@ -538,6 +540,10 @@ class OrganizationApplication(OrganizationMixin, HasObservations, db.Model):
538540
__tablename__ = "organization_applications"
539541
__repr__ = make_repr("name")
540542

543+
@declared_attr
544+
def normalized_name(cls): # noqa: N805
545+
return column_property(func.normalize_pep426_name(cls.name))
546+
541547
submitted_by_id: Mapped[UUID] = mapped_column(
542548
PG_UUID,
543549
ForeignKey(

warehouse/organizations/services.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import datetime
1313

1414
from sqlalchemy import delete, func, orm, select
15-
from sqlalchemy.exc import NoResultFound
15+
from sqlalchemy.exc import IntegrityError, NoResultFound
1616
from zope.interface import implementer
1717

1818
from warehouse.accounts.models import TermsOfServiceEngagement, User
@@ -524,8 +524,11 @@ def rename_organization(self, organization_id, name):
524524
organization = self.get_organization(organization_id)
525525
organization.name = name
526526

527-
self.db.flush() # flush db now so organization.normalized_name available
528-
self.add_catalog_entry(organization_id)
527+
try:
528+
self.db.flush() # flush db now so organization.normalized_name available
529+
self.add_catalog_entry(organization_id)
530+
except IntegrityError:
531+
raise ValueError(f'Organization name "{name}" has been used')
529532

530533
return organization
531534

0 commit comments

Comments
 (0)