Skip to content

Commit

Permalink
Merge branch 'main' into 1712-on-hold-deprecate-use-of-v1-tasks-withi…
Browse files Browse the repository at this point in the history
…n-v2-workflows
  • Loading branch information
tcompa committed Sep 11, 2024
2 parents e28d9ef + 13315f5 commit b1acbef
Show file tree
Hide file tree
Showing 9 changed files with 274 additions and 42 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ access-control rules (which will be introduced later).
* Add creation of default user group into `fractalctl set-db` command (\#1738).
* Create `update-db-script` for current version, that adds all users to default group (\#1738).
* API:
* Added `/auth/group/` and `/auth/group-names/` routers (\#1738).
* Implement `/auth/users/{id}/` POST/PATCH routes in `fractal-server` (\#1738).
* Added `/auth/group/` and `/auth/group-names/` routers (\#1738, \#1752).
* Implement `/auth/users/{id}/` POST/PATCH routes in `fractal-server` (\#1738, \#1747, \#1752).
* Introduce `UserUpdateWithNewGroupIds` schema for `PATCH /auth/users/{id}/` (\#1747, \#1752).
* Add `UserManager.on_after_register` hook to add new users to default user group (\#1738).
* Database:
* Added new `usergroup` and `linkusergroup` tables (\#1738).
Expand Down
2 changes: 1 addition & 1 deletion fractal_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__VERSION__ = "2.4.0a0"
__VERSION__ = "2.4.0a2"
47 changes: 25 additions & 22 deletions fractal_server/app/routes/auth/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
from fastapi import Depends
from fastapi import HTTPException
from fastapi import status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col
from sqlmodel import func
from sqlmodel import select

from . import current_active_superuser
Expand All @@ -18,7 +20,9 @@
from fractal_server.app.models import LinkUserGroup
from fractal_server.app.models import UserGroup
from fractal_server.app.models import UserOAuth
from fractal_server.logger import set_logger

logger = set_logger(__name__)

router_group = APIRouter()

Expand All @@ -31,9 +35,6 @@ async def get_list_user_groups(
user: UserOAuth = Depends(current_active_superuser),
db: AsyncSession = Depends(get_async_db),
) -> list[UserGroupRead]:
"""
FIXME docstring
"""

# Get all groups
stm_all_groups = select(UserGroup)
Expand All @@ -46,7 +47,8 @@ async def get_list_user_groups(
res = await db.execute(stm_all_links)
links = res.scalars().all()

# FIXME GROUPS: this must be optimized
# TODO: possible optimizations for this construction are listed in
# https://github.com/fractal-analytics-platform/fractal-server/issues/1742
for ind, group in enumerate(groups):
groups[ind] = dict(
group.model_dump(),
Expand All @@ -68,9 +70,6 @@ async def get_single_user_group(
user: UserOAuth = Depends(current_active_superuser),
db: AsyncSession = Depends(get_async_db),
) -> UserGroupRead:
"""
FIXME docstring
"""
group = await _get_single_group_with_user_ids(group_id=group_id, db=db)
return group

Expand All @@ -85,9 +84,6 @@ async def create_single_group(
user: UserOAuth = Depends(current_active_superuser),
db: AsyncSession = Depends(get_async_db),
) -> UserGroupRead:
"""
FIXME docstring
"""

# Check that name is not already in use
existing_name_str = select(UserGroup).where(
Expand Down Expand Up @@ -119,32 +115,42 @@ async def update_single_group(
user: UserOAuth = Depends(current_active_superuser),
db: AsyncSession = Depends(get_async_db),
) -> UserGroupRead:
"""
FIXME docstring
"""

# Check that all required users exist
# Note: The reason for introducing `col` is as in
# https://sqlmodel.tiangolo.com/tutorial/where/#type-annotations-and-errors,
stm = select(UserOAuth).where(
stm = select(func.count()).where(
col(UserOAuth.id).in_(group_update.new_user_ids)
)
res = await db.execute(stm)
matching_users = res.scalars().unique().all()
if not len(matching_users) == len(group_update.new_user_ids):
number_matching_users = res.scalar()
if number_matching_users != len(group_update.new_user_ids):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=(
f"At least user with IDs {group_update.new_user_ids} "
"does not exist."
f"Not all requested users (IDs {group_update.new_user_ids}) "
"exist."
),
)

# Add new users to existing group
for user_id in group_update.new_user_ids:
link = LinkUserGroup(user_id=user_id, group_id=group_id)
db.add(link)
await db.commit()
try:
await db.commit()
except IntegrityError as e:
error_msg = (
f"Cannot link users with IDs {group_update.new_user_ids} "
f"to group {group_id}. "
"Likely reason: one of these links already exists.\n"
f"Original error: {str(e)}"
)
logger.info(error_msg)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=error_msg,
)

updated_group = await _get_single_group_with_user_ids(
group_id=group_id, db=db
Expand All @@ -161,9 +167,6 @@ async def delete_single_group(
user: UserOAuth = Depends(current_active_superuser),
db: AsyncSession = Depends(get_async_db),
) -> UserGroupRead:
"""
FIXME docstring
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=(
Expand Down
117 changes: 103 additions & 14 deletions fractal_server/app/routes/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,32 @@
from fastapi_users import exceptions
from fastapi_users import schemas
from fastapi_users.router.common import ErrorCode
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col
from sqlmodel import func
from sqlmodel import select

from . import current_active_superuser
from ...db import get_async_db
from ...schemas.user import UserRead
from ...schemas.user import UserUpdate
from ...schemas.user import UserUpdateWithNewGroupIds
from ._aux_auth import _get_single_user_with_group_ids
from fractal_server.app.models import LinkUserGroup
from fractal_server.app.models import UserGroup
from fractal_server.app.models import UserOAuth
from fractal_server.app.routes.auth._aux_auth import _user_or_404
from fractal_server.app.security import get_user_manager
from fractal_server.app.security import UserManager
from fractal_server.logger import set_logger

router_users = APIRouter()


logger = set_logger(__name__)


@router_users.get("/users/{user_id}/", response_model=UserRead)
async def get_user(
user_id: int,
Expand All @@ -43,31 +52,110 @@ async def get_user(
@router_users.patch("/users/{user_id}/", response_model=UserRead)
async def patch_user(
user_id: int,
user_update: UserUpdate,
user_update: UserUpdateWithNewGroupIds,
current_superuser: UserOAuth = Depends(current_active_superuser),
user_manager: UserManager = Depends(get_user_manager),
db: AsyncSession = Depends(get_async_db),
):
"""
Custom version of the PATCH-user route from `fastapi-users`.
In order to keep the fastapi-users logic in place (which is convenient to
update user attributes), we split the endpoint into two branches. We either
go through the fastapi-users-based attribute-update branch, or through the
branch where we establish new user/group relationships.
Note that we prevent making both changes at the same time, since it would
be more complex to guarantee that endpoint error would leave the database
in the same state as before the API call.
"""

# We prevent simultaneous editing of both user attributes and user/group
# associations
user_update_dict_without_groups = user_update.dict(
exclude_unset=True, exclude={"new_group_ids"}
)
edit_attributes = user_update_dict_without_groups != {}
edit_groups = user_update.new_group_ids is not None
if edit_attributes and edit_groups:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
"Cannot modify both user attributes and group membership. "
"Please make two independent PATCH calls"
),
)

# Check that user exists
user_to_patch = await _user_or_404(user_id, db)

try:
user = await user_manager.update(
user_update, user_to_patch, safe=False, request=None
)
patched_user = schemas.model_validate(UserOAuth, user)
except exceptions.InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.UPDATE_USER_INVALID_PASSWORD,
"reason": e.reason,
},
if edit_groups:
# Establish new user/group relationships

# Check that all required groups exist
# Note: The reason for introducing `col` is as in
# https://sqlmodel.tiangolo.com/tutorial/where/#type-annotations-and-errors,
stm = select(func.count()).where(
col(UserGroup.id).in_(user_update.new_group_ids)
)
res = await db.execute(stm)
number_matching_groups = res.scalar()
if number_matching_groups != len(user_update.new_group_ids):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=(
"Not all requested groups (IDs: "
f"{user_update.new_group_ids}) exist."
),
)

for new_group_id in user_update.new_group_ids:
link = LinkUserGroup(user_id=user_id, group_id=new_group_id)
db.add(link)

try:
await db.commit()
except IntegrityError as e:
error_msg = (
f"Cannot link groups with IDs {user_update.new_group_ids} "
f"to user {user_id}. "
"Likely reason: one of these links already exists.\n"
f"Original error: {str(e)}"
)
logger.info(error_msg)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=error_msg,
)

patched_user = user_to_patch

elif edit_attributes:
# Modify user attributes
try:
user_update_without_groups = UserUpdate(
**user_update_dict_without_groups
)
user = await user_manager.update(
user_update_without_groups,
user_to_patch,
safe=False,
request=None,
)
patched_user = schemas.model_validate(UserOAuth, user)
except exceptions.InvalidPasswordException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": ErrorCode.UPDATE_USER_INVALID_PASSWORD,
"reason": e.reason,
},
)
else:
# Nothing to do, just continue
patched_user = user_to_patch

# Enrich user object with `group_ids` attribute
patched_user_with_group_ids = await _get_single_user_with_group_ids(
patched_user, db
)
Expand All @@ -92,7 +180,8 @@ async def list_users(
res = await db.execute(stm_all_links)
links = res.scalars().all()

# FIXME GROUPS: this must be optimized
# TODO: possible optimizations for this construction are listed in
# https://github.com/fractal-analytics-platform/fractal-server/issues/1742
for ind, user in enumerate(user_list):
user_list[ind] = dict(
user.model_dump(),
Expand Down
9 changes: 9 additions & 0 deletions fractal_server/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"UserRead",
"UserUpdate",
"UserCreate",
"UserUpdateWithNewGroupIds",
)


Expand Down Expand Up @@ -102,6 +103,14 @@ class UserUpdateStrict(BaseModel, extra=Extra.forbid):
)


class UserUpdateWithNewGroupIds(UserUpdate):
new_group_ids: Optional[list[int]] = None

_val_unique = validator("new_group_ids", allow_reuse=True)(
val_unique_list("new_group_ids")
)


class UserCreate(schemas.BaseUserCreate):
"""
Schema for `User` creation.
Expand Down
8 changes: 8 additions & 0 deletions fractal_server/app/schemas/user_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
from pydantic import BaseModel
from pydantic import Extra
from pydantic import Field
from pydantic import validator

from ._validators import val_unique_list


__all__ = (
"UserGroupRead",
Expand Down Expand Up @@ -55,3 +59,7 @@ class UserGroupUpdate(BaseModel, extra=Extra.forbid):
"""

new_user_ids: list[int] = Field(default_factory=list)

_val_unique = validator("new_user_ids", allow_reuse=True)(
val_unique_list("new_user_ids")
)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fractal-server"
version = "2.4.0a0"
version = "2.4.0a2"
description = "Server component of the Fractal analytics platform"
authors = [
"Tommaso Comparin <tommaso.comparin@exact-lab.it>",
Expand Down Expand Up @@ -92,7 +92,7 @@ filterwarnings = [
]

[tool.bumpver]
current_version = "2.4.0a0"
current_version = "2.4.0a2"
version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"
commit_message = "bump version {old_version} -> {new_version}"
commit = true
Expand Down
Loading

0 comments on commit b1acbef

Please sign in to comment.