From ee991532818c3bf8562bce3f523233e5b5cef0dc Mon Sep 17 00:00:00 2001 From: clock Date: Wed, 20 May 2026 15:27:36 -0500 Subject: [PATCH 1/5] Checkpoint --- ...f2ec55925c4c_external_management_update.py | 186 ++++++++++++++++++ userapp/api/load_options.py | 6 +- userapp/api/routes/groups.py | 14 +- userapp/api/routes/users.py | 17 +- userapp/api/tests/conftest.py | 2 +- userapp/api/tests/test_groups.py | 50 ++++- userapp/api/tests/test_projects.py | 54 ++++- userapp/core/models/enum.py | 6 + userapp/core/models/tables.py | 38 ++-- userapp/core/models/views.py | 59 +++++- userapp/core/schemas/general.py | 45 ++++- userapp/core/schemas/groups.py | 1 + userapp/core/schemas/user_group.py | 45 ++++- userapp/core/schemas/user_project.py | 21 +- userapp/core/schemas/users.py | 4 +- 15 files changed, 505 insertions(+), 43 deletions(-) create mode 100644 alembic/versions/f2ec55925c4c_external_management_update.py diff --git a/alembic/versions/f2ec55925c4c_external_management_update.py b/alembic/versions/f2ec55925c4c_external_management_update.py new file mode 100644 index 0000000..efebd44 --- /dev/null +++ b/alembic/versions/f2ec55925c4c_external_management_update.py @@ -0,0 +1,186 @@ +"""External management update + +Revision ID: f2ec55925c4c +Revises: 47f8adc9859b +Create Date: 2026-05-15 12:54:31.384728 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'f2ec55925c4c' +down_revision: Union[str, Sequence[str], None] = '47f8adc9859b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +entity_manager_enum = postgresql.ENUM('APPLICATION', 'MANIFEST', 'MORGRIDGE_AD', name='entity_manager_enum', create_type=False) + + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + entity_manager_enum.create(bind, checkfirst=True) + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_groups', sa.Column('managed_by', entity_manager_enum, nullable=False, server_default='APPLICATION')) + op.add_column('user_groups', sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False)) + op.add_column('user_groups', sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False)) + op.create_index('idx_user_group_managed_by', 'user_groups', ['group_id', 'managed_by', 'user_id'], unique=False) + op.add_column('user_projects', sa.Column('managed_by', entity_manager_enum, nullable=False, server_default='APPLICATION')) + op.add_column('user_projects', sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False)) + op.add_column('user_projects', sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False)) + op.create_index('idx_user_project_managed_by', 'user_projects', ['project_id', 'managed_by', 'user_id'], unique=False) + op.add_column('users', sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False)) + op.add_column('users', sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False)) + # ### end Alembic commands ### + + # ### commands generated by human ### + op.execute('DROP VIEW IF EXISTS joined_projects') + op.execute(""" + CREATE VIEW joined_projects AS + SELECT + u.id, + p.id AS project_id, + p.name AS project_name, + p.staff1 AS project_staff1, + p.staff2 AS project_staff2, + p.status AS project_status, + p.last_contact AS project_last_contact, + p.accounting_group AS project_accounting_group, + up.is_primary AS is_primary, + up.managed_by, + up.created_at, + up.updated_at, + u.name, + u.username, + u.email1, + u.email2, + u.netid, + u.netid_exp_datetime, + u.phone1, + u.phone2, + u.is_admin, + u.active, + u.date, + u.unix_uid, + u.position, + up.role, + ( + SELECT n.ticket + FROM notes n + LEFT JOIN user_notes un ON n.id = un.note_id + WHERE un.user_id = up.user_id AND un.project_id = up.project_id + ORDER BY n.id DESC + LIMIT 1 + ) AS last_note_ticket + FROM user_projects up + JOIN users u ON up.user_id = u.id + JOIN projects p ON up.project_id = p.id + """) + op.execute(""" + CREATE OR REPLACE VIEW group_users AS + SELECT + ug.group_id, + ug.user_id, + ug.managed_by, + ug.created_at, + ug.updated_at, + u.name, + u.netid, + u.username, + u.is_admin, + u.active, + u.unix_uid + FROM user_groups ug + JOIN users u ON ug.user_id = u.id + """) + op.execute(""" + CREATE VIEW user_group_memberships AS + SELECT + ug.group_id, + ug.user_id, + ug.managed_by, + ug.created_at, + ug.updated_at, + g.name, + g.point_of_contact, + g.unix_gid, + g.has_groupdir + FROM user_groups ug + JOIN groups g ON ug.group_id = g.id + """) + # Update the created_at date to the previous date field value + op.execute("UPDATE users SET created_at = date WHERE date IS NOT NULL") + # ### end commands generated by human ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands generated by human ### + op.execute("DROP VIEW IF EXISTS user_group_memberships") + op.execute("DROP VIEW IF EXISTS group_users") + op.execute("DROP VIEW IF EXISTS joined_projects") + # ### end commands generated by human ### + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'updated_at') + op.drop_column('users', 'created_at') + op.drop_index('idx_user_project_managed_by', table_name='user_projects') + op.drop_column('user_projects', 'updated_at') + op.drop_column('user_projects', 'created_at') + op.drop_column('user_projects', 'managed_by') + op.drop_index('idx_user_group_managed_by', table_name='user_groups') + op.drop_column('user_groups', 'updated_at') + op.drop_column('user_groups', 'created_at') + op.drop_column('user_groups', 'managed_by') + # ### end Alembic commands ### + + # ### commands generated by human ### + op.execute(""" + CREATE VIEW joined_projects AS + SELECT + u.id, + p.id AS project_id, + p.name AS project_name, + p.staff1 AS project_staff1, + p.staff2 AS project_staff2, + p.status AS project_status, + p.last_contact AS project_last_contact, + p.accounting_group AS project_accounting_group, + up.is_primary AS is_primary, + u.name, + u.username, + u.email1, + u.email2, + u.netid, + u.netid_exp_datetime, + u.phone1, + u.phone2, + u.is_admin, + u.active, + u.date, + u.unix_uid, + u.position, + up.role, + ( + SELECT n.ticket + FROM notes n + LEFT JOIN user_notes un ON n.id = un.note_id + WHERE un.user_id = up.user_id AND un.project_id = up.project_id + ORDER BY n.id DESC + LIMIT 1 + ) AS last_note_ticket + FROM user_projects up + JOIN users u ON up.user_id = u.id + JOIN projects p ON up.project_id = p.id + """) + # ### end commands generated by human ### + + bind = op.get_bind() + entity_manager_enum.drop(bind, checkfirst=True) + diff --git a/userapp/api/load_options.py b/userapp/api/load_options.py index 83eb12a..4be0147 100644 --- a/userapp/api/load_options.py +++ b/userapp/api/load_options.py @@ -1,11 +1,11 @@ from sqlalchemy.orm import selectinload -from userapp.core.models.tables import User as UserTable, Note as NoteTable, Group as GroupTable -from userapp.core.models.views import JoinedProjectView as JoinedProjectViewTable +from userapp.core.models.tables import User as UserTable, Note as NoteTable +from userapp.core.models.views import JoinedProjectView as JoinedProjectViewTable, UserGroupView as UserGroupViewTable user_load_options = [ selectinload(UserTable.notes).joinedload(NoteTable.author), - selectinload(UserTable.groups).joinedload(GroupTable.point_of_contact_user), + selectinload(UserTable.groups).selectinload(UserGroupViewTable.point_of_contact_user), selectinload(UserTable.projects).selectinload(JoinedProjectViewTable.staff1_user), selectinload(UserTable.projects).selectinload(JoinedProjectViewTable.staff2_user), selectinload(UserTable.user_forms), diff --git a/userapp/api/routes/groups.py b/userapp/api/routes/groups.py index f00e05d..662733b 100644 --- a/userapp/api/routes/groups.py +++ b/userapp/api/routes/groups.py @@ -5,12 +5,14 @@ from sqlalchemy import select, delete +from userapp.core.models.views import GroupUserView +from userapp.core.schemas.user_group import UserGroupPost 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.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 +from userapp.core.schemas.general import Relationship, GroupUserView as GroupUserViewSchema from userapp.core.schemas.groups import GroupGet, GroupPost, GroupPatch from userapp.core.schemas.users import UserGet @@ -54,18 +56,18 @@ async def update_group(group_id: int, group: GroupPatch, session=Depends(session @router.get("/{group_id}/users") -async def get_group_users(group_id: int, response: Response, page: int = 0, page_size: int = 100, filter_query_params=Depends(get_filter_query_params), session=Depends(session_generator)) -> List[UserGet]: +async def get_group_users(group_id: int, response: Response, page: int = 0, page_size: int = 100, filter_query_params=Depends(get_filter_query_params), session=Depends(session_generator)) -> List[GroupUserViewSchema]: """Get users associated with a group""" - select_stmt = select(UserTable).join(UserGroup).where(UserGroup.group_id == group_id) - return await list_select_stmt(session, select_stmt, UserTable, response, filter_query_params, page, page_size) + select_stmt = select(GroupUserView).where(GroupUserView.group_id == group_id) + return await list_select_stmt(session, select_stmt, GroupUserView, response, filter_query_params, page, page_size) @with_db_error_handling @router.post("/{group_id}/users", status_code=201) -async def add_user_to_group(group_id: int, user: Relationship, session=Depends(session_generator)) -> dict: +async def add_user_to_group(group_id: int, user: UserGroupPost, session=Depends(session_generator)) -> dict: """Add user to a group""" - user_group = UserGroup(user_id=user.id, group_id=group_id) + user_group = UserGroup(group_id=group_id, **user.model_dump(exclude_unset=True)) session.add(user_group) return {"message": "Users added to group successfully"} diff --git a/userapp/api/routes/users.py b/userapp/api/routes/users.py index 0d4fe98..b32d260 100644 --- a/userapp/api/routes/users.py +++ b/userapp/api/routes/users.py @@ -12,11 +12,11 @@ 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.general import JoinedProjectView as JoinedProjectViewSchema +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 from userapp.core.models.views import JoinedProjectView as JoinedProjectViewTable, \ - UserSubmitNodesView as UserSubmitNodesViewTable, UserSubmitNodesView + 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 @@ -24,6 +24,8 @@ # Rebuild field for those that would cause circular imports NoteGet.model_rebuild(_types_namespace={'UserGet': UserGet}) JoinedProjectViewSchema.model_rebuild(_types_namespace={'UserGet': UserGet}) +UserGroupViewSchema.model_rebuild(_types_namespace={'UserGet': UserGet}) + router = APIRouter( prefix="/users", @@ -131,14 +133,9 @@ async def get_user_submit_nodes(user_id: int, response: Response, page: int = 0, @router.get("/{user_id}/groups") -async def get_user_groups(user_id: int, response: Response, page: int = 0, page_size: int = 100, filter_query_params=Depends(get_filter_query_params), session=Depends(session_generator), check_is_user=Depends(check_is_user)) -> list[GroupGet]: +async def get_user_groups(user_id: int, response: Response, page: int = 0, page_size: int = 100, filter_query_params=Depends(get_filter_query_params), session=Depends(session_generator), check_is_user=Depends(check_is_user)) -> list[UserGroupViewSchema]: """Get groups associated with a user""" # Join Group to User via the UserGroups association table and filter by user_id - select_stmt = ( - select(Group) - .join(UserGroup, Group.id == UserGroup.group_id) - .join(UserTable, UserGroup.user_id == UserTable.id) - .where(UserTable.id == user_id) - ) - return await list_select_stmt(session, select_stmt, Group, response, filter_query_params, page, page_size) + 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) diff --git a/userapp/api/tests/conftest.py b/userapp/api/tests/conftest.py index f0eea65..bb6dffc 100644 --- a/userapp/api/tests/conftest.py +++ b/userapp/api/tests/conftest.py @@ -184,7 +184,7 @@ def user(existing_admin_client: Client, project_factory: Callable, group_factory user = user_factory(0, project_id=project['id']) # Add the group after the user is created - group_addition_response = existing_admin_client.post(f"/groups/{group['id']}/users", json={"id": user['id']}) + group_addition_response = existing_admin_client.post(f"/groups/{group['id']}/users", json={"user_id": user['id']}) assert group_addition_response.status_code == 201 user = existing_admin_client.get(f"/users/{user['id']}").json() diff --git a/userapp/api/tests/test_groups.py b/userapp/api/tests/test_groups.py index d169ec2..221baaa 100644 --- a/userapp/api/tests/test_groups.py +++ b/userapp/api/tests/test_groups.py @@ -1,5 +1,7 @@ import random +from userapp.core.models.enum import EntityManagerEnum + group_data_f = lambda: { "name": f"test-group-{random.randint(0, 10000000)}", "point_of_contact": None, @@ -225,7 +227,7 @@ def test_add_delete_user_to_group(self, admin_client, admin_user): response = admin_client.post( f"/groups/{group_id}/users", json={ - "id": user_id + "user_id": user_id } ) assert response.status_code == 201, f"Adding a user to a group should return a 200 status code. Got {response.content} instead." @@ -256,7 +258,7 @@ def test_get_group_users(self, admin_client): response = admin_client.post( f"/groups/{group_id}/users", json={ - "id": user_id + "user_id": user_id } ) assert response.status_code == 201, f"Adding a user to a group should return a 200 status code. Got {response.content} instead." @@ -308,7 +310,7 @@ def test_remove_user_from_group(self, admin_client): response = admin_client.post( f"/groups/{group_id}/users", json={ - "id": user_id + "user_id": user_id } ) assert response.status_code == 201, f"Adding a user to a group should return a 200 status code. Got {response.content} instead." @@ -319,6 +321,48 @@ def test_remove_user_from_group(self, admin_client): ) assert response.status_code == 204, f"Deleting a user from a group should return a 204 status code. Got {response.content} instead." + def test_add_user_to_group_default_managed_by(self, admin_client, group, user): + """Test that adding a user to a group without specifying managed_by defaults to APPLICATION""" + + response = admin_client.post( + f"/groups/{group['id']}/users", + json={"user_id": user['id']} + ) + assert response.status_code == 201, f"Adding a user to a group should return 201, got {response.text}" + + users_response = admin_client.get(f"/groups/{group['id']}/users") + assert users_response.status_code == 200, f"Getting group users should return 200, got {users_response.text}" + + group_users = users_response.json() + matched = [u for u in group_users if u['user_id'] == user['id']] + assert len(matched) == 1, "The added user should appear in the group's user list" + assert matched[0]['managed_by'] == EntityManagerEnum.APPLICATION.value, \ + f"managed_by should default to APPLICATION, got {matched[0]['managed_by']}" + + # Clean up + admin_client.delete(f"/groups/{group['id']}/users/{user['id']}") + + def test_add_user_to_group_manifest_managed_by(self, admin_client, group, user): + """Test that adding a user to a group with managed_by=MANIFEST is persisted and returned correctly""" + + response = admin_client.post( + f"/groups/{group['id']}/users", + json={"user_id": user['id'], "managed_by": EntityManagerEnum.MANIFEST.value} + ) + assert response.status_code == 201, f"Adding a user to a group should return 201, got {response.text}" + + users_response = admin_client.get(f"/groups/{group['id']}/users") + assert users_response.status_code == 200, f"Getting group users should return 200, got {users_response.text}" + + group_users = users_response.json() + matched = [u for u in group_users if u['user_id'] == user['id']] + assert len(matched) == 1, "The added user should appear in the group's user list" + assert matched[0]['managed_by'] == EntityManagerEnum.MANIFEST.value, \ + f"managed_by should be MANIFEST, got {matched[0]['managed_by']}" + + # Clean up + admin_client.delete(f"/groups/{group['id']}/users/{user['id']}") + def test_delete_group(self, admin_client): """Test deleting a group from the database""" diff --git a/userapp/api/tests/test_projects.py b/userapp/api/tests/test_projects.py index 85eb873..95e1d1a 100644 --- a/userapp/api/tests/test_projects.py +++ b/userapp/api/tests/test_projects.py @@ -1,4 +1,4 @@ -from userapp.core.models.enum import RoleEnum +from userapp.core.models.enum import RoleEnum, EntityManagerEnum from userapp.api.tests.fake_data import project_data_f class TestProjects: @@ -194,3 +194,55 @@ def test_add_user_to_project(self, admin_client, project, user): user_ids_in_project = [user['id'] for user in project_users] assert user['id'] in user_ids_in_project, "The created user should be in the project's users list" + + def test_add_user_to_project_default_managed_by(self, admin_client, project, user): + """Test that adding a user to a project without specifying managed_by defaults to APPLICATION""" + + response = admin_client.post( + f"/projects/{project['id']}/users", + json={ + "user_id": user['id'], + "is_primary": False, + "role": RoleEnum.MEMBER.value, + } + ) + assert response.status_code == 201, f"Adding a user to a project should return 201, got {response.text}" + + project_users_response = admin_client.get(f"/projects/{project['id']}/users") + assert project_users_response.status_code == 200, f"Getting project users should return 200, got {project_users_response.text}" + + project_users = project_users_response.json() + matched = [u for u in project_users if u['id'] == user['id']] + assert len(matched) == 1, "The added user should appear in the project's user list" + assert matched[0]['managed_by'] == EntityManagerEnum.APPLICATION.value, \ + f"managed_by should default to APPLICATION, got {matched[0]['managed_by']}" + + # Clean up + admin_client.delete(f"/projects/{project['id']}/users/{user['id']}") + + def test_add_user_to_project_manifest_managed_by(self, admin_client, project, user): + """Test that adding a user to a project with managed_by=MANIFEST is persisted and returned correctly""" + + response = admin_client.post( + f"/projects/{project['id']}/users", + json={ + "user_id": user['id'], + "is_primary": False, + "role": RoleEnum.MEMBER.value, + "managed_by": EntityManagerEnum.MANIFEST.value, + } + ) + assert response.status_code == 201, f"Adding a user to a project should return 201, got {response.text}" + + project_users_response = admin_client.get(f"/projects/{project['id']}/users") + assert project_users_response.status_code == 200, f"Getting project users should return 200, got {project_users_response.text}" + + project_users = project_users_response.json() + matched = [u for u in project_users if u['id'] == user['id']] + assert len(matched) == 1, "The added user should appear in the project's user list" + assert matched[0]['managed_by'] == EntityManagerEnum.MANIFEST.value, \ + f"managed_by should be MANIFEST, got {matched[0]['managed_by']}" + + # Clean up + admin_client.delete(f"/projects/{project['id']}/users/{user['id']}") + diff --git a/userapp/core/models/enum.py b/userapp/core/models/enum.py index eec1ecf..7d52db7 100644 --- a/userapp/core/models/enum.py +++ b/userapp/core/models/enum.py @@ -28,3 +28,9 @@ class FormStatusEnum(Enum): PENDING = "PENDING" APPROVED = "APPROVED" DENIED = "DENIED" + +class EntityManagerEnum(Enum): + """Enum to record what is managing a given entity""" + APPLICATION = "APPLICATION" # Default - Userapp manual management + MANIFEST = "MANIFEST" # Manifest Groups + MORGRIDGE_AD = "MORGRIDGE_ACTIVE_DIRECTORY" diff --git a/userapp/core/models/tables.py b/userapp/core/models/tables.py index 9fdc131..99dcb0a 100644 --- a/userapp/core/models/tables.py +++ b/userapp/core/models/tables.py @@ -7,11 +7,13 @@ from sqlalchemy import Enum as SQLEnum from sqlalchemy.dialects.postgresql import JSONB -from userapp.core.models.enum import FormStatusEnum, FormTypeEnum, RoleEnum, PositionEnum, HttpRequestMethodEnum +from userapp.core.models.enum import FormStatusEnum, FormTypeEnum, RoleEnum, PositionEnum, HttpRequestMethodEnum, \ + EntityManagerEnum from userapp.core.models.main import Base from userapp.core.models.views import JoinedProjectView from userapp.core.models.views import UserSubmitNodesView from userapp.core.models.views import UserApplicationView +from userapp.core.models.views import UserGroupView class Group(Base): @@ -106,9 +108,11 @@ class User(Base): phone2 = Column(String(255)) is_admin = Column(Boolean, default=False) active = Column(Boolean, nullable=False, server_default='false') - date = Column(TIMESTAMP, nullable=False, server_default=func.now()) + date = Column(TIMESTAMP, nullable=False, server_default=func.now()) # TODO: Delete this after we have migrated to created_at unix_uid = Column(Integer) position = Column(SQLEnum(PositionEnum, name="position_enum"), nullable=True) + created_at = Column(TIMESTAMP, nullable=False, server_default=func.now()) + updated_at = Column(TIMESTAMP, nullable=False, server_default=func.now(), onupdate=func.now()) notes: Mapped[List["Note"]] = relationship( secondary="user_notes", @@ -133,13 +137,11 @@ class User(Base): viewonly=True, ) - groups: Mapped[List["Group"]] = relationship( - secondary="user_groups", - primaryjoin="User.id==UserGroup.user_id", - secondaryjoin="Group.id==UserGroup.group_id", - foreign_keys="[UserGroup.user_id, UserGroup.group_id]", - lazy="selectin", - backref="users" + groups: Mapped[List["UserGroupView"]] = relationship( + "UserGroupView", + primaryjoin="User.id==UserGroupView.user_id", + foreign_keys="[UserGroupView.user_id]", + lazy="selectin" ) user_forms: Mapped[List[UserApplicationView]] = relationship( @@ -155,7 +157,14 @@ class UserGroup(Base): id = Column(Integer, primary_key=True, index=True) group_id = Column(Integer, ForeignKey('groups.id', ondelete="CASCADE"), nullable=False, index=True) user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False) - __table_args__ = (UniqueConstraint('user_id', 'group_id', name='user_groups_distinct'),) + managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum"), nullable=False, server_default=EntityManagerEnum.APPLICATION.value) + created_at = Column(TIMESTAMP, nullable=False, server_default=func.now()) + updated_at = Column(TIMESTAMP, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint('user_id', 'group_id', name='user_groups_distinct'), + Index('idx_user_group_managed_by', 'group_id', 'managed_by', 'user_id'), + ) class UserNote(Base): @@ -179,7 +188,14 @@ class UserProject(Base): user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False) role = Column(SQLEnum(RoleEnum, name="role_enum"), nullable=True) is_primary = Column(Boolean, nullable=False, default=False) - __table_args__ = (UniqueConstraint('user_id', 'project_id', name='user_projects_distinct'),) + managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum"), nullable=False, server_default=EntityManagerEnum.APPLICATION.value) + created_at = Column(TIMESTAMP, nullable=False, server_default=func.now()) + updated_at = Column(TIMESTAMP, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint('user_id', 'project_id', name='user_projects_distinct'), + Index('idx_user_project_managed_by', 'project_id', 'managed_by', 'user_id'), + ) class UserSubmit(Base): diff --git a/userapp/core/models/views.py b/userapp/core/models/views.py index bf59f0f..891ef79 100644 --- a/userapp/core/models/views.py +++ b/userapp/core/models/views.py @@ -4,7 +4,7 @@ from sqlalchemy import Enum as SQLEnum from sqlalchemy.orm import Mapped, relationship -from userapp.core.models.enum import RoleEnum, PositionEnum, FormTypeEnum, FormStatusEnum +from userapp.core.models.enum import RoleEnum, PositionEnum, FormTypeEnum, FormStatusEnum, EntityManagerEnum from userapp.core.models.main import Base @@ -44,7 +44,15 @@ class JoinedProjectView(Base): ) project_last_contact = Column(TIMESTAMP) project_accounting_group = Column(String(255)) + + # From the Project -> Users ( Many ) + created_at = Column(TIMESTAMP) + updated_at = Column(TIMESTAMP) + managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum")) + role = Column(SQLEnum(RoleEnum, name="role_enum")) is_primary = Column(Boolean) + + # From the User name = Column(String(255)) username = Column(String(255)) email1 = Column(String(255)) @@ -58,7 +66,6 @@ class JoinedProjectView(Base): date = Column(TIMESTAMP) unix_uid = Column(Integer) position = Column(SQLEnum(PositionEnum, name="position_enum")) - role = Column(SQLEnum(RoleEnum, name="role_enum")) last_note_ticket = Column(String(9)) @@ -121,3 +128,51 @@ class UserApplicationView(Base): viewonly=True, foreign_keys="[UserApplicationView.pi_id]", ) + + +class GroupUserView(Base): + """A view intended to show a groups users in the context of a known group""" + __tablename__ = 'group_users' + __table_args__ = {'info': dict(is_view=True)} + + # Columns from UserGroup Table + group_id = Column(Integer, ForeignKey("groups.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum")) + created_at = Column(TIMESTAMP) + updated_at = Column(TIMESTAMP) + + # From User + name = Column(String(255)) + netid = Column(String(255)) + username = Column(String(255)) + is_admin = Column(Boolean) + active = Column(Boolean) + unix_uid = Column(Integer) + + +class UserGroupView(Base): + """A view intended to show a user's groups in the context of a known user""" + __tablename__ = 'user_group_memberships' + __table_args__ = {'info': dict(is_view=True)} + + # Columns from UserGroup Table + group_id = Column(Integer, ForeignKey("groups.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum")) + created_at = Column(TIMESTAMP) + updated_at = Column(TIMESTAMP) + + # From Group + name = Column(String(255)) + point_of_contact = Column(Integer) + unix_gid = Column(Integer) + has_groupdir = Column(Boolean) + + point_of_contact_user: Mapped[Optional["User"]] = relationship( + "User", + primaryjoin="UserGroupView.point_of_contact==foreign(User.id)", + lazy="selectin", + viewonly=True, + foreign_keys="[UserGroupView.point_of_contact]", + ) diff --git a/userapp/core/schemas/general.py b/userapp/core/schemas/general.py index 0b8142f..225b4d4 100644 --- a/userapp/core/schemas/general.py +++ b/userapp/core/schemas/general.py @@ -2,7 +2,7 @@ from typing import Optional, TYPE_CHECKING from pydantic import BaseModel as PydanticBaseModel, ConfigDict, model_validator, Field, EmailStr, computed_field -from userapp.core.models.enum import RoleEnum, PositionEnum, FormStatusEnum, FormTypeEnum +from userapp.core.models.enum import RoleEnum, PositionEnum, FormStatusEnum, FormTypeEnum, EntityManagerEnum class BaseModel(PydanticBaseModel): @@ -37,7 +37,15 @@ class JoinedProjectView(BaseModel): project_status: Optional[str] = Field(default=None) project_last_contact: Optional[datetime] = Field(default=None) project_accounting_group: Optional[str] = Field(default=None) + + # From the relationship + managed_by: EntityManagerEnum + created_at: datetime + updated_at: datetime is_primary: Optional[bool] = Field(default=None) + role: Optional[RoleEnum] = Field(default=None) + + # From the user name: str username: Optional[str] = Field(default=None) email1: Optional[EmailStr] = Field(default=None) @@ -51,7 +59,6 @@ class JoinedProjectView(BaseModel): date: Optional[datetime] = Field(default=None) unix_uid: Optional[int] = Field(default=None) position: Optional[PositionEnum] = Field(default=None) - role: Optional[RoleEnum] = Field(default=None) last_note_ticket: Optional[str] = Field(default=None) @computed_field @@ -102,3 +109,37 @@ class UserApplicationViewFull(UserApplicationView): created_by: Optional["UserGet"] = Field(default=None, validation_alias='created_by_user') updated_by: Optional["UserGet"] = Field(default=None, validation_alias='updated_by_user') pi_user: Optional["UserGet"] = Field(default=None, validation_alias='pi_user') + +class GroupUserView(BaseModel): + """Represents a user record within the context of a known group. Known Group and its relationship to a User""" + + # From UserGroup table + group_id: int + user_id: int + managed_by: Optional[EntityManagerEnum] = Field(default=None) + created_at: Optional[datetime] = Field(default=None) + updated_at: Optional[datetime] = Field(default=None) + + # From User table + name: str + netid: Optional[str] = Field(default=None) + username: Optional[str] = Field(default=None) + is_admin: Optional[bool] = Field(default=None) + active: Optional[bool] = Field(default=None) + unix_uid: Optional[int] = Field(default=None) + +class UserGroupView(BaseModel): + """Represents a group record in the context of a known user. Known User and its relationship to a Group""" + + # From UserGroup table + group_id: int + user_id: int + managed_by: Optional[EntityManagerEnum] = Field(default=None) + created_at: Optional[datetime] = Field(default=None) + updated_at: Optional[datetime] = Field(default=None) + + # From the Group table + name: str + point_of_contact: Optional["UserGet"] = Field(default=None, validation_alias='point_of_contact_user') + unix_gid: Optional[int] = Field(default=None) + has_groupdir: bool diff --git a/userapp/core/schemas/groups.py b/userapp/core/schemas/groups.py index 3205b2e..a68033a 100644 --- a/userapp/core/schemas/groups.py +++ b/userapp/core/schemas/groups.py @@ -4,6 +4,7 @@ from userapp.core.schemas.general import BaseModel + def group_name_validator(name: str) -> str: if name is None: return name diff --git a/userapp/core/schemas/user_group.py b/userapp/core/schemas/user_group.py index c7c13cc..2df2751 100644 --- a/userapp/core/schemas/user_group.py +++ b/userapp/core/schemas/user_group.py @@ -1,5 +1,48 @@ +from datetime import datetime +from typing import Optional + +from pydantic import field_serializer + +from userapp.core.models.enum import EntityManagerEnum from userapp.core.schemas.general import BaseModel -class UserGroupPost(BaseModel): +class UserGroupBase(BaseModel): group_id: int user_id: int + managed_by: EntityManagerEnum + created_at: datetime + updated_at: datetime + + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None + + +class UserGroupGet(BaseModel): + group_id: int + user_id: int + managed_by: EntityManagerEnum + created_at: datetime + updated_at: datetime + + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None + + +class UserGroupPatch(BaseModel): + managed_by: Optional[EntityManagerEnum] = None + + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None + + +class UserGroupPost(BaseModel): + user_id: int + managed_by: Optional[EntityManagerEnum] = None + + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None + diff --git a/userapp/core/schemas/user_project.py b/userapp/core/schemas/user_project.py index 4bc501c..55fff13 100644 --- a/userapp/core/schemas/user_project.py +++ b/userapp/core/schemas/user_project.py @@ -1,8 +1,9 @@ +from datetime import datetime from typing import Optional from pydantic import field_serializer, ConfigDict, Field -from userapp.core.models.enum import RoleEnum +from userapp.core.models.enum import RoleEnum, EntityManagerEnum from userapp.core.schemas.general import BaseModel class UserProjectTableSchema(BaseModel): @@ -15,11 +16,18 @@ class UserProjectTableSchema(BaseModel): user_id: int role: Optional[RoleEnum] = Field(default=None) is_primary: bool + managed_by: Optional[EntityManagerEnum] = Field(default=None) + updated_at: Optional[datetime] = Field(default=None) + created_at: Optional[datetime] = Field(default=None) @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: return role.name if role is not None else None + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None + class UserProjectGet(UserProjectTableSchema): """Exact same as UserProjectTableSchema for now, but kept separate for future changes""" pass @@ -28,16 +36,27 @@ class UserProjectPatch(BaseModel): role: Optional[RoleEnum] = None is_primary: Optional[bool] = None + managed_by: Optional[EntityManagerEnum] = None @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: return role.name if role is not None else None + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None + + class UserProjectPost(BaseModel): user_id: int role: Optional[RoleEnum] = None is_primary: bool = False + managed_by: Optional[EntityManagerEnum] = None @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: return role.name if role is not None else None + + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.name if managed_by is not None else None diff --git a/userapp/core/schemas/users.py b/userapp/core/schemas/users.py index d9d4933..5c31804 100644 --- a/userapp/core/schemas/users.py +++ b/userapp/core/schemas/users.py @@ -3,7 +3,7 @@ from datetime import datetime import re -from userapp.core.schemas.general import JoinedProjectView, UserApplicationView as UserApplicationViewSchema +from userapp.core.schemas.general import JoinedProjectView, UserApplicationView as UserApplicationViewSchema, UserGroupView from userapp.core.schemas.note import NoteGet from userapp.core.schemas.user_submit import UserSubmitGet, UserSubmitPost from userapp.core.schemas.general import BaseModel @@ -90,7 +90,7 @@ class UserGetFull(UserGet): notes: list["NoteGet"] = Field(default=[]) submit_nodes: list["UserSubmitGet"] = Field(default=[]) projects: list["JoinedProjectView"] = Field(default=[]) - groups: list["GroupGet"] = Field(default=[]) + groups: list["UserGroupView"] = Field(default=[]) user_forms: list["UserApplicationViewSchema"] = Field(default=[]) class UserPost(BaseModel): From 4dada87c1bdc1721f4cd0a379168d503acd09d60 Mon Sep 17 00:00:00 2001 From: clock Date: Thu, 21 May 2026 10:53:48 -0500 Subject: [PATCH 2/5] Add Managed endpoints --- userapp/api/routes/__init__.py | 2 + userapp/api/routes/managed/__init__.py | 9 + userapp/api/routes/managed/_factory.py | 261 ++++++++++++++ userapp/api/routes/managed/manifest.py | 8 + userapp/api/routes/managed/morgridge_ad.py | 8 + userapp/api/tests/test_managed.py | 383 +++++++++++++++++++++ userapp/core/models/views.py | 10 +- userapp/core/schemas/user_group.py | 5 + userapp/core/schemas/user_project.py | 8 + 9 files changed, 690 insertions(+), 4 deletions(-) create mode 100644 userapp/api/routes/managed/__init__.py create mode 100644 userapp/api/routes/managed/_factory.py create mode 100644 userapp/api/routes/managed/manifest.py create mode 100644 userapp/api/routes/managed/morgridge_ad.py create mode 100644 userapp/api/tests/test_managed.py diff --git a/userapp/api/routes/__init__.py b/userapp/api/routes/__init__.py index e60d5ef..c27c3bf 100644 --- a/userapp/api/routes/__init__.py +++ b/userapp/api/routes/__init__.py @@ -1,5 +1,6 @@ from userapp.api.routes.forms import router as forms_router from .groups import router as groups_router +from .managed import router as managed_router from .pi_projects import router as pi_projects_router from .projects import router as projects_router from .security import router as security_router @@ -12,6 +13,7 @@ routes_router, forms_router, groups_router, + managed_router, pi_projects_router, projects_router, security_router, diff --git a/userapp/api/routes/managed/__init__.py b/userapp/api/routes/managed/__init__.py new file mode 100644 index 0000000..744bf9b --- /dev/null +++ b/userapp/api/routes/managed/__init__.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from .manifest import router as manifest_router +from .morgridge_ad import router as morgridge_ad_router + +router = APIRouter(prefix="/managed") +router.include_router(manifest_router) +router.include_router(morgridge_ad_router) + diff --git a/userapp/api/routes/managed/_factory.py b/userapp/api/routes/managed/_factory.py new file mode 100644 index 0000000..e5efbb2 --- /dev/null +++ b/userapp/api/routes/managed/_factory.py @@ -0,0 +1,261 @@ +"""Factory for creating managed-sync routers. + +Both the Manifest and Morgridge AD sync routers are structurally identical; they +differ only in the EntityManagerEnum value they own and in their URL prefix / tag. +This module exposes a single ``create_managed_router`` factory that builds a +fully-formed APIRouter with both PUT endpoints wired up. +""" +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete, select + +from userapp.core.models.enum import EntityManagerEnum +from userapp.core.models.tables import UserProject, UserGroup, Project, Group, User +from userapp.core.models.views import ( + JoinedProjectView as JoinedProjectViewTable, + UserGroupView as UserGroupViewTable, +) +from userapp.core.schemas.general import JoinedProjectView, UserGroupView +from userapp.core.schemas.user_group import ManagedUserGroupPut +from userapp.core.schemas.user_project import ManagedUserProjectPut +from userapp.core.schemas.users import UserGet +from userapp.api.routes.security import check_is_admin +from userapp.api.util import with_db_error_handling +from userapp.db import session_generator + +# Rebuild schemas that contain forward references once at import time. +JoinedProjectView.model_rebuild(_types_namespace={"UserGet": UserGet}) +UserGroupView.model_rebuild(_types_namespace={"UserGet": UserGet}) + + +def create_managed_router( + prefix: str, + tags: List[str], + manager: EntityManagerEnum, +) -> APIRouter: + """Return an APIRouter with ``PUT /projects/{id}/users`` and + ``PUT /groups/{id}/users`` endpoints that perform idempotent full-replace + syncs restricted to *manager*-owned rows. + + Args: + prefix: URL prefix for the router (e.g. ``"/manifest"``). + tags: OpenAPI tags list. + manager: The ``EntityManagerEnum`` value that owns the rows this router + may create / update / delete. + """ + router = APIRouter( + prefix=prefix, + tags=tags, + dependencies=[Depends(check_is_admin)], + responses={404: {"description": "Not found"}}, + ) + + # ------------------------------------------------------------------ # + # Projects # + # ------------------------------------------------------------------ # + + @with_db_error_handling + @router.put("/projects/{project_id}/users", response_model=List[JoinedProjectView]) + async def sync_project_users( + project_id: int, + users: List[ManagedUserProjectPut], + session=Depends(session_generator), + ) -> List[JoinedProjectView]: + f"""Replace the set of {manager.name}-managed members of a project. + + Rows owned by other managers are not touched. + Users no longer in the list are removed; new users are inserted; + existing users whose role or is_primary changed are updated. + Users already claimed by another manager are silently skipped. + Returns the full current set of {manager.name}-managed memberships for + this project. + """ + # Validate the project exists + project = await session.get(Project, project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + + # Reject duplicate user_ids in the request + desired = {entry.user_id: entry for entry in users} + if len(desired) != len(users): + raise HTTPException(status_code=422, detail="Duplicate user_id values in request") + + # Validate all user_ids exist before making any changes + if desired: + users_result = await session.execute( + select(User.id).where(User.id.in_(desired.keys())) + ) + found_ids = set(users_result.scalars()) + missing = set(desired.keys()) - found_ids + if missing: + raise HTTPException( + status_code=404, + detail=f"User(s) not found: {sorted(missing)}", + ) + + # Fetch existing manager-owned rows for this project + existing_result = await session.execute( + select(UserProject).where( + UserProject.project_id == project_id, + UserProject.managed_by == manager, + ) + ) + existing = {row.user_id: row for row in existing_result.scalars().all()} + + to_remove = set(existing.keys()) - set(desired.keys()) + to_add = set(desired.keys()) - set(existing.keys()) + to_check = set(existing.keys()) & set(desired.keys()) + + # Silently skip users already claimed by another manager + if to_add: + claimed_result = await session.execute( + select(UserProject.user_id).where( + UserProject.project_id == project_id, + UserProject.user_id.in_(to_add), + UserProject.managed_by != manager, + ) + ) + to_add -= set(claimed_result.scalars()) + + # Remove members no longer in the list + if to_remove: + await session.execute( + delete(UserProject).where( + UserProject.project_id == project_id, + UserProject.user_id.in_(to_remove), + UserProject.managed_by == manager, + ) + ) + + # Add new members + for user_id in to_add: + entry = desired[user_id] + session.add( + UserProject( + project_id=project_id, + user_id=user_id, + role=entry.role, + is_primary=entry.is_primary, + managed_by=manager, + ) + ) + + # Update members whose role or is_primary changed + for user_id in to_check: + row = existing[user_id] + entry = desired[user_id] + if row.role != entry.role or row.is_primary != entry.is_primary: + row.role = entry.role + row.is_primary = entry.is_primary + + # Flush so the view reflects the changes + await session.flush() + + result = await session.execute( + select(JoinedProjectViewTable).where( + JoinedProjectViewTable.project_id == project_id, + JoinedProjectViewTable.managed_by == manager, + ) + ) + return result.scalars().all() + + # ------------------------------------------------------------------ # + # Groups # + # ------------------------------------------------------------------ # + + @with_db_error_handling + @router.put("/groups/{group_id}/users", response_model=List[UserGroupView]) + async def sync_group_users( + group_id: int, + users: List[ManagedUserGroupPut], + session=Depends(session_generator), + ) -> List[UserGroupView]: + f"""Replace the set of {manager.name}-managed members of a group. + + Rows owned by other managers are not touched. + Users no longer in the list are removed; new users are inserted. + Users already claimed by another manager are silently skipped. + Returns the full current set of {manager.name}-managed memberships for + this group. + """ + # Validate the group exists + group = await session.get(Group, group_id) + if group is None: + raise HTTPException(status_code=404, detail=f"Group {group_id} not found") + + # Reject duplicate user_ids in the request + desired_list = [entry.user_id for entry in users] + desired = set(desired_list) + if len(desired) != len(desired_list): + raise HTTPException(status_code=422, detail="Duplicate user_id values in request") + + # Validate all user_ids exist before making any changes + if desired: + users_result = await session.execute( + select(User.id).where(User.id.in_(desired)) + ) + found_ids = set(users_result.scalars()) + missing = desired - found_ids + if missing: + raise HTTPException( + status_code=404, + detail=f"User(s) not found: {sorted(missing)}", + ) + + # Fetch existing manager-owned rows for this group + existing_result = await session.execute( + select(UserGroup.user_id).where( + UserGroup.group_id == group_id, + UserGroup.managed_by == manager, + ) + ) + existing = set(existing_result.scalars()) + + to_remove = existing - desired + to_add = desired - existing + + # Silently skip users already claimed by another manager + if to_add: + claimed_result = await session.execute( + select(UserGroup.user_id).where( + UserGroup.group_id == group_id, + UserGroup.user_id.in_(to_add), + UserGroup.managed_by != manager, + ) + ) + to_add -= set(claimed_result.scalars()) + + # Remove members no longer in the list + if to_remove: + await session.execute( + delete(UserGroup).where( + UserGroup.group_id == group_id, + UserGroup.user_id.in_(to_remove), + UserGroup.managed_by == manager, + ) + ) + + # Add new members + for user_id in to_add: + session.add( + UserGroup( + group_id=group_id, + user_id=user_id, + managed_by=manager, + ) + ) + + # Flush so the view reflects the changes + await session.flush() + + result = await session.execute( + select(UserGroupViewTable).where( + UserGroupViewTable.group_id == group_id, + UserGroupViewTable.managed_by == manager, + ) + ) + return result.scalars().all() + + return router + diff --git a/userapp/api/routes/managed/manifest.py b/userapp/api/routes/managed/manifest.py new file mode 100644 index 0000000..0692d8f --- /dev/null +++ b/userapp/api/routes/managed/manifest.py @@ -0,0 +1,8 @@ +from userapp.core.models.enum import EntityManagerEnum +from userapp.api.routes.managed._factory import create_managed_router + +router = create_managed_router( + prefix="/manifest", + tags=["Manifest Sync"], + manager=EntityManagerEnum.MANIFEST, +) diff --git a/userapp/api/routes/managed/morgridge_ad.py b/userapp/api/routes/managed/morgridge_ad.py new file mode 100644 index 0000000..1024e99 --- /dev/null +++ b/userapp/api/routes/managed/morgridge_ad.py @@ -0,0 +1,8 @@ +from userapp.core.models.enum import EntityManagerEnum +from userapp.api.routes.managed._factory import create_managed_router + +router = create_managed_router( + prefix="/morgridge-ad", + tags=["Morgridge AD Sync"], + manager=EntityManagerEnum.MORGRIDGE_AD, +) diff --git a/userapp/api/tests/test_managed.py b/userapp/api/tests/test_managed.py new file mode 100644 index 0000000..9183f63 --- /dev/null +++ b/userapp/api/tests/test_managed.py @@ -0,0 +1,383 @@ +""" +Tests for managed-sync endpoints: + PUT /managed/manifest/projects/{id}/users + PUT /managed/morgridge-ad/projects/{id}/users + PUT /managed/manifest/groups/{id}/users + PUT /managed/morgridge-ad/groups/{id}/users + +Both managers share identical business logic so each test class is +parametrized over the two managers to avoid duplicate code. +""" +import pytest + +from userapp.core.models.enum import EntityManagerEnum, RoleEnum + +# --------------------------------------------------------------------------- +# Parametrize markers +# --------------------------------------------------------------------------- + +MANAGERS = [ + pytest.param("manifest", EntityManagerEnum.MANIFEST, id="manifest"), + pytest.param("morgridge-ad", EntityManagerEnum.MORGRIDGE_AD, id="morgridge_ad"), +] + + +# --------------------------------------------------------------------------- +# URL helpers +# --------------------------------------------------------------------------- + +def project_url(prefix: str, project_id: int) -> str: + return f"/managed/{prefix}/projects/{project_id}/users" + + +def group_url(prefix: str, group_id: int) -> str: + return f"/managed/{prefix}/groups/{group_id}/users" + + +# --------------------------------------------------------------------------- +# Project sync tests +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("manager_prefix,manager_enum", MANAGERS) +class TestManagedProjectSync: + """Tests for PUT /managed/{manager}/projects/{id}/users.""" + + def test_requires_admin(self, nonadmin_client, project, manager_prefix, manager_enum): + """Non-admin requests are rejected with 403.""" + response = nonadmin_client.put(project_url(manager_prefix, project["id"]), json=[]) + assert response.status_code == 403 + + def test_nonexistent_project_returns_404(self, existing_admin_client, manager_prefix, manager_enum): + """Returns 404 when the project does not exist.""" + response = existing_admin_client.put(project_url(manager_prefix, 999_999_999), json=[]) + assert response.status_code == 404 + + def test_nonexistent_user_returns_404(self, existing_admin_client, project, manager_prefix, manager_enum): + """Returns 404 when a user_id in the body does not exist.""" + response = existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": 999_999_999, "is_primary": False}], + ) + assert response.status_code == 404 + + def test_duplicate_user_id_returns_422(self, existing_admin_client, project, manager_prefix, manager_enum): + """Returns 422 when the same user_id appears more than once in the request. + + Duplicate detection runs before user-existence checks, so a non-existent + id is sufficient here. + """ + response = existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[ + {"user_id": 1, "is_primary": False}, + {"user_id": 1, "is_primary": True}, + ], + ) + assert response.status_code == 422 + + def test_empty_list_on_clean_project_returns_empty( + self, existing_admin_client, project, manager_prefix, manager_enum + ): + """PUT [] on a project that has no managed rows returns 200 with an empty list.""" + response = existing_admin_client.put(project_url(manager_prefix, project["id"]), json=[]) + assert response.status_code == 200 + assert response.json() == [] + + def test_sync_adds_users( + self, existing_admin_client, project, project_factory, user_factory, manager_prefix, manager_enum + ): + """Users in the request body that are not already in the project get added.""" + # Create user whose primary project is a *different* project so they + # are not already a member of the test project. + side_project = project_factory() + user = user_factory(0, side_project["id"]) + try: + response = existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user["id"], "is_primary": False}], + ) + assert response.status_code == 200, response.text + data = response.json() + matched = [r for r in data if r["id"] == user["id"]] + assert len(matched) == 1, "Synced user should appear in the response" + assert matched[0]["project_id"] == project["id"] + assert matched[0]["managed_by"] == manager_enum.value + finally: + existing_admin_client.delete(f"/users/{user['id']}") + existing_admin_client.delete(f"/projects/{side_project['id']}") + + def test_sync_removes_users_absent_from_new_list( + self, existing_admin_client, project, project_factory, user_factory, manager_prefix, manager_enum + ): + """Users that were previously managed but are absent from the new PUT body are removed.""" + side_project = project_factory() + user = user_factory(0, side_project["id"]) + try: + # First sync: add the user + existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user["id"], "is_primary": False}], + ) + # Second sync: empty list → user should be removed + response = existing_admin_client.put(project_url(manager_prefix, project["id"]), json=[]) + assert response.status_code == 200, response.text + assert response.json() == [] + finally: + existing_admin_client.delete(f"/users/{user['id']}") + existing_admin_client.delete(f"/projects/{side_project['id']}") + + def test_sync_updates_role_and_is_primary( + self, existing_admin_client, project, project_factory, user_factory, manager_prefix, manager_enum + ): + """An existing managed membership is updated when role or is_primary changes.""" + side_project = project_factory() + user = user_factory(0, side_project["id"]) + try: + # Add as MEMBER / not primary + existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user["id"], "role": RoleEnum.MEMBER.value, "is_primary": False}], + ) + # Update to PI / primary + response = existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user["id"], "role": RoleEnum.PI.value, "is_primary": True}], + ) + assert response.status_code == 200, response.text + matched = [r for r in response.json() if r["id"] == user["id"]] + assert len(matched) == 1 + assert matched[0]["role"] == RoleEnum.PI.value + assert matched[0]["is_primary"] is True + finally: + existing_admin_client.delete(f"/users/{user['id']}") + existing_admin_client.delete(f"/projects/{side_project['id']}") + + def test_application_claimed_user_is_silently_skipped( + self, existing_admin_client, project, user_factory, manager_prefix, manager_enum + ): + """A user already managed by APPLICATION in the same project is silently skipped. + + user_factory creates users with their primary project set, which results + in an APPLICATION-managed UserProject row for that project. + """ + user = user_factory(0, project["id"]) + try: + response = existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user["id"], "is_primary": False}], + ) + assert response.status_code == 200, response.text + matched = [r for r in response.json() if r["id"] == user["id"]] + assert len(matched) == 0, "APPLICATION-managed user should be silently skipped" + finally: + existing_admin_client.delete(f"/users/{user['id']}") + + def test_does_not_remove_application_managed_members( + self, existing_admin_client, project, project_factory, user_factory, manager_prefix, manager_enum + ): + """APPLICATION-managed memberships are untouched even when the managed sync clears its own rows.""" + # user_app is in the project with APPLICATION managed_by (via primary project assignment) + user_app = user_factory(0, project["id"]) + # user_managed is in a different primary project so they are not yet in the test project + side_project = project_factory() + user_managed = user_factory(1, side_project["id"]) + try: + # Add user_managed via managed sync + existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user_managed["id"], "is_primary": False}], + ) + # Sync with empty list – removes user_managed but must NOT touch user_app + existing_admin_client.put(project_url(manager_prefix, project["id"]), json=[]) + + members_resp = existing_admin_client.get(f"/projects/{project['id']}/users") + assert members_resp.status_code == 200 + member_ids = [m["id"] for m in members_resp.json()] + assert user_app["id"] in member_ids, "APPLICATION-managed member should not be removed" + finally: + existing_admin_client.delete(f"/users/{user_app['id']}") + existing_admin_client.delete(f"/users/{user_managed['id']}") + existing_admin_client.delete(f"/projects/{side_project['id']}") + + def test_response_only_includes_rows_for_this_manager( + self, existing_admin_client, project, project_factory, user_factory, manager_prefix, manager_enum + ): + """The PUT response only includes rows owned by the called manager, not APPLICATION rows.""" + user_app = user_factory(0, project["id"]) # APPLICATION + side_project = project_factory() + user_managed = user_factory(1, side_project["id"]) # will be added as managed + try: + response = existing_admin_client.put( + project_url(manager_prefix, project["id"]), + json=[{"user_id": user_managed["id"], "is_primary": False}], + ) + assert response.status_code == 200, response.text + returned_ids = {r["id"] for r in response.json()} + assert user_app["id"] not in returned_ids, "APPLICATION member should not appear in response" + assert user_managed["id"] in returned_ids, "Managed member should appear in response" + finally: + existing_admin_client.delete(f"/users/{user_app['id']}") + existing_admin_client.delete(f"/users/{user_managed['id']}") + existing_admin_client.delete(f"/projects/{side_project['id']}") + + +# --------------------------------------------------------------------------- +# Group sync tests +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("manager_prefix,manager_enum", MANAGERS) +class TestManagedGroupSync: + """Tests for PUT /managed/{manager}/groups/{id}/users.""" + + def test_requires_admin(self, nonadmin_client, group, manager_prefix, manager_enum): + """Non-admin requests are rejected with 403.""" + response = nonadmin_client.put(group_url(manager_prefix, group["id"]), json=[]) + assert response.status_code == 403 + + def test_nonexistent_group_returns_404(self, existing_admin_client, manager_prefix, manager_enum): + """Returns 404 when the group does not exist.""" + response = existing_admin_client.put(group_url(manager_prefix, 999_999_999), json=[]) + assert response.status_code == 404 + + def test_nonexistent_user_returns_404(self, existing_admin_client, group, manager_prefix, manager_enum): + """Returns 404 when a user_id in the body does not exist.""" + response = existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": 999_999_999}], + ) + assert response.status_code == 404 + + def test_duplicate_user_id_returns_422(self, existing_admin_client, group, manager_prefix, manager_enum): + """Returns 422 when the same user_id appears more than once in the request. + + Duplicate detection runs before user-existence checks, so a non-existent + id is sufficient here. + """ + response = existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": 1}, {"user_id": 1}], + ) + assert response.status_code == 422 + + def test_empty_list_on_clean_group_returns_empty( + self, existing_admin_client, group, manager_prefix, manager_enum + ): + """PUT [] on a group that has no managed rows returns 200 with an empty list.""" + response = existing_admin_client.put(group_url(manager_prefix, group["id"]), json=[]) + assert response.status_code == 200 + assert response.json() == [] + + def test_sync_adds_users( + self, existing_admin_client, group, project, user_factory, manager_prefix, manager_enum + ): + """Users in the request body that are not already in the group get added. + + user_factory does NOT add users to groups, so the created user is + guaranteed to be free to be claimed by the managed sync. + """ + user = user_factory(0, project["id"]) + try: + response = existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": user["id"]}], + ) + assert response.status_code == 200, response.text + data = response.json() + matched = [r for r in data if r["user_id"] == user["id"]] + assert len(matched) == 1, "Synced user should appear in the response" + assert matched[0]["group_id"] == group["id"] + assert matched[0]["managed_by"] == manager_enum.value + finally: + existing_admin_client.delete(f"/users/{user['id']}") + + def test_sync_removes_users_absent_from_new_list( + self, existing_admin_client, group, project, user_factory, manager_prefix, manager_enum + ): + """Users that were previously managed but are absent from the new PUT body are removed.""" + user = user_factory(0, project["id"]) + try: + # First sync: add the user + existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": user["id"]}], + ) + # Second sync: empty list → user should be removed + response = existing_admin_client.put(group_url(manager_prefix, group["id"]), json=[]) + assert response.status_code == 200, response.text + assert response.json() == [] + finally: + existing_admin_client.delete(f"/users/{user['id']}") + + def test_application_claimed_user_is_silently_skipped( + self, existing_admin_client, group, project, user_factory, manager_prefix, manager_enum + ): + """A user already managed by APPLICATION in the same group is silently skipped.""" + user = user_factory(0, project["id"]) + # Explicitly add user via the APPLICATION route first + add_resp = existing_admin_client.post( + f"/groups/{group['id']}/users", json={"user_id": user["id"]} + ) + assert add_resp.status_code == 201, add_resp.text + try: + response = existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": user["id"]}], + ) + assert response.status_code == 200, response.text + matched = [r for r in response.json() if r["user_id"] == user["id"]] + assert len(matched) == 0, "APPLICATION-managed user should be silently skipped" + finally: + existing_admin_client.delete(f"/users/{user['id']}") + + def test_does_not_remove_application_managed_members( + self, existing_admin_client, group, project, user_factory, manager_prefix, manager_enum + ): + """APPLICATION-managed group memberships are untouched when the managed sync clears its own rows.""" + user_app = user_factory(0, project["id"]) + user_managed = user_factory(1, project["id"]) + # Add user_app to the group via the APPLICATION route + add_resp = existing_admin_client.post( + f"/groups/{group['id']}/users", json={"user_id": user_app["id"]} + ) + assert add_resp.status_code == 201, add_resp.text + try: + # Add user_managed via managed sync + existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": user_managed["id"]}], + ) + # Sync with empty list – removes user_managed but must NOT touch user_app + existing_admin_client.put(group_url(manager_prefix, group["id"]), json=[]) + + members_resp = existing_admin_client.get(f"/groups/{group['id']}/users") + assert members_resp.status_code == 200 + member_ids = [m["user_id"] for m in members_resp.json()] + assert user_app["id"] in member_ids, "APPLICATION-managed member should not be removed" + finally: + existing_admin_client.delete(f"/users/{user_app['id']}") + existing_admin_client.delete(f"/users/{user_managed['id']}") + + def test_response_only_includes_rows_for_this_manager( + self, existing_admin_client, group, project, user_factory, manager_prefix, manager_enum + ): + """The PUT response only includes rows owned by the called manager, not APPLICATION rows.""" + user_app = user_factory(0, project["id"]) + user_managed = user_factory(1, project["id"]) + # Add user_app via APPLICATION route + add_resp = existing_admin_client.post( + f"/groups/{group['id']}/users", json={"user_id": user_app["id"]} + ) + assert add_resp.status_code == 201, add_resp.text + try: + response = existing_admin_client.put( + group_url(manager_prefix, group["id"]), + json=[{"user_id": user_managed["id"]}], + ) + assert response.status_code == 200, response.text + returned_ids = {r["user_id"] for r in response.json()} + assert user_app["id"] not in returned_ids, "APPLICATION member should not appear in response" + assert user_managed["id"] in returned_ids, "Managed member should appear in response" + finally: + existing_admin_client.delete(f"/users/{user_app['id']}") + existing_admin_client.delete(f"/users/{user_managed['id']}") + diff --git a/userapp/core/models/views.py b/userapp/core/models/views.py index 891ef79..c835864 100644 --- a/userapp/core/models/views.py +++ b/userapp/core/models/views.py @@ -136,8 +136,9 @@ class GroupUserView(Base): __table_args__ = {'info': dict(is_view=True)} # Columns from UserGroup Table - group_id = Column(Integer, ForeignKey("groups.id"), primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + # No ForeignKey declarations — this is a view; FK constraints don't exist on it. + group_id = Column(Integer, primary_key=True) + user_id = Column(Integer, primary_key=True) managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum")) created_at = Column(TIMESTAMP) updated_at = Column(TIMESTAMP) @@ -157,8 +158,9 @@ class UserGroupView(Base): __table_args__ = {'info': dict(is_view=True)} # Columns from UserGroup Table - group_id = Column(Integer, ForeignKey("groups.id"), primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + # No ForeignKey declarations — this is a view; FK constraints don't exist on it. + group_id = Column(Integer, primary_key=True) + user_id = Column(Integer, primary_key=True) managed_by = Column(SQLEnum(EntityManagerEnum, name="entity_manager_enum")) created_at = Column(TIMESTAMP) updated_at = Column(TIMESTAMP) diff --git a/userapp/core/schemas/user_group.py b/userapp/core/schemas/user_group.py index 2df2751..b03965c 100644 --- a/userapp/core/schemas/user_group.py +++ b/userapp/core/schemas/user_group.py @@ -46,3 +46,8 @@ class UserGroupPost(BaseModel): def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: return managed_by.name if managed_by is not None else None + +class ManagedUserGroupPut(BaseModel): + """Used by a managed endpoint which has a url explicit managed_by and group_id""" + + user_id: int diff --git a/userapp/core/schemas/user_project.py b/userapp/core/schemas/user_project.py index 55fff13..43a4b7c 100644 --- a/userapp/core/schemas/user_project.py +++ b/userapp/core/schemas/user_project.py @@ -60,3 +60,11 @@ def serialize_role(self, role: RoleEnum) -> str: @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: return managed_by.name if managed_by is not None else None + + +class ManagedUserProjectPut(BaseModel): + """Used by a managed endpoint which has a url explicit managed_by""" + + user_id: int + role: Optional[RoleEnum] = None + is_primary: bool = False From 6bec26f11edab0e69d50aa7e530b1f0195e6ebe1 Mon Sep 17 00:00:00 2001 From: Cannon Lock <49032265+CannonLock@users.noreply.github.com> Date: Thu, 21 May 2026 11:16:51 -0500 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- userapp/core/models/tables.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/userapp/core/models/tables.py b/userapp/core/models/tables.py index 99dcb0a..2f77842 100644 --- a/userapp/core/models/tables.py +++ b/userapp/core/models/tables.py @@ -139,9 +139,9 @@ class User(Base): groups: Mapped[List["UserGroupView"]] = relationship( "UserGroupView", - primaryjoin="User.id==UserGroupView.user_id", - foreign_keys="[UserGroupView.user_id]", - lazy="selectin" + primaryjoin="User.id==foreign(UserGroupView.user_id)", + lazy="selectin", + viewonly=True, ) user_forms: Mapped[List[UserApplicationView]] = relationship( From 53f9d29161c8338906e5363f3a06e1f430ca7670 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 16:23:23 +0000 Subject: [PATCH 4/5] Fix user project managed_by defaults --- userapp/core/schemas/user_project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userapp/core/schemas/user_project.py b/userapp/core/schemas/user_project.py index 43a4b7c..f654570 100644 --- a/userapp/core/schemas/user_project.py +++ b/userapp/core/schemas/user_project.py @@ -16,7 +16,7 @@ class UserProjectTableSchema(BaseModel): user_id: int role: Optional[RoleEnum] = Field(default=None) is_primary: bool - managed_by: Optional[EntityManagerEnum] = Field(default=None) + managed_by: EntityManagerEnum = Field(default=EntityManagerEnum.APPLICATION) updated_at: Optional[datetime] = Field(default=None) created_at: Optional[datetime] = Field(default=None) @@ -51,7 +51,7 @@ class UserProjectPost(BaseModel): user_id: int role: Optional[RoleEnum] = None is_primary: bool = False - managed_by: Optional[EntityManagerEnum] = None + managed_by: EntityManagerEnum = EntityManagerEnum.APPLICATION @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: From 7fee2a2689b0eb9d04b38318b05dde6d37c2a5a3 Mon Sep 17 00:00:00 2001 From: clock Date: Thu, 21 May 2026 12:01:58 -0500 Subject: [PATCH 5/5] Update enum and fix group listing test --- userapp/api/tests/test_users.py | 31 +++------------------------- userapp/core/schemas/general.py | 15 +++++++++++++- userapp/core/schemas/user_group.py | 8 +++---- userapp/core/schemas/user_project.py | 12 +++++------ userapp/core/schemas/users.py | 8 +++---- 5 files changed, 31 insertions(+), 43 deletions(-) diff --git a/userapp/api/tests/test_users.py b/userapp/api/tests/test_users.py index f50b1e6..4745ca1 100644 --- a/userapp/api/tests/test_users.py +++ b/userapp/api/tests/test_users.py @@ -265,14 +265,15 @@ def test_get_user_groups_with_groups(self, admin_client: Client, user_factory, p # Manually add user to groups in the database for testing group_ids = [1, 2] for group_id in group_ids: - admin_client.post(f"/groups/{group_id}/users", json={"id": user['id']}) + r = admin_client.post(f"/groups/{group_id}/users", json={"user_id": user['id']}) + assert r.status_code == 201, f"Adding user to group {group_id} should return a 201 status code, instead got {r.text}" response = admin_client.get(f"/users/{user['id']}/groups") assert response.status_code == 200, f"Getting user groups should return a 200 status code, instead got {response.text}" groups = response.json() assert len(groups) == len(group_ids), f"User should belong to {len(group_ids)} groups" - assert all(group['id'] in group_ids for group in groups), "User's groups should match the added groups" + assert all(group['group_id'] in group_ids for group in groups), "User's groups should match the added groups" def test_get_user_by_netid(self, admin_client: Client, user_factory, project_factory): """Test getting a user by netid""" @@ -343,19 +344,6 @@ def expected_auth_username(user_data: dict) -> bool: class TestUserAuthBehavior: - def test_schema_computed_fields_follow_username_and_netid(self): - netid_user = UserGet(**schema_user_data(active=True, username="testnetid", netid="testnetid")) - username_user = UserGet(**schema_user_data(active=True, username="unixuser", netid="testnetid")) - inactive_project_view = JoinedProjectView( - **schema_project_view_data(active=False, username="unixuser", netid="testnetid", role=RoleEnum.MEMBER) - ) - - assert netid_user.auth_netid is True - assert netid_user.auth_username is False - assert username_user.auth_netid is False - assert username_user.auth_username is True - assert inactive_project_view.model_dump()["auth_netid"] is False - assert inactive_project_view.model_dump()["auth_username"] is False def test_userpost_requires_netid_when_active(self): with pytest.raises(ValidationError) as exc_info: @@ -363,19 +351,6 @@ def test_userpost_requires_netid_when_active(self): assert "netid must be provided" in str(exc_info.value).lower() - def test_create_active_user_defaults_username_to_netid(self, admin_client: Client, project_factory): - project = project_factory() - user_payload = user_data_f(101, project["id"]) - user_payload.pop("username") - - create_response = admin_client.post("/users", json=user_payload) - assert create_response.status_code == 201, create_response.text - created_user = create_response.json() - - assert created_user["username"] == created_user["netid"] - assert created_user["auth_netid"] is True - assert created_user["auth_username"] is False - def test_patch_user_can_set_distinct_username(self, admin_client: Client, project_factory): project = project_factory() user_payload = user_data_f(102, project["id"]) diff --git a/userapp/core/schemas/general.py b/userapp/core/schemas/general.py index 225b4d4..cc49da9 100644 --- a/userapp/core/schemas/general.py +++ b/userapp/core/schemas/general.py @@ -1,6 +1,6 @@ from datetime import datetime from typing import Optional, TYPE_CHECKING -from pydantic import BaseModel as PydanticBaseModel, ConfigDict, model_validator, Field, EmailStr, computed_field +from pydantic import BaseModel as PydanticBaseModel, ConfigDict, model_validator, Field, EmailStr, computed_field, field_serializer from userapp.core.models.enum import RoleEnum, PositionEnum, FormStatusEnum, FormTypeEnum, EntityManagerEnum @@ -42,6 +42,11 @@ class JoinedProjectView(BaseModel): managed_by: EntityManagerEnum created_at: datetime updated_at: datetime + + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: + return managed_by.value if managed_by is not None else None + is_primary: Optional[bool] = Field(default=None) role: Optional[RoleEnum] = Field(default=None) @@ -120,6 +125,10 @@ class GroupUserView(BaseModel): created_at: Optional[datetime] = Field(default=None) updated_at: Optional[datetime] = Field(default=None) + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> Optional[str]: + return managed_by.value if managed_by is not None else None + # From User table name: str netid: Optional[str] = Field(default=None) @@ -138,6 +147,10 @@ class UserGroupView(BaseModel): created_at: Optional[datetime] = Field(default=None) updated_at: Optional[datetime] = Field(default=None) + @field_serializer('managed_by') + def serialize_managed_by(self, managed_by: EntityManagerEnum) -> Optional[str]: + return managed_by.value if managed_by is not None else None + # From the Group table name: str point_of_contact: Optional["UserGet"] = Field(default=None, validation_alias='point_of_contact_user') diff --git a/userapp/core/schemas/user_group.py b/userapp/core/schemas/user_group.py index b03965c..404b9dc 100644 --- a/userapp/core/schemas/user_group.py +++ b/userapp/core/schemas/user_group.py @@ -15,7 +15,7 @@ class UserGroupBase(BaseModel): @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class UserGroupGet(BaseModel): @@ -27,7 +27,7 @@ class UserGroupGet(BaseModel): @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class UserGroupPatch(BaseModel): @@ -35,7 +35,7 @@ class UserGroupPatch(BaseModel): @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class UserGroupPost(BaseModel): @@ -44,7 +44,7 @@ class UserGroupPost(BaseModel): @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class ManagedUserGroupPut(BaseModel): diff --git a/userapp/core/schemas/user_project.py b/userapp/core/schemas/user_project.py index f654570..75c3cc4 100644 --- a/userapp/core/schemas/user_project.py +++ b/userapp/core/schemas/user_project.py @@ -22,11 +22,11 @@ class UserProjectTableSchema(BaseModel): @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: - return role.name if role is not None else None + return role.value if role is not None else None @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class UserProjectGet(UserProjectTableSchema): """Exact same as UserProjectTableSchema for now, but kept separate for future changes""" @@ -40,11 +40,11 @@ class UserProjectPatch(BaseModel): @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: - return role.name if role is not None else None + return role.value if role is not None else None @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class UserProjectPost(BaseModel): @@ -55,11 +55,11 @@ class UserProjectPost(BaseModel): @field_serializer('role') def serialize_role(self, role: RoleEnum) -> str: - return role.name if role is not None else None + return role.value if role is not None else None @field_serializer('managed_by') def serialize_managed_by(self, managed_by: EntityManagerEnum) -> str: - return managed_by.name if managed_by is not None else None + return managed_by.value if managed_by is not None else None class ManagedUserProjectPut(BaseModel): diff --git a/userapp/core/schemas/users.py b/userapp/core/schemas/users.py index 5c31804..e578d79 100644 --- a/userapp/core/schemas/users.py +++ b/userapp/core/schemas/users.py @@ -33,7 +33,7 @@ class UserTableSchema(BaseModel): @field_serializer('position') def serialize_position(self, position: PositionEnum) -> str | None: - return position.name if position is not None else None + return position.value if position is not None else None class UserGet(BaseModel): @@ -56,7 +56,7 @@ class UserGet(BaseModel): @field_serializer('position') def serialize_position(self, position: PositionEnum) -> str: - return position.name if position is not None else None + return position.value if position is not None else None @computed_field @property @@ -112,7 +112,7 @@ class UserPost(BaseModel): @field_serializer('position') def serialize_position(self, position: PositionEnum) -> str: - return position.name if position is not None else None + return position.value if position is not None else None @model_validator(mode="after") def check_active_requires_netid(self): @@ -147,7 +147,7 @@ class UserPatch(BaseModel): @field_serializer('position') def serialize_position(self, position: PositionEnum) -> str: - return position.name if position is not None else None + return position.value if position is not None else None class UserPatchFull(UserPatch):