Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create separate user endpoints for different purposes (admin, org, project) #2238

Merged
merged 5 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ async def create(

return new_role

@classmethod
async def all(
cls,
db: Connection,
project_id: Optional[int] = None,
) -> Optional[list[Self]]:
"""Fetch all project user roles."""
filters = []
params = {}
if project_id:
filters.append(f"project_id = {project_id}")
params["project_id"] = project_id

sql = f"""
SELECT * FROM user_roles
{"WHERE " + " AND ".join(filters) if filters else ""}
"""
async with db.cursor(row_factory=class_row(cls)) as cur:
await cur.execute(
sql,
)
return await cur.fetchall()


class DbUser(BaseModel):
"""Table users."""
Expand Down
51 changes: 43 additions & 8 deletions src/backend/app/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
from loguru import logger as log
from psycopg import Connection

from app.auth.roles import mapper, project_manager, super_admin
from app.auth.roles import mapper, org_admin, project_manager, super_admin
from app.db.database import db_conn
from app.db.enums import HTTPStatus
from app.db.enums import UserRole as UserRoleEnum
from app.db.models import DbUser
from app.db.models import DbUser, DbUserRole
from app.users import user_schemas
from app.users.user_crud import get_paginated_users, process_inactive_users
from app.users.user_deps import get_user
Expand All @@ -42,17 +42,32 @@
@router.get("", response_model=user_schemas.PaginatedUsers)
async def get_users(
db: Annotated[Connection, Depends(db_conn)],
current_user: Annotated[DbUser, Depends(project_manager)],
_: Annotated[DbUser, Depends(super_admin)],
page: int = Query(1, ge=1),
results_per_page: int = Query(13, le=100),
search: str = None,
search: str = "",
):
"""Get all user details."""
return await get_paginated_users(db, page, results_per_page, search)


@router.get("/usernames", response_model=list[user_schemas.Usernames])
async def get_userlist(
db: Annotated[Connection, Depends(db_conn)],
_: Annotated[DbUser, Depends(org_admin)],
search: str = "",
):
"""Get all user list with info such as id and username."""
users = await DbUser.all(db, search=search)
if not users:
return []
return [
user_schemas.Usernames(id=user.id, username=user.username) for user in users
]


@router.get("/user-role-options")
async def get_user_roles(current_user: Annotated[DbUser, Depends(mapper)]):
async def get_user_roles(_: Annotated[DbUser, Depends(mapper)]):
"""Check for available user role options."""
user_roles = {}
for role in UserRoleEnum:
Expand All @@ -64,7 +79,7 @@ async def get_user_roles(current_user: Annotated[DbUser, Depends(mapper)]):
async def update_existing_user(
user_id: int,
new_user_data: user_schemas.UserUpdate,
current_user: Annotated[DbUser, Depends(super_admin)],
_: Annotated[DbUser, Depends(super_admin)],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this approach for showing unused vars, similar to other languages

db: Annotated[Connection, Depends(db_conn)],
):
"""Change the role of a user."""
Expand All @@ -74,7 +89,7 @@ async def update_existing_user(
@router.get("/{id}", response_model=user_schemas.UserOut)
async def get_user_by_identifier(
user: Annotated[DbUser, Depends(get_user)],
current_user: Annotated[DbUser, Depends(super_admin)],
_: Annotated[DbUser, Depends(super_admin)],
):
"""Get a single users details.

Expand Down Expand Up @@ -103,10 +118,30 @@ async def delete_user_by_identifier(
@router.post("/process-inactive-users")
async def delete_inactive_users(
db: Annotated[Connection, Depends(db_conn)],
current_user: Annotated[DbUser, Depends(super_admin)],
_: Annotated[DbUser, Depends(super_admin)],
):
"""Identify inactive users, send warnings, and delete accounts."""
log.info("Start processing inactive users")
await process_inactive_users(db)
log.info("Finished processing inactive users")
return Response(status_code=HTTPStatus.NO_CONTENT)


@router.get(
"/{project_id}/project-users", response_model=list[user_schemas.UserRolesOut]
)
async def get_project_users(
db: Annotated[Connection, Depends(db_conn)],
project_user_dict: Annotated[DbUser, Depends(project_manager)],
):
"""Get project users and their project role."""
project = project_user_dict.get("project")
users = await DbUserRole.all(db, project.id)
if not users:
return []
return [
user_schemas.UserRolesOut(
user_id=user.user_id, project_id=user.project_id, role=user.role
)
for user in users
]
12 changes: 10 additions & 2 deletions src/backend/app/users/user_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,20 @@ class UserRole(BaseModel):
class UserRolesOut(DbUserRole):
"""User role for a specific project."""

# project_id is redundant if the user specified it in the endpoint
project_id: Annotated[Optional[int], Field(exclude=True)] = None
user_id: int
role: ProjectRole
project_id: Optional[int] = None


class PaginatedUsers(BaseModel):
"""Project summaries + Pagination info."""

results: list[UserOut]
pagination: PaginationInfo


class Usernames(BaseModel):
"""User info with username and their id."""

id: int
username: str
2 changes: 1 addition & 1 deletion src/frontend/src/api/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const UpdateUserRole = (url: string, payload: { role: 'READ_ONLY' | 'ADMI

export const GetUserListForSelect = (
url: string,
params: { page: number; results_per_page: number; search: string },
params: { page: number; org_id: number; results_per_page: number; search: string },
) => {
return async (dispatch: AppDispatch) => {
dispatch(UserActions.SetUserListForSelectLoading(true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const ProjectDetailsForm = ({ flag }) => {
hasODKCredentials: item?.odk_central_url ? true : false,
}));
const [hasODKCredentials, setHasODKCredentials] = useState(false);
const [userSearchText, setUserSearchText] = useState('');

const submission = () => {
dispatch(CreateProjectActions.SetIndividualProjectDetailsData(values));
Expand Down Expand Up @@ -106,6 +107,23 @@ const ProjectDetailsForm = ({ flag }) => {
}
}, [values.useDefaultODKCredentials]);

useEffect(() => {
if (!userSearchText) return;
if (!values.organisation_id && userSearchText) {
dispatch(CommonActions.SetSnackBar({ message: 'Please select an organization', variant: 'warning' }));
return;
}

dispatch(
GetUserListForSelect(`${VITE_API_URL}/users`, {
search: userSearchText,
page: 1,
results_per_page: 30,
org_id: values.organisation_id,
}),
);
}, [userSearchText]);

useEffect(() => {
if (isEmpty(organisationList)) return;
organisationList?.map((organization) => {
Expand Down Expand Up @@ -223,9 +241,7 @@ const ProjectDetailsForm = ({ flag }) => {
isLoading={userListLoading}
handleApiSearch={(value) => {
if (value) {
dispatch(
GetUserListForSelect(`${VITE_API_URL}/users`, { search: value, page: 1, results_per_page: 30 }),
);
setUserSearchText(value);
} else {
dispatch(UserActions.SetUserListForSelect([]));
}
Expand Down
Loading