diff --git a/userapp/api/routes/_util.py b/userapp/api/routes/_util.py index 597e966..d525c8b 100644 --- a/userapp/api/routes/_util.py +++ b/userapp/api/routes/_util.py @@ -1,7 +1,12 @@ +from fastapi import HTTPException +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from userapp.api.util import create_one_endpoint -from userapp.core.models.tables import UserSubmit, User +from userapp.core.models.tables import UserSubmit, User, UserProject, UserGroup +from userapp.core.models.views import JoinedProjectView, UserGroupView +from userapp.core.schemas.user_project import UserProjectPatch +from userapp.core.schemas.user_group import UserGroupPatch from userapp.core.schemas.user_submit import UserSubmitTableSchema, UserSubmitPost @@ -31,4 +36,74 @@ async def _patch_user_submit_nodes(session: AsyncSession, user: User, new_submit for_auth_netid=for_auth_netid, **submit_node.model_dump(), ) - await create_one_endpoint(session, UserSubmit, user_submit_model) \ No newline at end of file + await create_one_endpoint(session, UserSubmit, user_submit_model) + + +async def _patch_user_project( + session: AsyncSession, + user_id: int, + project_id: int, + patch: UserProjectPatch, +) -> JoinedProjectView: + """Patch the UserProject row identified by (user_id, project_id) and return + the matching row from the joined_projects view. Raises 404 if no such + membership exists.""" + + row = await session.scalar( + select(UserProject).where( + UserProject.user_id == user_id, + UserProject.project_id == project_id, + ) + ) + if row is None: + raise HTTPException( + status_code=404, + detail=f"User {user_id} is not a member of project {project_id}", + ) + + for key, value in patch.model_dump(exclude_unset=True).items(): + setattr(row, key, value) + await session.flush() + + view_row = await session.scalar( + select(JoinedProjectView).where( + JoinedProjectView.id == user_id, + JoinedProjectView.project_id == project_id, + ) + ) + return view_row + + +async def _patch_user_group( + session: AsyncSession, + user_id: int, + group_id: int, + patch: UserGroupPatch, +) -> UserGroupView: + """Patch the UserGroup row identified by (user_id, group_id) and return the + matching row from the user_group_memberships view. Raises 404 if no such + membership exists.""" + + row = await session.scalar( + select(UserGroup).where( + UserGroup.user_id == user_id, + UserGroup.group_id == group_id, + ) + ) + if row is None: + raise HTTPException( + status_code=404, + detail=f"User {user_id} is not a member of group {group_id}", + ) + + for key, value in patch.model_dump(exclude_unset=True).items(): + setattr(row, key, value) + await session.flush() + + view_row = await session.scalar( + select(UserGroupView).where( + UserGroupView.user_id == user_id, + UserGroupView.group_id == group_id, + ) + ) + return view_row diff --git a/userapp/api/routes/groups.py b/userapp/api/routes/groups.py index 662733b..1b6cbf0 100644 --- a/userapp/api/routes/groups.py +++ b/userapp/api/routes/groups.py @@ -6,13 +6,14 @@ from sqlalchemy import select, delete from userapp.core.models.views import GroupUserView -from userapp.core.schemas.user_group import UserGroupPost +from userapp.core.schemas.user_group import UserGroupPost, UserGroupPatch from userapp.db import session_generator from userapp.query_parser import get_filter_query_params from userapp.api.routes.security import check_is_admin +from userapp.api.routes._util import _patch_user_group from userapp.api.util import list_endpoint, get_one_endpoint, create_one_endpoint, update_one_endpoint, list_select_stmt, \ delete_one_endpoint, with_db_error_handling -from userapp.core.schemas.general import Relationship, GroupUserView as GroupUserViewSchema +from userapp.core.schemas.general import Relationship, GroupUserView as GroupUserViewSchema, UserGroupView as UserGroupViewSchema from userapp.core.schemas.groups import GroupGet, GroupPost, GroupPatch from userapp.core.schemas.users import UserGet @@ -86,3 +87,16 @@ async def remove_user_from_group(group_id: int, user_id: int, session=Depends(se if result.rowcount == 0: raise HTTPException(status_code=404, detail="User not found in group") + + +@with_db_error_handling +@router.patch("/{group_id}/users/{user_id}") +async def patch_user_in_group( + group_id: int, + user_id: int, + patch: UserGroupPatch, + session=Depends(session_generator), +) -> UserGroupViewSchema: + """Patch a user's membership in a group (managed_by).""" + return await _patch_user_group(session, user_id, group_id, patch) + diff --git a/userapp/api/routes/projects.py b/userapp/api/routes/projects.py index fc234df..c04f0e4 100644 --- a/userapp/api/routes/projects.py +++ b/userapp/api/routes/projects.py @@ -9,12 +9,13 @@ from userapp.api.util import list_endpoint, get_one_endpoint, create_one_endpoint, update_one_endpoint, delete_one_endpoint, \ list_select_stmt from userapp.core.schemas.projects import ProjectGet, ProjectPost, ProjectPatch -from userapp.core.schemas.user_project import UserProjectPost, UserProjectTableSchema +from userapp.core.schemas.user_project import UserProjectPost, UserProjectTableSchema, UserProjectPatch from userapp.core.schemas.note import NoteGet, NoteTableSchema, NotePost, NoteGetFull from userapp.core.schemas.general import JoinedProjectView as JoinedProjectViewSchema from userapp.core.models.tables import Project as ProjectTable, Note as NoteTable, UserNote, User, UserProject from userapp.core.models.views import JoinedProjectView as JoinedProjectViewTable from userapp.core.schemas.users import UserGet +from userapp.api.routes._util import _patch_user_project # Rebuild fields that use forward references to avoid circular imports NoteGet.model_rebuild(_types_namespace={'UserGet': UserGet}) @@ -111,6 +112,17 @@ async def remove_user_from_project(project_id: int, user_id: int, session=Depend raise HTTPException(status_code=404, detail="User not found in project") +@router.patch("/{project_id}/users/{user_id}") +async def patch_user_in_project( + project_id: int, + user_id: int, + patch: UserProjectPatch, + session=Depends(session_generator), +) -> JoinedProjectViewSchema: + """Patch a user's membership in a project (role, is_primary, managed_by).""" + return await _patch_user_project(session, user_id, project_id, patch) + + @router.get("/{project_id}/notes") async def get_project_notes(project_id: int, response: Response, page: int = 0, page_size: int = 100, filter_query_params=Depends(get_filter_query_params), session=Depends(session_generator)) -> list[NoteGetFull]: """Get notes associated with a project""" diff --git a/userapp/api/routes/users.py b/userapp/api/routes/users.py index b32d260..b5b9e05 100644 --- a/userapp/api/routes/users.py +++ b/userapp/api/routes/users.py @@ -11,7 +11,8 @@ list_select_stmt, update_one_endpoint from userapp.core.schemas.users import UserGet, UserPost, UserPatch, UserPostFull, UserPatchFull, \ RestrictedUserPatch, UserTableSchema, UserGetFull -from userapp.core.schemas.user_project import UserProjectPost, UserProjectTableSchema +from userapp.core.schemas.user_project import UserProjectPost, UserProjectTableSchema, UserProjectPatch +from userapp.core.schemas.user_group import UserGroupPatch from userapp.core.schemas.general import JoinedProjectView as JoinedProjectViewSchema, UserGroupView as UserGroupViewSchema from userapp.core.schemas.user_submit import UserSubmitPost, UserSubmitTableSchema, UserSubmitGet from userapp.core.schemas.note import NoteGet @@ -19,7 +20,7 @@ UserSubmitNodesView as UserSubmitNodesViewTable, UserSubmitNodesView, UserGroupView as UserGroupViewTable from userapp.core.models.tables import User as UserTable, UserProject, UserSubmit, Group, UserGroup, Note as NoteTable from userapp.api.load_options import user_load_options -from userapp.api.routes._util import _patch_user_submit_nodes +from userapp.api.routes._util import _patch_user_submit_nodes, _patch_user_project, _patch_user_group # Rebuild field for those that would cause circular imports NoteGet.model_rebuild(_types_namespace={'UserGet': UserGet}) @@ -139,3 +140,28 @@ async def get_user_groups(user_id: int, response: Response, page: int = 0, page_ # Join Group to User via the UserGroups association table and filter by user_id select_stmt = select(UserGroupViewTable).where(UserGroupViewTable.user_id == user_id) return await list_select_stmt(session, select_stmt, UserGroupViewTable, response, filter_query_params, page, page_size) + + +@router.patch("/{user_id}/projects/{project_id}") +async def patch_user_project( + user_id: int, + project_id: int, + patch: UserProjectPatch, + session=Depends(session_generator), + check_is_admin=Depends(check_is_admin), +) -> JoinedProjectViewSchema: + """Patch a user's membership in a project (role, is_primary, managed_by).""" + return await _patch_user_project(session, user_id, project_id, patch) + + +@router.patch("/{user_id}/groups/{group_id}") +async def patch_user_group( + user_id: int, + group_id: int, + patch: UserGroupPatch, + session=Depends(session_generator), + check_is_admin=Depends(check_is_admin), +) -> UserGroupViewSchema: + """Patch a user's membership in a group (managed_by).""" + return await _patch_user_group(session, user_id, group_id, patch) +