Skip to content

Commit a546fb6

Browse files
tw4likreymer
andauthored
Improve handling of duplicate org name/slug (#1917)
Initial implementation of #1892 - Modifies the backend to return `duplicate_org_name` or `duplicate_org_slug` as appropriate on a pymongo `DuplicateKeyError` - Updates frontend to handle `duplicate_org_name`, `duplicate_org_slug`, and `invalid_slug` error details - Update errors to be more consistent, also return `duplicate_org_subscription.subId` for duplicate subscription instead of the more generic `already_exists` --------- Co-authored-by: Ilya Kreymer <ikreymer@gmail.com>
1 parent 9a67e28 commit a546fb6

File tree

7 files changed

+127
-20
lines changed

7 files changed

+127
-20
lines changed

backend/btrixcloud/orgs.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@
6767
ACTIVE,
6868
)
6969
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
70-
from .utils import slug_from_name, validate_slug, JSONSerializer
70+
from .utils import (
71+
slug_from_name,
72+
validate_slug,
73+
get_duplicate_key_error_field,
74+
JSONSerializer,
75+
)
7176

7277
if TYPE_CHECKING:
7378
from .invites import InviteOps
@@ -301,8 +306,15 @@ async def create_default_org(self) -> None:
301306
)
302307
try:
303308
await self.orgs.insert_one(org.to_dict())
304-
except DuplicateKeyError:
305-
print(f"Organization name {org.name} already in use - skipping", flush=True)
309+
except DuplicateKeyError as err:
310+
field = get_duplicate_key_error_field(err)
311+
value = org.name
312+
if field == "slug":
313+
value = org.slug
314+
print(
315+
f"Organization {field} {value} already in use - skipping",
316+
flush=True,
317+
)
306318

