From d3fb33a78ac5889d8b90d720fd1231a30616ef8c Mon Sep 17 00:00:00 2001 From: Tessa Walsh Date: Wed, 3 Jul 2024 11:01:01 -0400 Subject: [PATCH] Add and apply backend sorting for org list The default org will always be sorted first, regardless of sort options. Orgs after the first will be sorted by name ascending by default. Sorting currently supported on name, slug, and readOnly. --- backend/btrixcloud/orgs.py | 61 +++++++++++++++++++++++++++++++------- backend/test/test_org.py | 57 +++++++++++++++++++++++++++++++++++ frontend/src/pages/home.ts | 5 ++-- 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 2da2d526..7736fe0b 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -134,6 +134,8 @@ class OrgOps: role: UserRole = UserRole.VIEWER, page_size: int = DEFAULT_PAGE_SIZE, page: int = 1, + sort_by: Optional[str] = "name", + sort_direction: int = 1, calculate_total=True, ): """Get all orgs a user is a member of""" @@ -142,19 +144,52 @@ class OrgOps: skip = page_size * page if user.is_superuser: - query = {} + query: Dict[str, object] = {} else: - query = {f"users.{user.id}": {"$gte": role.value}} + query: Dict[str, object] = {f"users.{user.id}": {"$gte": role.value}} - total = 0 - if calculate_total: - total = await self.orgs.count_documents(query) + aggregate = [{"$match": query}] - cursor = self.orgs.find(query, skip=skip, limit=page_size) - results = await cursor.to_list(length=page_size) - orgs = [Organization.from_dict(res) for res in results] + # Ensure default org is always first, then sort on sort_by if set + sort_query = {"default": -1} - return orgs, total + if sort_by: + sort_fields = ("name", "slug", "readOnly") + if sort_by not in sort_fields: + raise HTTPException(status_code=400, detail="invalid_sort_by") + if sort_direction not in (1, -1): + raise HTTPException(status_code=400, detail="invalid_sort_direction") + + sort_query[sort_by] = sort_direction + + aggregate.extend([{"$sort": sort_query}]) + + aggregate.extend( + [ + { + "$facet": { + "items": [ + {"$skip": skip}, + {"$limit": page_size}, + ], + "total": [{"$count": "count"}], + } + }, + ] + ) + + # Get total + cursor = self.orgs.aggregate(aggregate) + results = await cursor.to_list(length=1) + result = results[0] + items = result["items"] + + try: + total = int(result["total"][0]["count"]) + except (IndexError, ValueError): + total = 0 + + return [Organization.from_dict(data) for data in items], total async def get_org_for_user_by_id( self, oid: UUID, user: User, role: UserRole = UserRole.VIEWER @@ -788,9 +823,15 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep, user_or_shared_secr user: User = Depends(user_dep), pageSize: int = DEFAULT_PAGE_SIZE, page: int = 1, + sortBy: Optional[str] = "name", + sortDirection: Optional[int] = 1, ): results, total = await ops.get_orgs_for_user( - user, page_size=pageSize, page=page + user, + page_size=pageSize, + page=page, + sort_by=sortBy, + sort_direction=sortDirection, ) serialized_results = [ await res.serialize_for_user(user, user_manager) for res in results diff --git a/backend/test/test_org.py b/backend/test/test_org.py index ade1605a..13c146c4 100644 --- a/backend/test/test_org.py +++ b/backend/test/test_org.py @@ -703,3 +703,60 @@ def test_create_org_and_invite_existing_user(admin_auth_headers): "giftedExecMinutes": 0, } assert "subData" not in org + + +def test_sort_orgs(admin_auth_headers): + # Create a few new orgs for testing + r = requests.post( + f"{API_PREFIX}/orgs/create", + headers=admin_auth_headers, + json={"name": "abc", "slug": "abc"}, + ) + assert r.status_code == 200 + + r = requests.post( + f"{API_PREFIX}/orgs/create", + headers=admin_auth_headers, + json={"name": "mno", "slug": "mno"}, + ) + assert r.status_code == 200 + + r = requests.post( + f"{API_PREFIX}/orgs/create", + headers=admin_auth_headers, + json={"name": "xyz", "slug": "xyz"}, + ) + assert r.status_code == 200 + + # Check default sorting + # Default org should come first, followed by alphabetical sorting ascending + r = requests.get(f"{API_PREFIX}/orgs", headers=admin_auth_headers) + data = r.json() + orgs = data["items"] + + assert orgs[0]["default"] + + other_orgs = orgs[1:] + last_name = None + for org in other_orgs: + org_name = org["name"] + if last_name: + assert org_name > last_name + last_name = org_name + + # Sort by name descending, ensure default org still first + r = requests.get( + f"{API_PREFIX}/orgs?sortBy=name&sortDirection=-1", headers=admin_auth_headers + ) + data = r.json() + orgs = data["items"] + + assert orgs[0]["default"] + + other_orgs = orgs[1:] + last_name = None + for org in other_orgs: + org_name = org["name"] + if last_name: + assert org_name < last_name + last_name = org_name diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index 8c5477da..37d13a70 100644 --- a/frontend/src/pages/home.ts +++ b/frontend/src/pages/home.ts @@ -2,7 +2,6 @@ import { localized, msg, str } from "@lit/localize"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import sortBy from "lodash/fp/sortBy"; import type { InviteSuccessDetail } from "@/features/accounts/invite-form"; import type { APIPaginatedList } from "@/types/api"; @@ -278,12 +277,12 @@ export class Home extends LiteElement { } private async fetchOrgs() { - this.orgList = sortBy("name")(await this.getOrgs()); + this.orgList = await this.getOrgs(); } private async getOrgs() { const data = await this.apiFetch>( - "/orgs", + "/orgs?sortBy=name", this.authState!, );