diff --git a/alembic/env.py b/alembic/env.py index c59beac..4b9f8ea 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -27,10 +27,10 @@ # Import your Base and all models to ensure they're registered from userapp.core.models.main import Base from userapp.core.models.tables import ( - Group, Note, Project, SubmitNode, User, UserGroup, - UserNote, UserProject, UserSubmit, Token, Access + Group, Note, Project, User, UserGroup, + UserNote, UserProject, Token, Access ) -from userapp.core.models.views import JoinedProjectView, UserSubmitNodesView # Import views if needed +from userapp.core.models.views import JoinedProjectView # Import views if needed target_metadata = Base.metadata diff --git a/alembic/versions/026216ef4d8a_add_type_enum_and_label_string_to_groups.py b/alembic/versions/026216ef4d8a_add_type_enum_and_label_string_to_groups.py new file mode 100644 index 0000000..93ba56d --- /dev/null +++ b/alembic/versions/026216ef4d8a_add_type_enum_and_label_string_to_groups.py @@ -0,0 +1,209 @@ +"""add group type/description and submit node groups + +Revision ID: 026216ef4d8a +Revises: f2ec55925c4c +Create Date: 2026-05-29 + +""" +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 = '026216ef4d8a' +down_revision: Union[str, Sequence[str], None] = 'f2ec55925c4c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +GROUPS_DATA = [ + {'node_name': 'wright-ap4000.chtc.wisc.edu', 'group_name': 'wright-ap-login', 'description': 'Access to wright-ap4000.chtc.wisc.edu'}, + {'node_name': 'townsend-submit.chtc.wisc.edu', 'group_name': 'townsend-ap-login', 'description': 'Access to townsend-submit.chtc.wisc.edu'}, + {'node_name': 'hpclogin1.chtc.wisc.edu', 'group_name': 'spark-login', 'description': 'Access to hpclogin1.chtc.wisc.edu'}, + {'node_name': 'osg-sw-submit.chtc.wisc.edu', 'group_name': 'osgsw-ap-login', 'description': 'Access to osg-sw-submit.chtc.wisc.edu'}, + {'node_name': 'oconnor-ap.chtc.wisc.edu', 'group_name': 'oconnor-ap-login', 'description': 'Access to oconnor-ap.chtc.wisc.edu'}, + {'node_name': 'learn.chtc.wisc.edu', 'group_name': 'learn-ap-login', 'description': 'Access to learn.chtc.wisc.edu'}, + {'node_name': 'htc_transfer', 'group_name': 'htc-transfer-login', 'description': 'Access to htc_transfer'}, + {'node_name': 'ap2002.chtc.wisc.edu', 'group_name': 'ap2002-login', 'description': 'Access to ap2002.chtc.wisc.edu'}, + {'node_name': 'ap2001.chtc.wisc.edu', 'group_name': 'ap2001-login', 'description': 'Access to ap2001.chtc.wisc.edu'}, +] + +group_type_enum = postgresql.ENUM( + 'SUBMIT_NODE', + name='group_type_enum', + create_type=False, +) + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + group_type_enum.create(bind, checkfirst=True) + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('groups', sa.Column('description', sa.String(length=255), nullable=True)) + op.add_column('groups', sa.Column('type', group_type_enum, nullable=True)) + # ### end Alembic commands ### + + # ### commands generated by human - please check for typos! ### + values_list_string = ",\n".join( + f"('{g['group_name']}', '{g['description']}', 'SUBMIT_NODE'::group_type_enum, FALSE)" + for g in GROUPS_DATA + ) + + op.execute(f""" + INSERT INTO groups (name, description, type, has_groupdir) + VALUES + {values_list_string} + ON CONFLICT (name) DO NOTHING + """) + + mapping_list_string = ",\n".join( + f"('{g['node_name']}', '{g['group_name']}')" + for g in GROUPS_DATA + ) + + op.execute(f""" + INSERT INTO user_groups (user_id, group_id, managed_by) + SELECT DISTINCT + us.user_id, + g.id, + 'APPLICATION'::entity_manager_enum + FROM user_submits us + INNER JOIN submit_nodes sn ON sn.id = us.submit_node_id + INNER JOIN ( + VALUES + {mapping_list_string} + ) AS mapping(node_name, group_name) ON sn.name = mapping.node_name + INNER JOIN groups g ON g.name = mapping.group_name + ON CONFLICT ON CONSTRAINT user_groups_distinct DO NOTHING + """) + + op.execute("DROP VIEW IF EXISTS user_group_memberships") + 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.description, + g.point_of_contact, + g.unix_gid, + g.has_groupdir, + g.type + FROM user_groups ug + JOIN groups g ON ug.group_id = g.id + """) + + # Drop the now-replaced submit node objects. Submit node access is represented as + # membership in a SUBMIT_NODE-typed group (backfilled above), so these are obsolete. + # Order matters: dependent view first, then child table, then parent table. + op.execute("DROP VIEW IF EXISTS user_submit_nodes") + op.execute("DROP TABLE IF EXISTS user_submits") + op.execute("DROP TABLE IF EXISTS submit_nodes") + # ### end commands generated by human ### + + +def downgrade() -> None: + """Downgrade schema.""" + + # ### commands generated by human - please check for typos! ### + + # Recreate the submit node objects that upgrade() dropped, restoring the pre-migration + # schema (reverse order of the drops: parent table, child table, then the view). + # NOTE: this restores schema only, not the dropped data (it was migrated into groups). + # NOTE: the user_submit_nodes view body is reconstructed from the ORM model columns -- the + # original was created pre-Alembic and is in no migration. Confirm against + # `SELECT pg_get_viewdef('user_submit_nodes'::regclass, true);` if exact fidelity is needed. + op.execute(""" + CREATE TABLE IF NOT EXISTS submit_nodes ( + id SERIAL PRIMARY KEY, + name VARCHAR(60) + ) + """) + + op.execute(""" + CREATE TABLE IF NOT EXISTS user_submits ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + submit_node_id INTEGER NOT NULL REFERENCES submit_nodes(id) ON DELETE CASCADE, + for_auth_netid BOOLEAN, + disk_quota INTEGER, + hpc_diskquota INTEGER NOT NULL DEFAULT 100, + hpc_inodequota INTEGER NOT NULL DEFAULT 50000, + hpc_joblimit INTEGER NOT NULL DEFAULT 10, + hpc_corelimit INTEGER NOT NULL DEFAULT 720, + hpc_fairshare INTEGER NOT NULL DEFAULT 100, + CONSTRAINT user_submits_distinct UNIQUE (user_id, submit_node_id, for_auth_netid) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_user_submits_submit_node_id ON user_submits (submit_node_id)") + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_user_submits_userid_submitnodeid_incl + ON user_submits (user_id, submit_node_id) + INCLUDE (disk_quota, hpc_diskquota, hpc_inodequota, hpc_joblimit, hpc_corelimit, hpc_fairshare) + """) + + op.execute(""" + CREATE OR REPLACE VIEW user_submit_nodes AS + SELECT + us.user_id, + us.submit_node_id, + sn.name AS submit_node_name, + us.disk_quota, + us.hpc_diskquota, + us.hpc_inodequota, + us.hpc_joblimit, + us.hpc_corelimit, + us.hpc_fairshare + FROM user_submits us + JOIN submit_nodes sn ON sn.id = us.submit_node_id + """) + + group_names_string = ', '.join(f"'{group['group_name']}'" for group in GROUPS_DATA) + + op.execute(f""" + DELETE FROM user_groups + WHERE group_id IN ( + SELECT id FROM groups + WHERE name IN ({group_names_string}) + AND type = 'SUBMIT_NODE'::group_type_enum + ) + """) + + op.execute(f""" + DELETE FROM groups + WHERE name IN ({group_names_string}) + AND type = 'SUBMIT_NODE'::group_type_enum + """) + + op.execute("DROP VIEW IF EXISTS user_group_memberships") + 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 + """) + # ### end commands generated by human ### + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('groups', 'type') + op.drop_column('groups', 'description') + # ### end Alembic commands ### + + bind = op.get_bind() + group_type_enum.drop(bind, checkfirst=True) \ No newline at end of file diff --git a/userapp/api/load_options.py b/userapp/api/load_options.py index 4be0147..8dc8ac3 100644 --- a/userapp/api/load_options.py +++ b/userapp/api/load_options.py @@ -9,5 +9,4 @@ selectinload(UserTable.projects).selectinload(JoinedProjectViewTable.staff1_user), selectinload(UserTable.projects).selectinload(JoinedProjectViewTable.staff2_user), selectinload(UserTable.user_forms), - selectinload(UserTable.submit_nodes) ] diff --git a/userapp/api/routes/__init__.py b/userapp/api/routes/__init__.py index c27c3bf..8c13673 100644 --- a/userapp/api/routes/__init__.py +++ b/userapp/api/routes/__init__.py @@ -4,7 +4,6 @@ from .pi_projects import router as pi_projects_router from .projects import router as projects_router from .security import router as security_router -from .submit_nodes import router as submit_nodes_router from .users import router as users_router from .tokens import router as tokens_router from .routes import router as routes_router @@ -17,7 +16,6 @@ pi_projects_router, projects_router, security_router, - submit_nodes_router, users_router, tokens_router ] diff --git a/userapp/api/routes/_util.py b/userapp/api/routes/_util.py index 0b6bf70..656dffd 100644 --- a/userapp/api/routes/_util.py +++ b/userapp/api/routes/_util.py @@ -3,40 +3,37 @@ from sqlalchemy.ext.asyncio import AsyncSession from userapp.api.util import create_one_endpoint -from userapp.core.models.tables import UserSubmit, User, UserProject, UserGroup +from userapp.core.models.enum import GroupTypeEnum +from userapp.core.models.tables import 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 +from userapp.core.schemas.user_group import UserGroupCreate # see below -async def _patch_user_submit_nodes(session: AsyncSession, user: User, new_submit_nodes: list[UserSubmitPost]): +async def _patch_user_submit_node_groups(session: AsyncSession, user: User, new_submit_node_group_ids: list[int]): """Updates the passed in user to match the provided list of submit_nodes""" - # Delete the submit nodes that are not in the list of new submit nodes - for existing_submit_node in user.submit_nodes: - if existing_submit_node.submit_node_id not in [sn.submit_node_id for sn in new_submit_nodes]: - delete_stmt = ( - UserSubmit.__table__.delete() - .where(UserSubmit.user_id == user.id) - .where(UserSubmit.submit_node_id == existing_submit_node.submit_node_id) - ) - await session.execute(delete_stmt) - - # Add the missing submit nodes - for submit_node in new_submit_nodes: - - if submit_node.submit_node_id in [sn.submit_node_id for sn in user.submit_nodes]: - continue # Already exists + existing_group_ids = [g.group_id for g in user.groups] - # Create nodes for both auth_netid True and False to simplify logic - for for_auth_netid in [True, False]: - user_submit_model = UserSubmitTableSchema( - user_id=user.id, - for_auth_netid=for_auth_netid, - **submit_node.model_dump(), + # Remove memberships not in the new list + for existing_group in user.groups: + if existing_group.type == GroupTypeEnum.SUBMIT_NODE and existing_group.group_id not in new_submit_node_group_ids: + await session.execute( + UserGroup.__table__.delete() + .where(UserGroup.user_id == user.id) + .where(UserGroup.group_id == existing_group.group_id) ) - await create_one_endpoint(session, UserSubmit, user_submit_model) + + # Add memberships that are missing + for group_id in new_submit_node_group_ids: + if group_id in existing_group_ids: + continue + await create_one_endpoint( + session, + UserGroup, + UserGroupCreate(user_id=user.id, group_id=group_id), + ) async def _patch_user_project( diff --git a/userapp/api/routes/forms/_util.py b/userapp/api/routes/forms/_util.py index cf162cc..3641220 100644 --- a/userapp/api/routes/forms/_util.py +++ b/userapp/api/routes/forms/_util.py @@ -7,7 +7,7 @@ from userapp.core.models.tables import UserForm as UserFormTable, UserGroup, UserProject from userapp.core.schemas.user_application_form import UserFormPatch from userapp.core.schemas.user_project import UserProjectTableSchema -from userapp.api.routes._util import _patch_user_submit_nodes +from userapp.api.routes._util import _patch_user_submit_node_groups CHTC_NO_REPLY_EMAIL = "no-reply@chtc.wisc.edu" CHTC_TICKETING_EMAIL = "chtc@cs.wisc.edu" @@ -80,10 +80,10 @@ async def on_user_form_accept(session: AsyncSession, form_id: int, form: UserFor user.position = form.user_position - if not (form.project_id and form.user_position and form.submit_nodes): + if not (form.project_id and form.user_position and form.submit_node_group_ids): raise HTTPException( status_code=400, - detail="project_id, user_position, and submit_nodes must be provided to accept a user form", + detail="project_id, user_position, and submit_node_group_ids must be provided to accept a user form", ) # Clear out projects, and groups @@ -101,7 +101,7 @@ async def on_user_form_accept(session: AsyncSession, form_id: int, form: UserFor ), ) # Update to the set of approved submit nodes - await _patch_user_submit_nodes(session, user, form.submit_nodes) + await _patch_user_submit_node_groups(session, user, form.submit_node_group_ids) # Try sending the user an email try: diff --git a/userapp/api/routes/forms/user_application.py b/userapp/api/routes/forms/user_application.py index 0987041..41aec87 100644 --- a/userapp/api/routes/forms/user_application.py +++ b/userapp/api/routes/forms/user_application.py @@ -8,13 +8,12 @@ from userapp.api.util import create_one_endpoint, list_endpoint, list_select_stmt, update_one_endpoint, get_one_endpoint from userapp.core.models.enum import FormStatusEnum, FormTypeEnum from userapp.core.models.tables import BaseForm as BaseFormTable, Project as ProjectTable, \ - SubmitNode as SubmitNodeTable, User as UserTable, UserForm as UserFormTable, UserProject, UserSubmit + User as UserTable, UserForm as UserFormTable, UserProject from userapp.core.models.views import UserApplicationView as UserApplicationViewTable from userapp.core.schemas.general import UserApplicationViewFull as UserApplicationViewFullSchema from userapp.core.schemas.forms import BaseFormTableSchema from userapp.core.schemas.user_application_form import UserFormPost, UserFormPatch, UserFormTableSchema from userapp.core.schemas.user_project import UserProjectTableSchema -from userapp.core.schemas.user_submit import UserSubmitTableSchema from userapp.core.schemas.users import UserGet from userapp.db import session_generator from userapp.query_parser import get_filter_query_params diff --git a/userapp/api/routes/submit_nodes.py b/userapp/api/routes/submit_nodes.py deleted file mode 100644 index 1df8643..0000000 --- a/userapp/api/routes/submit_nodes.py +++ /dev/null @@ -1,35 +0,0 @@ -from fastapi import APIRouter, Response, Depends - -from userapp.query_parser import get_filter_query_params -from userapp.api.routes.security import check_is_admin, check_is_authenticated -from userapp.api.util import list_endpoint, create_one_endpoint, update_one_endpoint, delete_one_endpoint -from userapp.db import session_generator -from userapp.core.schemas.submit_node import SubmitNodeTableSchema, SubmitNodeGet, SubmitNodePost, SubmitNodePatch -from userapp.core.models.tables import SubmitNode as SubmitNodeTable - -router = APIRouter( - prefix="/submit_nodes", - tags=["Submit Nodes"], - dependencies=[], - responses={ - 404: { - "description": "Not found" - } - } -) - -@router.get("") -async def get_submit_nodes(response: Response, page: int = 0, page_size: int = 100, filter_query_params=Depends(get_filter_query_params), session=Depends(session_generator), is_authenticated=Depends(check_is_authenticated)) -> list[SubmitNodeGet]: - return await list_endpoint(session, SubmitNodeTable, response, filter_query_params, page, page_size) - -@router.delete("/{submit_node_id}", status_code=204) -async def delete_submit_node(submit_node_id: int, session=Depends(session_generator), is_admin=Depends(check_is_admin)) -> None: - await delete_one_endpoint(session, SubmitNodeTable, submit_node_id) - -@router.post("", status_code=201) -async def create_submit_node(submit_node: SubmitNodePost, session=Depends(session_generator), is_admin=Depends(check_is_admin)) -> SubmitNodeGet: - return await create_one_endpoint(session, SubmitNodeTable, submit_node) - -@router.put("/{submit_node_id}", status_code=200) -async def update_submit_node(submit_node_id: int, submit_node: SubmitNodePatch, session=Depends(session_generator), is_admin=Depends(check_is_admin)) -> SubmitNodeGet: - return await update_one_endpoint(session, SubmitNodeTable, submit_node_id, submit_node) diff --git a/userapp/api/routes/users.py b/userapp/api/routes/users.py index b5b9e05..62dc8cd 100644 --- a/userapp/api/routes/users.py +++ b/userapp/api/routes/users.py @@ -14,13 +14,12 @@ 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 from userapp.core.models.views import JoinedProjectView as JoinedProjectViewTable, \ - UserSubmitNodesView as UserSubmitNodesViewTable, UserSubmitNodesView, UserGroupView as UserGroupViewTable -from userapp.core.models.tables import User as UserTable, UserProject, UserSubmit, Group, UserGroup, Note as NoteTable + UserGroupView as UserGroupViewTable +from userapp.core.models.tables import User as UserTable, UserProject, Group, UserGroup, Note as NoteTable from userapp.api.load_options import user_load_options -from userapp.api.routes._util import _patch_user_submit_nodes, _patch_user_project, _patch_user_group +from userapp.api.routes._util import _patch_user_project, _patch_user_group # Rebuild field for those that would cause circular imports NoteGet.model_rebuild(_types_namespace={'UserGet': UserGet}) @@ -67,19 +66,6 @@ async def create_user(user: UserPostFull, session=Depends(session_generator), ch user_project_schema = UserProjectTableSchema(project_id=user.primary_project_id, role=user.primary_project_role, is_primary=True, user_id=created_user.id) await create_one_endpoint(session, UserProject, user_project_schema) - # Create the submit node associations - for submit_node in user.submit_nodes: - - # Create nodes for both auth_netid True and False to simplify logic - for for_auth_netid in [True, False]: - - user_submit_model = UserSubmitTableSchema( - user_id=created_user.id, - for_auth_netid=for_auth_netid, - **submit_node.model_dump(), - ) - await create_one_endpoint(session, UserSubmit, user_submit_model) - await session.flush() # Expire the user to force a fresh load from the database @@ -104,10 +90,6 @@ async def update_user(user_id: int, user: UserPatchFull, session=Depends(session user_data_only = UserPatch(**user.model_dump(exclude_unset=True)) updated_user = await update_one_endpoint(session, UserTable, user_id, user_data_only, load_options=user_load_options) - # Update Submit Nodes if patched - if user.submit_nodes is not None: - await _patch_user_submit_nodes(session, updated_user, user.submit_nodes) - # Expire the instance to force a fresh load from the database session.expire(updated_user) updated_user = await get_one_endpoint(session, UserTable, user_id, load_options=user_load_options) @@ -125,14 +107,6 @@ async def get_user_projects(user_id: int, response: Response, page: int = 0, pag return await list_endpoint(session, JoinedProjectViewTable, response, filter_query_params, page, page_size) -@router.get("/{user_id}/submit_nodes") -async def get_user_submit_nodes(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[UserSubmitGet]: - """Get submit nodes associated with a user""" - - select_stmt = select(UserSubmitNodesViewTable).where(UserSubmitNodesViewTable.user_id == user_id) - return await list_select_stmt(session, select_stmt, UserSubmitNodesViewTable, response, filter_query_params, page, page_size) - - @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[UserGroupViewSchema]: """Get groups associated with a user""" diff --git a/userapp/api/tests/fake_data.py b/userapp/api/tests/fake_data.py index a6cb524..e1c7654 100644 --- a/userapp/api/tests/fake_data.py +++ b/userapp/api/tests/fake_data.py @@ -3,7 +3,6 @@ from typing import Optional from userapp.core.models.enum import RoleEnum, PositionEnum -from userapp.core.schemas.user_submit import UserSubmitPost def project_data_f( @@ -54,11 +53,6 @@ def user_data_f(index: int, primary_project_id, is_admin=False, username: Option "position": PositionEnum.GRAD_STUDENT.name, "primary_project_id": primary_project_id, "primary_project_role": RoleEnum.MEMBER.name, - "submit_nodes": [ - { - "submit_node_id": 1 - } - ] } def user_form_data_f() -> dict: @@ -88,12 +82,12 @@ def user_form_data_f() -> dict: } -def user_form_approval_data_f(project_id: int, submit_nodes: list[UserSubmitPost]) -> dict: +def user_form_approval_data_f(project_id: int, submit_node_group_ids: list[int]) -> dict: return { "status": "APPROVED", "project_id": project_id, "user_position": PositionEnum.GRAD_STUDENT.value, - "submit_nodes": submit_nodes, + "submit_node_group_ids": submit_node_group_ids, } diff --git a/userapp/api/tests/test_forms.py b/userapp/api/tests/test_forms.py index b3f4203..9becb21 100644 --- a/userapp/api/tests/test_forms.py +++ b/userapp/api/tests/test_forms.py @@ -11,13 +11,13 @@ from userapp.api.tests.conftest import _make_auth_client -def create_submit_node(admin_client: Client) -> dict: +def create_submit_node_group(admin_client: Client) -> dict: response = admin_client.post( - "/submit_nodes", - json={"name": f"form-submit-node-{random.randint(0, 10**6)}"}, + "/groups", + json={"name": f"submit-node-group-{random.randint(0, 10**6)}", "type": "SUBMIT_NODE"}, ) assert response.status_code == 201, ( - f"POST /submit_nodes should return 201, got {response.status_code}: {response.text}" + f"POST /groups should return 201, got {response.status_code}: {response.text}" ) return response.json() @@ -274,10 +274,10 @@ def test_patch_updates_only_updated_by( ) project = project_factory() - submit_node = create_submit_node(admin_client) + submit_node_group = create_submit_node_group(admin_client) update_response = admin_client.patch( f"/forms/user-applications/{form_id}", - json=user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]), + json=user_form_approval_data_f(project["id"], [submit_node_group["id"]]), ) assert update_response.status_code == 200, ( @@ -320,10 +320,10 @@ def test_admin_can_approve_form(self, user: dict, nonadmin_client: Client, admin form_id = create_response.json()["id"] project = project_factory() - submit_node = create_submit_node(admin_client) + submit_node_group = create_submit_node_group(admin_client) update_response = admin_client.patch( f"/forms/user-applications/{form_id}", - json=user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]), + json=user_form_approval_data_f(project["id"], [submit_node_group["id"]]), ) assert update_response.status_code == 200, ( @@ -362,11 +362,11 @@ def test_approve_without_preserve_replaces_existing_data( form_id = create_response.json()["id"] original_project = project_factory() - original_submit_node = create_submit_node(admin_client) + original_submit_node_group = create_submit_node_group(admin_client) first_approval = admin_client.patch( f"/forms/user-applications/{form_id}", - json={**user_form_approval_data_f(original_project["id"], [{"submit_node_id": original_submit_node["id"]}]), "status": "APPROVED"}, + json={**user_form_approval_data_f(original_project["id"], [original_submit_node_group["id"]]), "status": "APPROVED"}, ) assert first_approval.status_code == 200 @@ -376,15 +376,14 @@ def test_approve_without_preserve_replaces_existing_data( user_post_approval = user_post_approval_response.json() assert user_post_approval["active"] - assert len(user_post_approval["groups"]) == 0, "User should not have any groups post approval when preserve_existing_data is False" + assert len(user_post_approval["groups"]) == 1, "User should have exactly 1 group (the approved submit node group) post approval when preserve_existing_data is False" + assert user_post_approval["groups"][0]["name"] == original_submit_node_group["name"], "User's group should be the approved submit node group post approval when preserve_existing_data is False" assert len(user_post_approval["projects"]) == 1, "User should have exactly 1 project post approval when preserve_existing_data is False" assert user_post_approval["projects"][0]["project_id"] == original_project["id"], "User's project should be the approved project post approval when preserve_existing_data is False" - assert len(user_post_approval["submit_nodes"]) == 1, "User should have exactly 1 submit node post approval when preserve_existing_data is False" - assert user_post_approval["submit_nodes"][0]["submit_node_name"] == original_submit_node["name"], "User's submit node should be the approved submit node post approval when preserve_existing_data is False" - # Check the new projects didn't collide with the old somehow + # Check the new projects/groups didn't collide with the old somehow assert user_post_approval["projects"][0]["project_id"] != user["projects"][0]["project_id"] - assert user_post_approval["submit_nodes"][0]["submit_node_name"] != user["submit_nodes"][0]["submit_node_name"] + assert user_post_approval["groups"][0]["name"] != user["groups"][0]["name"] def test_approve_with_preserve_keeps_existing_data( self, @@ -405,11 +404,11 @@ def test_approve_with_preserve_keeps_existing_data( form_id = create_response.json()["id"] original_project = project_factory() - original_submit_node = create_submit_node(admin_client) + original_submit_node_group = create_submit_node_group(admin_client) first_approval = admin_client.patch( f"/forms/user-applications/{form_id}", - json={**user_form_approval_data_f(original_project["id"], [{"submit_node_id": original_submit_node["id"]}]), + json={**user_form_approval_data_f(original_project["id"], [original_submit_node_group["id"]]), "status": "APPROVED", "preserve_existing_data": True}, ) assert first_approval.status_code == 200 @@ -423,12 +422,9 @@ def test_approve_with_preserve_keeps_existing_data( assert len(user_post_approval["groups"]) == len(user["groups"]), "User should have groups post approval when preserve_existing_data is True" assert len(user_post_approval["projects"]) == len(user['projects']) assert user_post_approval["projects"][0]["project_id"] == user["projects"][0]["project_id"] - assert len(user_post_approval["submit_nodes"]) == len(user['submit_nodes']) - assert user_post_approval["submit_nodes"][0]["submit_node_name"] == user["submit_nodes"][0]["submit_node_name"] # Check the new projects didn't collide with the old somehow assert user_post_approval["projects"][0]["project_id"] != original_project["id"] - assert user_post_approval["submit_nodes"][0]["submit_node_name"] != original_submit_node["name"] class TestUserFormTriggers: @@ -444,10 +440,10 @@ def test_approved_form_cannot_change(self, user: dict, nonadmin_client: Client, form_id = create_response.json()["id"] project = project_factory() - submit_node = create_submit_node(admin_client) + submit_node_group = create_submit_node_group(admin_client) approve_response = admin_client.patch( f"/forms/user-applications/{form_id}", - json=user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]), + json=user_form_approval_data_f(project["id"], [submit_node_group["id"]]), ) assert approve_response.status_code == 200 @@ -476,10 +472,10 @@ def test_approve_runs_trigger(self, user: dict, nonadmin_client: Client, admin_c ) project = project_factory() - submit_node = create_submit_node(admin_client) + submit_node_group = create_submit_node_group(admin_client) update_response = admin_client.patch( f"/forms/user-applications/{form_id}", - json=user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]), + json=user_form_approval_data_f(project["id"], [submit_node_group["id"]]), ) assert update_response.status_code == 200 @@ -508,10 +504,10 @@ def test_approve_activates_user_and_assigns_access( assert deactivate_response.status_code == 200 project = project_factory() - submit_node = create_submit_node(admin_client) + submit_node_group = create_submit_node_group(admin_client) update_response = admin_client.patch( f"/forms/user-applications/{form_id}", - json=user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]), + json=user_form_approval_data_f(project["id"], [submit_node_group["id"]]), ) assert update_response.status_code == 200, ( @@ -524,7 +520,7 @@ def test_approve_activates_user_and_assigns_access( assert updated_user["active"] is True assert any(project_membership["project_id"] == project["id"] for project_membership in updated_user["projects"]) - assert any(user_submit["submit_node_name"] == submit_node["name"] for user_submit in updated_user["submit_nodes"]) + assert any(group["name"] == submit_node_group["name"] for group in updated_user["groups"]) @pytest.fixture @@ -621,8 +617,8 @@ def test_approve_with_email_sets_user_email( form_id = create_response.json()["id"] project = project_factory() - submit_node = create_submit_node(admin_client) - approval_data = user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]) + submit_node_group = create_submit_node_group(admin_client) + approval_data = user_form_approval_data_f(project["id"], [submit_node_group["id"]]) approval_data["email"] = "new_email@example.com" update_response = admin_client.patch(f"/forms/user-applications/{form_id}", json=approval_data) @@ -665,9 +661,9 @@ def test_approve_without_email_fails_when_user_has_no_email( form_id = create_response.json()["id"] project = project_factory() - submit_node = create_submit_node(admin_client) + submit_node_group = create_submit_node_group(admin_client) # Do NOT include email in the approval patch - approval_data = user_form_approval_data_f(project["id"], [{"submit_node_id": submit_node["id"]}]) + approval_data = user_form_approval_data_f(project["id"], [submit_node_group["id"]]) update_response = admin_client.patch(f"/forms/user-applications/{form_id}", json=approval_data) diff --git a/userapp/api/tests/test_submit_nodes.py b/userapp/api/tests/test_submit_nodes.py deleted file mode 100644 index 7627697..0000000 --- a/userapp/api/tests/test_submit_nodes.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest -import random - -from httpx import Client - - -class TestSubmitNodesSecurity: - - def test_get_submit_nodes_requires_authentication(self, api_client: Client): - """Unauthenticated requests to GET /submit_nodes should be rejected with 401.""" - - response = api_client.get("/submit_nodes") - - assert response.status_code == 401, ( - f"Unauthenticated GET /submit_nodes should return 401, got {response.status_code}: {response.text}" - ) - - def test_get_submit_nodes_as_authenticated_user(self, nonadmin_client: Client): - """Authenticated non-admin users should be able to list submit nodes.""" - - response = nonadmin_client.get("/submit_nodes") - - assert response.status_code == 200, ( - f"Authenticated GET /submit_nodes should return 200, got {response.status_code}: {response.text}" - ) - - def test_create_submit_node_requires_admin(self, nonadmin_client: Client, project_factory, user_factory): - """Non-admin users should not be able to create submit nodes.""" - - submit_node_data = { - "name": "Test Submit Node", - "url": "https://submit.node.test", - "description": "A test submit node" - } - - response = nonadmin_client.post("/submit_nodes", json=submit_node_data) - - assert response.status_code == 403, ( - f"Non-admin POST /submit_nodes should return 403, got {response.status_code}: {response.text}" - ) - - def test_create_submit_node_as_admin(self, admin_client: Client): - """Admin users should be able to create submit nodes.""" - - submit_node_data = { - "name": f"test-submit{random.randint(0,10**5)}.node", - } - - response = admin_client.post("/submit_nodes", json=submit_node_data) - - assert response.status_code == 201, ( - f"Admin POST /submit_nodes should return 201, got {response.status_code}: {response.text}" - ) - - def test_delete_submit_node_requires_admin(self, nonadmin_client: Client): - """Non-admin users should not be able to delete submit nodes.""" - - # Attempt to delete as non-admin - delete_response = nonadmin_client.delete(f"/submit_nodes/1") - - assert delete_response.status_code == 403, ( - f"Non-admin DELETE /submit_nodes/1 should return 403, got {delete_response.status_code}: {delete_response.text}" - ) - - def test_delete_submit_node_as_admin(self, admin_client: Client): - """Admin users should be able to delete submit nodes.""" - - # First create a submit node to delete - submit_node_data = { - "name": f"test-submit{random.randint(0,10**5)}.node", - } - - create_response = admin_client.post("/submit_nodes", json=submit_node_data) - assert create_response.status_code == 201, ( - f"Admin POST /submit_nodes should return 201, got {create_response.status_code}: {create_response.text}" - ) - - submit_node_id = create_response.json()['id'] - - # Now delete the created submit node - delete_response = admin_client.delete(f"/submit_nodes/{submit_node_id}") - - assert delete_response.status_code == 204, ( - f"Admin DELETE /submit_nodes/{submit_node_id} should return 204, got {delete_response.status_code}: {delete_response.text}" - ) - - def test_update_submit_node_as_admin(self, admin_client: Client): - """Test admins can update submit nodes.""" - - # First create a submit node to update - submit_node_data = { - "name": f"test-submit{random.randint(0,10**5)}.node", - } - - create_response = admin_client.post("/submit_nodes", json=submit_node_data) - assert create_response.status_code == 201, ( - f"Admin POST /submit_nodes should return 201, got {create_response.status_code}: {create_response.text}" - ) - - submit_node_id = create_response.json()['id'] - - # Now update the created submit node - updated_name = f"test-submit{random.randint(0,10**5)}.node" - update_data = { - "name": updated_name, - } - - update_response = admin_client.put(f"/submit_nodes/{submit_node_id}", json=update_data) - - assert update_response.status_code == 200, ( - f"Admin PUT /submit_nodes/{submit_node_id} should return 200, got {update_response.status_code}: {update_response.text}" - ) - assert update_response.json()['name'] == updated_name, ( - f"Updated submit node name should be '{updated_name}', got '{update_response.json()['name']}'" - ) - - diff --git a/userapp/api/tests/test_users.py b/userapp/api/tests/test_users.py index 4745ca1..456a843 100644 --- a/userapp/api/tests/test_users.py +++ b/userapp/api/tests/test_users.py @@ -39,11 +39,9 @@ def test_create_user(self, admin_client: Client, project: dict): assert response.status_code == 201, f"Creating a user should return a 201 status code, instead got {response.text}" created_user = response.json() for key in created_user: - if key not in ["id", "is_pi", "submit_nodes", "date", "notes", "projects", "groups", "auth_netid", "auth_username", "username", "user_forms"]: + if key not in ["id", "is_pi", "date", "created_at", "updated_at", "notes", "projects", "groups", "auth_netid", "auth_username", "username", "user_forms"]: assert created_user[key] == user_payload[key], f"User {key} should match the payload" - assert set(map(lambda x: x['submit_node_id'], user_payload['submit_nodes'])) == set(map(lambda x: x['submit_node_id'], created_user['submit_nodes'])) - def test_get_user(self, admin_client: Client, user_factory, project_factory): """Test getting a user by ID""" @@ -71,11 +69,6 @@ def test_update_user_simple(self, admin_client: Client, user_factory, project_fa "phone1": "555-9999", "phone2": "", "position": "POSTDOC", - "submit_nodes": [ - { - "submit_node_id": 2 # Default is 1 - } - ] } user_payload = admin_client.patch(f"/users/{user['id']}", json=update_payload) @@ -87,78 +80,6 @@ def test_update_user_simple(self, admin_client: Client, user_factory, project_fa assert updated_data['phone1'] == update_payload['phone1'], "Phone 1 should be updated" assert updated_data['phone2'] == None, "Phone 2 should be updated" assert updated_data['position'] == update_payload['position'], "Position should be updated" - assert updated_data['submit_nodes'][0]['submit_node_id'] == 2, "User submit node should be updated" - - def test_update_users_submit_nodes(self, admin_client: Client, user_factory, project_factory): - """Test updating user's submit nodes""" - - project = project_factory() - user = user_factory(10, project['id']) - - update_payload = { - "submit_nodes": [ - { - "submit_node_id": 1 - }, - { - "submit_node_id": 2 - } - ] - } - - user_payload = admin_client.patch(f"/users/{user['id']}", json=update_payload) - - assert user_payload.status_code == 200, f"Updating a user's submit nodes should return a 200 status code, instead got {user_payload.text}" - - updated_data = user_payload.json() - updated_submit_node_ids = set(map(lambda x: x['submit_node_id'], updated_data['submit_nodes'])) - expected_submit_node_ids = set([1, 2]) - assert updated_submit_node_ids == expected_submit_node_ids, "User submit nodes should be updated correctly" - - def test_update_users_submit_nodes_twice(self, admin_client: Client, user_factory, project_factory): - """Test updating user's submit nodes""" - - project = project_factory() - user = user_factory(10, project['id']) - - update_payload = { - "submit_nodes": [ - { - "submit_node_id": 1 - }, - { - "submit_node_id": 2 - } - ] - } - - user_payload = admin_client.patch(f"/users/{user['id']}", json=update_payload) - - assert user_payload.status_code == 200, f"Updating a user's submit nodes should return a 200 status code, instead got {user_payload.text}" - - updated_data = user_payload.json() - updated_submit_node_ids = set(map(lambda x: x['submit_node_id'], updated_data['submit_nodes'])) - expected_submit_node_ids = set([1, 2]) - assert updated_submit_node_ids == expected_submit_node_ids, "User submit nodes should be updated correctly" - - update_payload = { - "submit_nodes": [ - { - "submit_node_id": 2 - } - ] - } - - user_payload = admin_client.patch(f"/users/{user['id']}", json=update_payload) - - assert user_payload.status_code == 200, f"Updating a user's submit nodes should return a 200 status code, instead got {user_payload.text}" - - updated_data = user_payload.json() - updated_submit_node_ids = set(map(lambda x: x['submit_node_id'], updated_data['submit_nodes'])) - expected_submit_node_ids = set([2]) - assert updated_submit_node_ids == expected_submit_node_ids, "User submit nodes should be updated correctly" - - def test_nullify_email1(self, admin_client: Client, user_factory, project_factory): @@ -236,17 +157,6 @@ def test_get_user_projects(self, admin_client: Client, user_factory, filled_out_ assert len(projects) > 0, "User should have at least one project" assert any(proj['project_id'] == filled_out_project['id'] for proj in projects), "User's projects should include the filled out project" - def test_get_user_submit_nodes(self, admin_client: Client, user_factory, filled_out_project: dict): - """Test getting submit nodes for a user""" - user = user_factory(random.randint(2001, 3000), filled_out_project['id']) - - response = admin_client.get(f"/users/{user['id']}/submit_nodes") - - assert response.status_code == 200, f"Getting user submit nodes should return a 200 status code, instead got {response.text}" - submit_nodes = response.json() - assert len(submit_nodes) > 0, "User should have at least one submit node" - assert user['submit_nodes'][0]['submit_node_id'] in map(lambda x: x['submit_node_id'], submit_nodes), "User's submit nodes should include those from the filled out project" - def test_get_user_groups_simple(self, admin_client: Client, user_factory, filled_out_project: dict): """Test getting groups for a user""" user = user_factory(random.randint(3001, 4000), filled_out_project['id']) @@ -290,21 +200,6 @@ def test_get_user_by_netid(self, admin_client: Client, user_factory, project_fac assert fetched_user['id'] == user['id'], "Fetched user ID should match the created user ID" assert fetched_user['name'] == user['name'], "Fetched user name should match the created user name" - def test_patch_user_doesnt_remove_submit_nodes(self, admin_client: Client, user, project): - """Test that patching a user without submit nodes does not remove existing submit nodes""" - - # First, update the user with an empty submit nodes list - update_payload = { - "name": "Cool Name" - } - - user_payload = admin_client.patch(f"/users/{user['id']}", json=update_payload) - - assert user_payload.status_code == 200, f"Updating a user with empty submit nodes should return a 200 status code, instead got {user_payload.text}" - - updated_data = user_payload.json() - assert len(updated_data['submit_nodes']) > 0, "User's submit nodes should not be removed when patching with an empty list" - def schema_user_data(**overrides): data = { diff --git a/userapp/core/models/enum.py b/userapp/core/models/enum.py index c89ca0d..d1b61cf 100644 --- a/userapp/core/models/enum.py +++ b/userapp/core/models/enum.py @@ -35,6 +35,9 @@ class EntityManagerEnum(Enum): MANIFEST = "MANIFEST" # Manifest Groups MORGRIDGE_AD = "MORGRIDGE_ACTIVE_DIRECTORY" +class GroupTypeEnum(Enum): + SUBMIT_NODE = "SUBMIT_NODE" + # I decided against using this so I could set the values in just the UI # Keeping it here just incase class DepartmentEnum(Enum): diff --git a/userapp/core/models/tables.py b/userapp/core/models/tables.py index 2f77842..7c644f9 100644 --- a/userapp/core/models/tables.py +++ b/userapp/core/models/tables.py @@ -8,10 +8,9 @@ from sqlalchemy.dialects.postgresql import JSONB from userapp.core.models.enum import FormStatusEnum, FormTypeEnum, RoleEnum, PositionEnum, HttpRequestMethodEnum, \ - EntityManagerEnum + EntityManagerEnum, GroupTypeEnum 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 @@ -20,9 +19,11 @@ class Group(Base): __tablename__ = 'groups' id = Column(Integer, primary_key=True, index=True) name = Column(VARCHAR(32), unique=True, nullable=False) + description = Column(String(255), nullable=True) point_of_contact = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), index=True) unix_gid = Column(Integer, unique=True) has_groupdir = Column(Boolean, nullable=False, default=True) + type = Column(SQLEnum(GroupTypeEnum, name='group_type_enum'), nullable=True) point_of_contact_user: Mapped[Optional["User"]] = relationship( "User", @@ -82,11 +83,6 @@ class Project(Base): ) -class SubmitNode(Base): - __tablename__ = 'submit_nodes' - id = Column(Integer, primary_key=True, index=True) - name = Column(String(60)) - class User(Base): __tablename__ = 'users' @@ -123,13 +119,6 @@ class User(Base): back_populates="users" ) - submit_nodes: Mapped[List[UserSubmitNodesView]] = relationship( - "UserSubmitNodesView", - primaryjoin="User.id==foreign(UserSubmitNodesView.user_id)", - lazy="selectin", - viewonly=True, - ) - projects: Mapped[List["JoinedProjectView"]] = relationship( "JoinedProjectView", primaryjoin="User.id==foreign(JoinedProjectView.id)", @@ -198,24 +187,6 @@ class UserProject(Base): ) -class UserSubmit(Base): - __tablename__ = 'user_submits' - __table_args__ = ( - UniqueConstraint('user_id', 'submit_node_id', 'for_auth_netid', name='user_submits_distinct'), - Index('idx_user_submits_userid_submitnodeid_incl', 'user_id', 'submit_node_id', - postgresql_include=['disk_quota', 'hpc_diskquota', 'hpc_inodequota', 'hpc_joblimit', 'hpc_corelimit', 'hpc_fairshare']), - ) - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False) - submit_node_id = Column(Integer, ForeignKey('submit_nodes.id', ondelete="CASCADE"), nullable=False, index=True) - for_auth_netid = Column(Boolean) - disk_quota = Column(Integer) - hpc_diskquota = Column(Integer, nullable=False, default=100) - hpc_inodequota = Column(Integer, nullable=False, default=50000) - hpc_joblimit = Column(Integer, nullable=False, default=10) - hpc_corelimit = Column(Integer, nullable=False, default=720) - hpc_fairshare = Column(Integer, nullable=False, default=100) - class Token(Base): __tablename__ = 'tokens' id = Column(Integer, primary_key=True, index=True) diff --git a/userapp/core/models/views.py b/userapp/core/models/views.py index c835864..bc98508 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, EntityManagerEnum +from userapp.core.models.enum import GroupTypeEnum, RoleEnum, PositionEnum, FormTypeEnum, FormStatusEnum, EntityManagerEnum from userapp.core.models.main import Base @@ -69,20 +69,6 @@ class JoinedProjectView(Base): last_note_ticket = Column(String(9)) -class UserSubmitNodesView(Base): - __tablename__ = 'user_submit_nodes' - __table_args__ = {'info': dict(is_view=True)} - user_id = Column(Integer, primary_key=True) - submit_node_id = Column(Integer, primary_key=True) - submit_node_name = Column(String(60)) - disk_quota = Column(Integer) - hpc_diskquota = Column(Integer) - hpc_inodequota = Column(Integer) - hpc_joblimit = Column(Integer) - hpc_corelimit = Column(Integer) - hpc_fairshare = Column(Integer) - - class UserApplicationView(Base): __tablename__ = 'user_applications' __table_args__ = {'info': dict(is_view=True)} @@ -167,9 +153,11 @@ class UserGroupView(Base): # From Group name = Column(String(255)) + description = Column(String(255)) point_of_contact = Column(Integer) unix_gid = Column(Integer) has_groupdir = Column(Boolean) + type = Column(SQLEnum(GroupTypeEnum, name="group_type_enum")) point_of_contact_user: Mapped[Optional["User"]] = relationship( "User", diff --git a/userapp/core/schemas/general.py b/userapp/core/schemas/general.py index 0ef3c51..2543796 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, EntityManagerEnum +from userapp.core.models.enum import GroupTypeEnum, RoleEnum, PositionEnum, FormStatusEnum, FormTypeEnum, EntityManagerEnum class BaseModel(PydanticBaseModel): @@ -144,6 +144,8 @@ class UserGroupView(BaseModel): # From the Group table name: str + description: Optional[str] = Field(default=None) point_of_contact: Optional["UserGet"] = Field(default=None, validation_alias='point_of_contact_user') unix_gid: Optional[int] = Field(default=None) has_groupdir: bool + type: Optional[GroupTypeEnum] = Field(default=None) diff --git a/userapp/core/schemas/groups.py b/userapp/core/schemas/groups.py index a68033a..f27f274 100644 --- a/userapp/core/schemas/groups.py +++ b/userapp/core/schemas/groups.py @@ -3,6 +3,7 @@ import re from userapp.core.schemas.general import BaseModel +from userapp.core.models.enum import GroupTypeEnum def group_name_validator(name: str) -> str: @@ -24,29 +25,36 @@ class GroupTableSchema(BaseModel): id: Optional[int] = Field(default=None) name: str + description: Optional[str] = Field(default=None) point_of_contact: Optional[int] = Field(default=None) unix_gid: Optional[int] = Field(default=None) has_groupdir: Optional[bool] = Field(default=None) - + type: Optional[GroupTypeEnum] = Field(default=None) class GroupPost(BaseModel): name: Annotated[str, AfterValidator(group_name_validator)] + description: Optional[str] = Field(default=None) point_of_contact: Optional[int] = Field(default=None) unix_gid: Optional[int] = Field(default=None) has_groupdir: Optional[bool] = Field(default=None) + type: Optional[GroupTypeEnum] = Field(default=None) class GroupPatch(BaseModel): name: Annotated[Optional[str], AfterValidator(group_name_validator)] = Field(default=None) + description: Optional[str] = Field(default=None) point_of_contact: Optional[int] = Field(default=None) unix_gid: Optional[int] = Field(default=None) has_groupdir: Optional[bool] = Field(default=None) + type: Optional[GroupTypeEnum] = Field(default=None) class GroupGet(BaseModel): id: int name: str + description: Optional[str] = Field(default=None) point_of_contact: Optional["UserGet"] = Field(default=None, validation_alias='point_of_contact_user') unix_gid: Optional[int] = Field(default=None) has_groupdir: bool + type: Optional[GroupTypeEnum] = Field(default=None) diff --git a/userapp/core/schemas/submit_node.py b/userapp/core/schemas/submit_node.py deleted file mode 100644 index e009679..0000000 --- a/userapp/core/schemas/submit_node.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional - -from pydantic import Field - -from userapp.core.schemas.general import BaseModel - -class SubmitNodeTableSchema(BaseModel): - """Used to represent a submit node as stored in the database""" - - id: Optional[int] = Field(default=None) - name: str - -class SubmitNodeGet(SubmitNodeTableSchema): - """Exact same as SubmitNodeTableSchema for now, but kept separate for future changes""" - pass - -class SubmitNodePost(BaseModel): - name: str - -class SubmitNodePatch(BaseModel): - name: str diff --git a/userapp/core/schemas/user_application_form.py b/userapp/core/schemas/user_application_form.py index 1b77f8f..da3a1ed 100644 --- a/userapp/core/schemas/user_application_form.py +++ b/userapp/core/schemas/user_application_form.py @@ -5,7 +5,7 @@ from userapp.core.models.enum import FormStatusEnum, FormTypeEnum, PositionEnum, RoleEnum from userapp.core.schemas.general import BaseModel -from userapp.core.schemas.users import UserGet, UserSubmitPost +from userapp.core.schemas.users import UserGet class UserFormPatch(BaseModel): @@ -15,7 +15,7 @@ class UserFormPatch(BaseModel): email: Optional[EmailStr] = Field(default=None) project_id: Optional[int] = Field(default=None) user_position: Optional[PositionEnum] = Field(default=None) - submit_nodes: Optional[list["UserSubmitPost"]] = Field(default=None) + submit_node_group_ids: Optional[list[int]] = Field(default=None) class UserFormTableSchema(BaseModel): diff --git a/userapp/core/schemas/user_group.py b/userapp/core/schemas/user_group.py index 0f7730d..e606ea3 100644 --- a/userapp/core/schemas/user_group.py +++ b/userapp/core/schemas/user_group.py @@ -33,3 +33,7 @@ class ManagedUserGroupPut(BaseModel): """Used by a managed endpoint which has a url explicit managed_by and group_id""" user_id: int + +class UserGroupCreate(BaseModel): + user_id: int + group_id: int \ No newline at end of file diff --git a/userapp/core/schemas/user_submit.py b/userapp/core/schemas/user_submit.py deleted file mode 100644 index 0cacfc9..0000000 --- a/userapp/core/schemas/user_submit.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Optional - -from pydantic import ConfigDict, Field - -from userapp.core.schemas.general import BaseModel - -class UserSubmitTableSchema(BaseModel): - """Used to represent a user-submit node association as stored in the database""" - - model_config = ConfigDict(extra='ignore') - - id: Optional[int] = Field(default=None) - submit_node_id: int - user_id: int - for_auth_netid: bool - disk_quota: Optional[int] = Field(default=None) - hpc_diskquota: Optional[int] = Field(default=None) - hpc_inodequota: Optional[int] = Field(default=None) - hpc_joblimit: Optional[int] = Field(default=None) - hpc_corelimit: Optional[int] = Field(default=None) - hpc_fairshare: Optional[int] = Field(default=None) - -class UserSubmitGet(BaseModel): - - model_config = ConfigDict(extra='ignore') - - id: Optional[int] = Field(default=None) - submit_node_id: int - submit_node_name: str - user_id: int - disk_quota: Optional[int] = Field(default=None) - hpc_diskquota: Optional[int] = Field(default=None) - hpc_inodequota: Optional[int] = Field(default=None) - hpc_joblimit: Optional[int] = Field(default=None) - hpc_corelimit: Optional[int] = Field(default=None) - hpc_fairshare: Optional[int] = Field(default=None) - -class UserSubmitPost(BaseModel): - - submit_node_id: int - # for_auth_netid: bool # Gets handled automatically - disk_quota: Optional[int] = Field(default=None) - hpc_diskquota: Optional[int] = Field(default=None) - hpc_inodequota: Optional[int] = Field(default=None) - hpc_joblimit: Optional[int] = Field(default=None) - hpc_corelimit: Optional[int] = Field(default=None) - hpc_fairshare: Optional[int] = Field(default=None) diff --git a/userapp/core/schemas/users.py b/userapp/core/schemas/users.py index a3ca203..f1a5add 100644 --- a/userapp/core/schemas/users.py +++ b/userapp/core/schemas/users.py @@ -5,7 +5,6 @@ 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 from userapp.core.schemas.groups import GroupGet from userapp.core.models.enum import RoleEnum, PositionEnum @@ -86,7 +85,6 @@ def auth_username(self) -> bool: class UserGetFull(UserGet): notes: list["NoteGet"] = Field(default=[]) - submit_nodes: list["UserSubmitGet"] = Field(default=[]) projects: list["JoinedProjectView"] = Field(default=[]) groups: list["UserGroupView"] = Field(default=[]) user_forms: list["UserApplicationViewSchema"] = Field(default=[]) @@ -119,7 +117,6 @@ class UserPostFull(UserPost): primary_project_id: int primary_project_role: RoleEnum - submit_nodes: Optional[list["UserSubmitPost"]] = Field(default=[]) class UserPatch(BaseModel): @@ -143,7 +140,7 @@ class UserPatch(BaseModel): class UserPatchFull(UserPatch): - submit_nodes: Optional[list[UserSubmitPost]] = Field(default=None) + pass class RestrictedUserPatch(BaseModel): """Used to allow a user to self update limited information""" diff --git a/userapp/main.py b/userapp/main.py index 8081daf..77c387e 100644 --- a/userapp/main.py +++ b/userapp/main.py @@ -106,7 +106,7 @@ async def commit_db_session(request: Request, call_next): return app async def main(): - config = uvicorn.Config("userapp.main:create_app", host="0.0.0.0", port=80, log_level="info", factory=True) + config = uvicorn.Config("userapp.main:create_app", host="0.0.0.0", port=8080, log_level="info", factory=True) server = uvicorn.Server(config) await server.serve()