307319
async def create_org(
308320
self,
@@ -337,7 +349,10 @@ async def create_org(
337349
try:
338350
await self.orgs.insert_one(org.to_dict())
339351
except DuplicateKeyError as dupe:
340-
raise HTTPException(status_code=400, detail="already_exists") from dupe
352+
field = get_duplicate_key_error_field(dupe)
353+
raise HTTPException(
354+
status_code=400, detail=f"duplicate_org_{field}"
355+
) from dupe
341356

342357
return org
343358

@@ -1390,9 +1405,11 @@ async def rename_org(
13901405

13911406
try:
13921407
await ops.update_slug_and_name(org)
1393-
except DuplicateKeyError:
1394-
# pylint: disable=raise-missing-from
1395-
raise HTTPException(status_code=400, detail="duplicate_org_name")
1408+
except DuplicateKeyError as dupe:
1409+
field = get_duplicate_key_error_field(dupe)
1410+
raise HTTPException(
1411+
status_code=400, detail=f"duplicate_org_{field}"
1412+
) from dupe
13961413

13971414
return {"updated": True}
13981415

backend/btrixcloud/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from fastapi import HTTPException
1818
from fastapi.responses import StreamingResponse
19+
from pymongo.errors import DuplicateKeyError
1920
from slugify import slugify
2021

2122

@@ -166,3 +167,16 @@ def stream_dict_list_as_csv(data: List[Dict[str, Union[str, int]]], filename: st
166167
media_type="text/csv",
167168
headers={"Content-Disposition": f"attachment;filename={filename}"},
168169
)
170+
171+
172+
def get_duplicate_key_error_field(err: DuplicateKeyError) -> str:
173+
"""Get name of duplicate field from pymongo DuplicateKeyError"""
174+
dupe_field = "name"
175+
if err.details:
176+
key_value = err.details.get("keyValue")
177+
if key_value:
178+
try:
179+
dupe_field = list(key_value.keys())[0]
180+
except IndexError:
181+
pass
182+
return dupe_field

backend/test/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
_all_crawls_delete_config_id = None
2929

3030
NON_DEFAULT_ORG_NAME = "Non-default org"
31+
NON_DEFAULT_ORG_SLUG = "non-default-org"
3132

3233
FAILED_STATES = ["canceled", "failed", "skipped_quota_reached"]
3334

@@ -77,7 +78,7 @@ def non_default_org_id(admin_auth_headers):
7778
r = requests.post(
7879
f"{API_PREFIX}/orgs/create",
7980
headers=admin_auth_headers,
80-
json={"name": NON_DEFAULT_ORG_NAME, "slug": "non-default-org"},
81+
json={"name": NON_DEFAULT_ORG_NAME, "slug": NON_DEFAULT_ORG_SLUG},
8182
)
8283
assert r.status_code == 200
8384

backend/test/test_org.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from .conftest import API_PREFIX
7+
from .conftest import API_PREFIX, NON_DEFAULT_ORG_NAME, NON_DEFAULT_ORG_SLUG
88
from .utils import read_in_chunks
99

1010
curr_dir = os.path.dirname(os.path.realpath(__file__))
@@ -92,6 +92,34 @@ def test_rename_org_invalid_slug(admin_auth_headers, default_org_id):
9292
assert r.json()["detail"] == "invalid_slug"
9393

9494

95+
def test_rename_org_duplicate_name(
96+
admin_auth_headers, default_org_id, non_default_org_id
97+
):
98+
rename_data = {"name": NON_DEFAULT_ORG_NAME, "slug": "this-slug-should-be-okay"}
99+
r = requests.post(
100+
f"{API_PREFIX}/orgs/{default_org_id}/rename",
101+
headers=admin_auth_headers,
102+
json=rename_data,
103+
)
104+
105+
assert r.status_code == 400
106+
assert r.json()["detail"] == "duplicate_org_name"
107+
108+
109+
def test_rename_org_duplicate_name(
110+
admin_auth_headers, default_org_id, non_default_org_id
111+
):
112+
rename_data = {"name": "Should be okay", "slug": NON_DEFAULT_ORG_SLUG}
113+
r = requests.post(
114+
f"{API_PREFIX}/orgs/{default_org_id}/rename",
115+
headers=admin_auth_headers,
116+
json=rename_data,
117+
)
118+
119+
assert r.status_code == 400
120+
assert r.json()["detail"] == "duplicate_org_slug"
121+
122+
95123
def test_create_org(admin_auth_headers):
96124
NEW_ORG_NAME = "New Org"
97125
r = requests.post(
@@ -118,6 +146,30 @@ def test_create_org(admin_auth_headers):
118146
assert NEW_ORG_NAME in org_names
119147

120148

149+
def test_create_org_duplicate_name(admin_auth_headers, non_default_org_id):
150+
r = requests.post(
151+
f"{API_PREFIX}/orgs/create",
152+
headers=admin_auth_headers,
153+
json={"name": NON_DEFAULT_ORG_NAME, "slug": "another-new-org"},
154+
)
155+
156+
assert r.status_code == 400
157+
data = r.json()
158+
assert data["detail"] == "duplicate_org_name"
159+
160+
161+
def test_create_org_duplicate_slug(admin_auth_headers, non_default_org_id):
162+
r = requests.post(
163+
f"{API_PREFIX}/orgs/create",
164+
headers=admin_auth_headers,
165+
json={"name": "Yet another new org", "slug": NON_DEFAULT_ORG_SLUG},
166+
)
167+
168+
assert r.status_code == 400
169+
data = r.json()
170+
assert data["detail"] == "duplicate_org_slug"
171+
172+
121173
# disable until storage customization is enabled
122174
def _test_change_org_storage(admin_auth_headers):
123175
# change to invalid storage

backend/test/test_org_subs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def test_create_sub_org_and_invite_existing_user_dupe_sub(admin_auth_headers):
144144
)
145145

146146
assert r.status_code == 400
147-
assert r.json()["detail"] == "already_exists"
147+
assert r.json()["detail"] == "duplicate_org_subscription.subId"
148148

149149

150150
def test_create_sub_org_and_invite_existing_user(admin_auth_headers):

frontend/src/pages/invite/ui/org-form.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,22 @@ export class OrgForm extends TailwindElement {
147147
await this.onRenameSuccess(payload);
148148
} catch (e) {
149149
console.debug(e);
150-
if (isApiError(e) && e.details === "duplicate_org_name") {
151-
throw new Error(
152-
msg("This org name or URL is already taken, try another one."),
153-
);
150+
if (isApiError(e)) {
151+
if (e.details === "duplicate_org_name") {
152+
throw new Error(
153+
msg("This org name is already taken, try another one."),
154+
);
155+
} else if (e.details === "duplicate_org_slug") {
156+
throw new Error(
157+
msg("This org URL is already taken, try another one."),
158+
);
159+
} else if (e.details === "invalid_slug") {
160+
throw new Error(
161+
msg(
162+
"This org URL is invalid. Please use alphanumeric characters and dashes (-) only.",
163+
),
164+
);
165+
}
154166
}
155167

156168
this._notify.toast({

frontend/src/pages/org/settings/settings.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -626,13 +626,24 @@ export class OrgSettings extends TailwindElement {
626626
} catch (e) {
627627
console.debug(e);
628628

629+
let message = msg(
630+
"Sorry, couldn't rename organization at this time. Try again later from org settings.",
631+
);
632+
633+
if (isApiError(e)) {
634+
if (e.details === "duplicate_org_name") {
635+
message = msg("This org name is already taken, try another one.");
636+
} else if (e.details === "duplicate_org_slug") {
637+
message = msg("This org URL is already taken, try another one.");
638+
} else if (e.details === "invalid_slug") {
639+
message = msg(
640+
"This org URL is invalid. Please use alphanumeric characters and dashes (-) only.",
641+
);
642+
}
643+
}
644+
629645
this.notify.toast({
630-
message:
631-
isApiError(e) && e.details === "duplicate_org_name"
632-
? msg("This org name or URL is already taken, try another one.")
633-
: msg(
634-
"Sorry, couldn't rename organization at this time. Try again later from org settings.",
635-
),
646+
message: message,
636647
variant: "danger",
637648
icon: "exclamation-octagon",
638649
});

0 commit comments

Comments
 (0)