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>
This commit is contained in:
parent
9a67e28f13
commit
a546fb6fe0
@ -67,7 +67,12 @@ from .models import (
|
|||||||
ACTIVE,
|
ACTIVE,
|
||||||
)
|
)
|
||||||
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
|
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
|
||||||
from .utils import slug_from_name, validate_slug, JSONSerializer
|
from .utils import (
|
||||||
|
slug_from_name,
|
||||||
|
validate_slug,
|
||||||
|
get_duplicate_key_error_field,
|
||||||
|
JSONSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .invites import InviteOps
|
from .invites import InviteOps
|
||||||
@ -301,8 +306,15 @@ class OrgOps:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self.orgs.insert_one(org.to_dict())
|
await self.orgs.insert_one(org.to_dict())
|
||||||
except DuplicateKeyError:
|
except DuplicateKeyError as err:
|
||||||
print(f"Organization name {org.name} already in use - skipping", flush=True)
|
field = get_duplicate_key_error_field(err)
|
||||||
|
value = org.name
|
||||||
|
if field == "slug":
|
||||||
|
value = org.slug
|
||||||
|
print(
|
||||||
|
f"Organization {field} {value} already in use - skipping",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def create_org(
|
async def create_org(
|
||||||
self,
|
self,
|
||||||
@ -337,7 +349,10 @@ class OrgOps:
|
|||||||
try:
|
try:
|
||||||
await self.orgs.insert_one(org.to_dict())
|
await self.orgs.insert_one(org.to_dict())
|
||||||
except DuplicateKeyError as dupe:
|
except DuplicateKeyError as dupe:
|
||||||
raise HTTPException(status_code=400, detail="already_exists") from dupe
|
field = get_duplicate_key_error_field(dupe)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"duplicate_org_{field}"
|
||||||
|
) from dupe
|
||||||
|
|
||||||
return org
|
return org
|
||||||
|
|
||||||
@ -1390,9 +1405,11 @@ def init_orgs_api(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await ops.update_slug_and_name(org)
|
await ops.update_slug_and_name(org)
|
||||||
except DuplicateKeyError:
|
except DuplicateKeyError as dupe:
|
||||||
# pylint: disable=raise-missing-from
|
field = get_duplicate_key_error_field(dupe)
|
||||||
raise HTTPException(status_code=400, detail="duplicate_org_name")
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"duplicate_org_{field}"
|
||||||
|
) from dupe
|
||||||
|
|
||||||
return {"updated": True}
|
return {"updated": True}
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pymongo.errors import DuplicateKeyError
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
@ -166,3 +167,16 @@ def stream_dict_list_as_csv(data: List[Dict[str, Union[str, int]]], filename: st
|
|||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": f"attachment;filename={filename}"},
|
headers={"Content-Disposition": f"attachment;filename={filename}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_duplicate_key_error_field(err: DuplicateKeyError) -> str:
|
||||||
|
"""Get name of duplicate field from pymongo DuplicateKeyError"""
|
||||||
|
dupe_field = "name"
|
||||||
|
if err.details:
|
||||||
|
key_value = err.details.get("keyValue")
|
||||||
|
if key_value:
|
||||||
|
try:
|
||||||
|
dupe_field = list(key_value.keys())[0]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
return dupe_field
|
||||||
|
@ -28,6 +28,7 @@ _all_crawls_config_id = None
|
|||||||
_all_crawls_delete_config_id = None
|
_all_crawls_delete_config_id = None
|
||||||
|
|
||||||
NON_DEFAULT_ORG_NAME = "Non-default org"
|
NON_DEFAULT_ORG_NAME = "Non-default org"
|
||||||
|
NON_DEFAULT_ORG_SLUG = "non-default-org"
|
||||||
|
|
||||||
FAILED_STATES = ["canceled", "failed", "skipped_quota_reached"]
|
FAILED_STATES = ["canceled", "failed", "skipped_quota_reached"]
|
||||||
|
|
||||||
@ -77,7 +78,7 @@ def non_default_org_id(admin_auth_headers):
|
|||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{API_PREFIX}/orgs/create",
|
f"{API_PREFIX}/orgs/create",
|
||||||
headers=admin_auth_headers,
|
headers=admin_auth_headers,
|
||||||
json={"name": NON_DEFAULT_ORG_NAME, "slug": "non-default-org"},
|
json={"name": NON_DEFAULT_ORG_NAME, "slug": NON_DEFAULT_ORG_SLUG},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import uuid
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .conftest import API_PREFIX
|
from .conftest import API_PREFIX, NON_DEFAULT_ORG_NAME, NON_DEFAULT_ORG_SLUG
|
||||||
from .utils import read_in_chunks
|
from .utils import read_in_chunks
|
||||||
|
|
||||||
curr_dir = os.path.dirname(os.path.realpath(__file__))
|
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):
|
|||||||
assert r.json()["detail"] == "invalid_slug"
|
assert r.json()["detail"] == "invalid_slug"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_org_duplicate_name(
|
||||||
|
admin_auth_headers, default_org_id, non_default_org_id
|
||||||
|
):
|
||||||
|
rename_data = {"name": NON_DEFAULT_ORG_NAME, "slug": "this-slug-should-be-okay"}
|
||||||
|
r = requests.post(
|
||||||
|
f"{API_PREFIX}/orgs/{default_org_id}/rename",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json=rename_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "duplicate_org_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_org_duplicate_name(
|
||||||
|
admin_auth_headers, default_org_id, non_default_org_id
|
||||||
|
):
|
||||||
|
rename_data = {"name": "Should be okay", "slug": NON_DEFAULT_ORG_SLUG}
|
||||||
|
r = requests.post(
|
||||||
|
f"{API_PREFIX}/orgs/{default_org_id}/rename",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json=rename_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "duplicate_org_slug"
|
||||||
|
|
||||||
|
|
||||||
def test_create_org(admin_auth_headers):
|
def test_create_org(admin_auth_headers):
|
||||||
NEW_ORG_NAME = "New Org"
|
NEW_ORG_NAME = "New Org"
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
@ -118,6 +146,30 @@ def test_create_org(admin_auth_headers):
|
|||||||
assert NEW_ORG_NAME in org_names
|
assert NEW_ORG_NAME in org_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_org_duplicate_name(admin_auth_headers, non_default_org_id):
|
||||||
|
r = requests.post(
|
||||||
|
f"{API_PREFIX}/orgs/create",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"name": NON_DEFAULT_ORG_NAME, "slug": "another-new-org"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.json()
|
||||||
|
assert data["detail"] == "duplicate_org_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_org_duplicate_slug(admin_auth_headers, non_default_org_id):
|
||||||
|
r = requests.post(
|
||||||
|
f"{API_PREFIX}/orgs/create",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"name": "Yet another new org", "slug": NON_DEFAULT_ORG_SLUG},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.json()
|
||||||
|
assert data["detail"] == "duplicate_org_slug"
|
||||||
|
|
||||||
|
|
||||||
# disable until storage customization is enabled
|
# disable until storage customization is enabled
|
||||||
def _test_change_org_storage(admin_auth_headers):
|
def _test_change_org_storage(admin_auth_headers):
|
||||||
# change to invalid storage
|
# change to invalid storage
|
||||||
|
@ -144,7 +144,7 @@ def test_create_sub_org_and_invite_existing_user_dupe_sub(admin_auth_headers):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json()["detail"] == "already_exists"
|
assert r.json()["detail"] == "duplicate_org_subscription.subId"
|
||||||
|
|
||||||
|
|
||||||
def test_create_sub_org_and_invite_existing_user(admin_auth_headers):
|
def test_create_sub_org_and_invite_existing_user(admin_auth_headers):
|
||||||
|
@ -147,10 +147,22 @@ export class OrgForm extends TailwindElement {
|
|||||||
await this.onRenameSuccess(payload);
|
await this.onRenameSuccess(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug(e);
|
console.debug(e);
|
||||||
if (isApiError(e) && e.details === "duplicate_org_name") {
|
if (isApiError(e)) {
|
||||||
throw new Error(
|
if (e.details === "duplicate_org_name") {
|
||||||
msg("This org name or URL is already taken, try another one."),
|
throw new Error(
|
||||||
);
|
msg("This org name is already taken, try another one."),
|
||||||
|
);
|
||||||
|
} else if (e.details === "duplicate_org_slug") {
|
||||||
|
throw new Error(
|
||||||
|
msg("This org URL is already taken, try another one."),
|
||||||
|
);
|
||||||
|
} else if (e.details === "invalid_slug") {
|
||||||
|
throw new Error(
|
||||||
|
msg(
|
||||||
|
"This org URL is invalid. Please use alphanumeric characters and dashes (-) only.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._notify.toast({
|
this._notify.toast({
|
||||||
|
@ -626,13 +626,24 @@ export class OrgSettings extends TailwindElement {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug(e);
|
console.debug(e);
|
||||||
|
|
||||||
|
let message = msg(
|
||||||
|
"Sorry, couldn't rename organization at this time. Try again later from org settings.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isApiError(e)) {
|
||||||
|
if (e.details === "duplicate_org_name") {
|
||||||
|
message = msg("This org name is already taken, try another one.");
|
||||||
|
} else if (e.details === "duplicate_org_slug") {
|
||||||
|
message = msg("This org URL is already taken, try another one.");
|
||||||
|
} else if (e.details === "invalid_slug") {
|
||||||
|
message = msg(
|
||||||
|
"This org URL is invalid. Please use alphanumeric characters and dashes (-) only.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.notify.toast({
|
this.notify.toast({
|
||||||
message:
|
message: message,
|
||||||
isApiError(e) && e.details === "duplicate_org_name"
|
|
||||||
? msg("This org name or URL is already taken, try another one.")
|
|
||||||
: msg(
|
|
||||||
"Sorry, couldn't rename organization at this time. Try again later from org settings.",
|
|
||||||
),
|
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
icon: "exclamation-octagon",
|
icon: "exclamation-octagon",
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user