Skip to content

Commit 1487f44

Browse files
Ayush8923vprashrex
andauthored
feat(organization-management): Delete & Edit Functionality (#956)
Co-authored-by: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com>
1 parent dcd0dbf commit 1487f44

25 files changed

Lines changed: 852 additions & 146 deletions
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1-
Delete an organization.
1+
Delete an organization. **Requires superuser access.**
22

3-
Permanently deletes an organization and all associated data.
3+
Supports two delete modes, selected by an optional request body:
4+
5+
```json
6+
{ "hard_delete": false }
7+
```
8+
9+
- **`hard_delete: false` (default)** — *Soft delete.* The organization is marked **inactive** and all of its projects are deactivated as well. No data is removed, so the organization can be reactivated later. It simply stops appearing in listings and can no longer be used. Omitting the body entirely also performs a soft delete.
10+
- **`hard_delete: true`** — *Permanent delete.* The organization and everything owned by it — projects, collections, documents, credentials, assistants, fine-tunings, conversations, and user-project mappings — are permanently removed. **This cannot be undone.**
11+
12+
In both modes, user **accounts are never deleted** (a user may belong to other organizations). Any user left without an active project afterwards is marked **inactive** and can no longer log in until they are added to an active project again.
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
List all organizations.
1+
List organizations. **Requires superuser access.**
22

3-
Returns paginated list of all organizations in the system. The response includes a `has_more` field in `metadata` indicating whether additional pages are available.
3+
Returns a paginated list of organizations. The response includes a `has_more` field in `metadata` indicating whether additional pages are available.
4+
5+
**Query Parameters:**
6+
- `search` (optional): Case-insensitive substring match on the organization **name**. For example, `?search=acme` returns every organization whose name contains "acme".
7+
- `is_active` (optional, default `true`): Filter by active status. Pass `false` to list soft-deleted organizations.
8+
- `skip` (optional, default `0`): Number of records to skip for pagination.
9+
- `limit` (optional, default `100`, max `100`): Maximum number of records to return.
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1-
Delete a project.
1+
Delete a project. **Requires superuser access.**
22

3-
Permanently deletes a project and all associated data including documents, collections, and configurations.
3+
Supports two delete modes, selected by an optional request body:
4+
5+
```json
6+
{ "hard_delete": false }
7+
```
8+
9+
- **`hard_delete: false` (default)** — *Soft delete.* The project is marked **inactive**. No data is removed, so the project can be reactivated later. It simply stops appearing in listings and can no longer be used. Omitting the body entirely also performs a soft delete.
10+
- **`hard_delete: true`** — *Permanent delete.* The project and everything owned by it — collections, documents, credentials, assistants, fine-tunings, conversations, and user-project mappings — are permanently removed. **This cannot be undone.**
11+
12+
In both modes, user **accounts are never deleted** (a user may belong to other projects). Any user left without an active project afterwards is marked **inactive** and can no longer log in until they are added to an active project again.
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
List all projects.
1+
List projects across all organizations. **Requires superuser access.**
22

3-
Returns paginated list of all projects across all organizations.
3+
Returns a paginated list of projects. The response includes a `has_more` field in `metadata` indicating whether additional pages are available.
4+
5+
**Query Parameters:**
6+
- `search` (optional): Case-insensitive substring match on the project **name**. For example, `?search=onboarding` returns every project whose name contains "onboarding".
7+
- `is_active` (optional, default `true`): Filter by active status. Pass `false` to list soft-deleted projects.
8+
- `skip` (optional, default `0`): Number of records to skip for pagination.
9+
- `limit` (optional, default `100`, max `100`): Maximum number of records to return.
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
List all projects for a given organization.
1+
List projects for a given organization. **Requires superuser access.**
22

3-
Returns all projects belonging to the specified organization ID. The organization must exist and be active.
3+
Returns the projects belonging to the specified organization ID. The organization must exist and be active.
4+
5+
**Query Parameters:**
6+
- `search` (optional): Case-insensitive substring match on the project **name**. For example, `?search=onboarding` returns only projects whose name contains "onboarding".
7+
- `is_active` (optional, default `true`): Filter by active status. Pass `false` to list soft-deleted projects (e.g. to selectively reactivate them).

‎backend/app/api/docs/user_project/delete.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ Remove a user from a project. **Requires superuser access.**
66
**Query Parameters:**
77
- `project_id` (required): The ID of the project to remove the user from.
88

9-
This only removes the user-project mapping — the user account itself is not deleted. You cannot remove yourself from a project.
9+
This only removes the user-project mapping — the user account itself is never deleted. If this was the user's last project, the account is marked **inactive** and can no longer log in until they are added to a project again. You cannot remove yourself from a project.

‎backend/app/api/routes/auth.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse:
114114

115115
available_projects = get_user_accessible_projects(session=session, user_id=user.id)
116116

117+
if not user.is_superuser and not available_projects:
118+
logger.info(
119+
f"[google_auth] User has no accessible projects | user_id: {user.id}"
120+
)
121+
raise HTTPException(
122+
status_code=status.HTTP_403_FORBIDDEN,
123+
detail="You are not assigned to any active project. Please contact your administrator.",
124+
)
125+
117126
if len(available_projects) == 1:
118127
proj = available_projects[0]
119128
logger.info(
@@ -389,6 +398,15 @@ def verify_magic_link(session: SessionDep, token: str) -> JSONResponse:
389398
# Get user's projects to embed in token
390399
available_projects = get_user_accessible_projects(session=session, user_id=user.id)
391400

401+
if not user.is_superuser and not available_projects:
402+
logger.info(
403+
f"[verify_magic_link] User has no accessible projects | user_id: {user.id}"
404+
)
405+
raise HTTPException(
406+
status_code=status.HTTP_403_FORBIDDEN,
407+
detail="You are not assigned to any active project. Please contact your administrator.",
408+
)
409+
392410
organization_id = None
393411
project_id = None
394412
if len(available_projects) == 1:

‎backend/app/api/routes/login.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datetime import timedelta
2-
from typing import Annotated, Any, Optional
2+
from typing import Annotated, Any
33

4-
from fastapi import APIRouter, Depends, HTTPException, Form
4+
from fastapi import APIRouter, Depends, Form, HTTPException
55
from fastapi.responses import HTMLResponse
66
from fastapi.security import OAuth2PasswordRequestForm
77

@@ -11,6 +11,7 @@
1111
from app.core.config import settings
1212
from app.core.security import get_password_hash
1313
from app.crud import authenticate, get_user_by_email
14+
from app.crud.auth import get_user_accessible_projects
1415
from app.models import Message, NewPassword, Token, UserPublic
1516
from app.utils import (
1617
generate_password_reset_token,
@@ -26,9 +27,8 @@
2627
def login_access_token(
2728
session: SessionDep,
2829
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
29-
token_expiry_minutes: Optional[int] = Form(
30-
default=settings.ACCESS_TOKEN_EXPIRE_MINUTES, ge=1, le=60 * 24 * 360
31-
),
30+
token_expiry_minutes: int
31+
| None = Form(default=settings.ACCESS_TOKEN_EXPIRE_MINUTES, ge=1, le=60 * 24 * 360),
3232
) -> Token:
3333
"""
3434
OAuth2 compatible token login with customizable expiration time.
@@ -42,6 +42,14 @@ def login_access_token(
4242
elif not user.is_active:
4343
raise HTTPException(status_code=400, detail="Inactive user")
4444

45+
if not user.is_superuser and not get_user_accessible_projects(
46+
session=session, user_id=user.id
47+
):
48+
raise HTTPException(
49+
status_code=403,
50+
detail="You are not assigned to any active project. Please contact your administrator.",
51+
)
52+
4553
access_token_expires = timedelta(minutes=token_expiry_minutes)
4654

4755
return Token(

‎backend/app/api/routes/organization.py‎

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import logging
2-
from typing import List
32

43
from fastapi import APIRouter, Depends, HTTPException, Query
54
from sqlalchemy import func
65
from sqlmodel import select
76

7+
from app.api.deps import SessionDep
8+
from app.api.permissions import Permission, require_permission
9+
from app.crud.organization import (
10+
cascade_deactivate_organization,
11+
create_organization,
12+
get_organization_by_id,
13+
get_organization_by_name,
14+
hard_delete_organization,
15+
soft_delete_organization,
16+
)
817
from app.models import (
18+
DeleteRequest,
919
Organization,
1020
OrganizationCreate,
11-
OrganizationUpdate,
1221
OrganizationPublic,
22+
OrganizationUpdate,
1323
)
14-
from app.api.deps import SessionDep
15-
from app.api.permissions import Permission, require_permission
16-
from app.crud.organization import create_organization, get_organization_by_id
1724
from app.utils import APIResponse, load_description
1825

1926
logger = logging.getLogger(__name__)
@@ -24,18 +31,30 @@
2431
@router.get(
2532
"",
2633
dependencies=[Depends(require_permission(Permission.SUPERUSER))],
27-
response_model=APIResponse[List[OrganizationPublic]],
34+
response_model=APIResponse[list[OrganizationPublic]],
2835
description=load_description("organization/list.md"),
2936
)
3037
def read_organizations(
3138
session: SessionDep,
3239
skip: int = Query(0, ge=0),
3340
limit: int = Query(100, ge=1, le=100),
34-
) -> APIResponse[List[OrganizationPublic]]:
35-
count_statement = select(func.count()).select_from(Organization)
41+
search: str
42+
| None = Query(
43+
None, description="Case-insensitive substring match on the organization name"
44+
),
45+
is_active: bool = Query(
46+
True,
47+
description="Filter by active status. Pass false to list soft-deleted organizations.",
48+
),
49+
) -> APIResponse[list[OrganizationPublic]]:
50+
filters = [Organization.is_active.is_(is_active)]
51+
if search and search.strip():
52+
filters.append(Organization.name.ilike(f"%{search.strip()}%"))
53+
54+
count_statement = select(func.count()).select_from(Organization).where(*filters)
3655
count = session.exec(count_statement).one()
3756

38-
statement = select(Organization).offset(skip).limit(limit)
57+
statement = select(Organization).where(*filters).offset(skip).limit(limit)
3958
organizations = session.exec(statement).all()
4059

4160
has_more = (skip + limit) < count
@@ -69,7 +88,7 @@ def read_organization(
6988
Retrieve an organization by ID.
7089
"""
7190
org = get_organization_by_id(session=session, org_id=org_id)
72-
if org is None:
91+
if org is None or not org.is_active:
7392
raise HTTPException(status_code=404, detail="Organization not found")
7493
return APIResponse.success_response(org)
7594

@@ -89,11 +108,28 @@ def update_organization(
89108
raise HTTPException(status_code=404, detail="Organization not found")
90109

91110
org_data = org_in.model_dump(exclude_unset=True)
92-
org = org.model_copy(update=org_data)
93111

112+
new_name = org_data.get("name")
113+
if new_name and new_name != org.name:
114+
existing = get_organization_by_name(session=session, name=new_name)
115+
if existing and existing.id != org.id:
116+
raise HTTPException(
117+
status_code=409,
118+
detail="An organization with this name already exists",
119+
)
120+
121+
target_active = org_data.get("is_active")
122+
deactivating = target_active is False and org.is_active
123+
124+
org.sqlmodel_update(org_data)
94125
session.add(org)
95-
session.commit()
96126
session.flush()
127+
128+
if deactivating:
129+
cascade_deactivate_organization(session=session, organization=org)
130+
131+
session.commit()
132+
session.refresh(org)
97133
logger.info(
98134
f"[update_organization] Organization Updated Successfully | 'org_id': {org.id}"
99135
)
@@ -105,17 +141,25 @@ def update_organization(
105141
"/{org_id}",
106142
dependencies=[Depends(require_permission(Permission.SUPERUSER))],
107143
response_model=APIResponse[None],
108-
include_in_schema=False,
109144
description=load_description("organization/delete.md"),
110145
)
111-
def delete_organization(session: SessionDep, org_id: int) -> APIResponse[None]:
146+
def delete_organization_endpoint(
147+
session: SessionDep,
148+
org_id: int,
149+
body: DeleteRequest | None = None,
150+
) -> APIResponse[None]:
112151
org = get_organization_by_id(session=session, org_id=org_id)
113152
if org is None:
114153
raise HTTPException(status_code=404, detail="Organization not found")
115154

116-
session.delete(org)
117-
session.commit()
155+
hard_delete = body.hard_delete if body else False
156+
if hard_delete:
157+
hard_delete_organization(session=session, organization=org)
158+
else:
159+
soft_delete_organization(session=session, organization=org)
160+
118161
logger.info(
119-
f"[delete_organization] Organization Deleted Successfully | 'org_id': {org_id}"
162+
f"[delete_organization_endpoint] Organization deleted | 'org_id': {org_id}, "
163+
f"hard_delete: {hard_delete}"
120164
)
121165
return APIResponse.success_response(None)

0 commit comments

Comments
 (0)