Skip to content
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
707d73f
adds new role
pcrespov Sep 4, 2025
47199cc
migration
pcrespov Sep 4, 2025
b320c11
support role
pcrespov Sep 4, 2025
cae5218
account_request_review_by returns a username instead of a user_id
pcrespov Sep 4, 2025
0d3e5e0
fixes tests
pcrespov Sep 4, 2025
caf6862
fixes mypy
pcrespov Sep 4, 2025
16333b8
disable tracing to remove warnings
pcrespov Sep 5, 2025
33366c3
fixe tsts
pcrespov Sep 5, 2025
3bed221
update OAS
pcrespov Sep 5, 2025
d7eeaac
services/webserver api version: 0.77.0 → 0.77.1
pcrespov Sep 5, 2025
d5e3899
services/api-server version: 0.13.0 → 0.13.1
pcrespov Sep 5, 2025
fbc4df0
minor
pcrespov Sep 5, 2025
1de64dc
Drafts group_or_role_permission_required
pcrespov Sep 8, 2025
64a4c76
checks whether user belongs to support group
pcrespov Sep 8, 2025
2b97c76
undo supoprt role
pcrespov Sep 8, 2025
561123c
refactor product-support logic to product and group plugins
pcrespov Sep 8, 2025
323db55
restorce version increase
pcrespov Sep 8, 2025
31e8ffc
updates OAS
pcrespov Sep 8, 2025
a3bde6a
services/webserver api version: 0.77.0 → 0.77.1
pcrespov Sep 8, 2025
3d7e588
search by email, gid or uname
pcrespov Sep 8, 2025
7f511c4
simplify
pcrespov Sep 8, 2025
d552085
moved fixture
pcrespov Sep 8, 2025
3e75fcc
adds check on permissions
pcrespov Sep 8, 2025
7d40a2d
moves logic to policy
pcrespov Sep 8, 2025
c06625f
splits decorator from actual function
pcrespov Sep 8, 2025
2401f26
doc
pcrespov Sep 8, 2025
0e327fc
pylint
pcrespov Sep 8, 2025
55532ed
fixes cyclic import
pcrespov Sep 8, 2025
de45e3e
fixes tests
pcrespov Sep 8, 2025
31ba967
doc
pcrespov Sep 8, 2025
01b9e20
adds username
pcrespov Sep 8, 2025
c9442dc
fixes model conversion
pcrespov Sep 8, 2025
0c950f3
@sanderegg review: rename filters
pcrespov Sep 8, 2025
2073214
fixes mocks
pcrespov Sep 8, 2025
2004c66
connects other search options
pcrespov Sep 8, 2025
9fa51cb
updates OAS
pcrespov Sep 8, 2025
6b9c3c5
services/webserver api version: 0.77.1 → 0.77.2
pcrespov Sep 8, 2025
fa404d3
fixes mocks
pcrespov Sep 9, 2025
feef84d
@bisgaard-itis review: validator fields
pcrespov Sep 9, 2025
6a2f224
Merge branch 'master' into is340/support-center-new-role
pcrespov Sep 9, 2025
6baa179
Merge branch 'master' into is340/support-center-new-role
pcrespov Sep 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re
from datetime import date, datetime
from enum import Enum
from typing import Annotated, Any, Literal, Self
from typing import Annotated, Any, Literal, Self, TypeAlias

import annotated_types
from common_library.basic_types import DEFAULT_FACTORY
Expand All @@ -18,6 +18,7 @@
StringConstraints,
ValidationInfo,
field_validator,
model_validator,
)
from pydantic.config import JsonDict

Expand Down Expand Up @@ -83,7 +84,14 @@ class MyProfileRestGet(OutputSchemaWithoutCamelCase):
login: LowerCaseEmailStr
phone: str | None = None

role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"]
role: Literal[
"ANONYMOUS",
"GUEST",
"USER",
"TESTER",
"PRODUCT_OWNER",
"ADMIN",
]
groups: MyGroupsGet | None = None
gravatar_id: Annotated[str | None, Field(deprecated=True)] = None

Expand Down Expand Up @@ -306,15 +314,40 @@ class UserAccountReject(InputSchema):
email: EmailStr


GlobString: TypeAlias = Annotated[
str,
StringConstraints(
min_length=3, max_length=200, strip_whitespace=True, pattern=r"^[^%]*$"
),
]


class UserAccountSearchQueryParams(RequestParameters):
email: Annotated[
str,
GlobString | None,
Field(
min_length=3,
max_length=200,
description="complete or glob pattern for an email",
),
]
] = None
primary_group_id: Annotated[
GroupID | None,
Field(
description="Filter by primary group ID",
),
] = None
user_name: Annotated[
GlobString | None,
Field(
description="complete or glob pattern for a username",
),
] = None

@model_validator(mode="after")
def _validate_at_least_one_filter(self) -> Self:
if not any([self.email, self.primary_group_id, self.user_name]):
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe you can generate this list from self.model_fields or something like that, so it is more future proof?

msg = "At least one filter (email, primary_group_id, or user_name) must be provided"
raise ValueError(msg)
return self


class UserAccountGet(OutputSchema):
Expand All @@ -340,9 +373,9 @@ class UserAccountGet(OutputSchema):
# pre-registration NOTE: that some users have no pre-registartion and therefore all options here can be none
pre_registration_id: int | None
pre_registration_created: datetime | None
invited_by: str | None = None
invited_by: UserNameID | None = None
account_request_status: AccountRequestStatus | None
account_request_reviewed_by: UserID | None = None
account_request_reviewed_by: UserNameID | None = None
account_request_reviewed_at: datetime | None = None

# user status
Expand Down
6 changes: 3 additions & 3 deletions packages/models-library/src/models_library/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from typing import Annotated, TypeAlias

from common_library.users_enums import UserRole
from models_library.basic_types import IDStr
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, StringConstraints
from pydantic.config import JsonDict
from typing_extensions import ( # https://docs.pydantic.dev/latest/api/standard_library_types/#typeddict
Expand All @@ -12,8 +11,9 @@
from .emails import LowerCaseEmailStr

UserID: TypeAlias = PositiveInt
UserNameID: TypeAlias = IDStr

UserNameID: TypeAlias = Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)
]

FirstNameStr: TypeAlias = Annotated[
str, StringConstraints(strip_whitespace=True, max_length=255)
Expand Down
4 changes: 2 additions & 2 deletions services/api-server/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ commit_args = --no-verify
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts = --strict-markers
markers =
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows."
testit: "marks test to run during development"

[mypy]
plugins =
plugins =
pydantic.mypy
sqlalchemy.ext.mypy.plugin
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ class ProfileCommon(BaseModel):
last_name: LastNameStr | None = Field(None, examples=["Maxwell"])


class ProfileUpdate(ProfileCommon):
...
class ProfileUpdate(ProfileCommon): ...


class UserRoleEnum(StrAutoEnum):
Expand Down
2 changes: 1 addition & 1 deletion services/web/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.77.0
0.77.2
6 changes: 3 additions & 3 deletions services/web/server/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.77.0
current_version = 0.77.2
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
Expand All @@ -13,13 +13,13 @@ commit_args = --no-verify
addopts = --strict-markers
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
markers =
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows."
testit: "marks test to run during development"
heavy_load: "mark tests that require large amount of data"

[mypy]
plugins =
plugins =
pydantic.mypy
sqlalchemy.ext.mypy.plugin
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
openapi: 3.1.0
info:
title: simcore-service-webserver
description: Main service with an interface (http-API & websockets) to the web front-end
version: 0.77.0
version: 0.77.2
servers:
- url: ''
description: webserver
Expand Down Expand Up @@ -1798,12 +1798,36 @@
parameters:
- name: email
in: query
required: true
required: false
schema:
type: string
minLength: 3
maxLength: 200
anyOf:
- type: string
minLength: 3
maxLength: 200
pattern: ^[^%]*$
- type: 'null'
title: Email
- name: primary_group_id
in: query
required: false
schema:
anyOf:
- type: integer
exclusiveMinimum: true
minimum: 0
- type: 'null'
title: Primary Group Id
- name: user_name
in: query
required: false
schema:
anyOf:
- type: string
minLength: 3
maxLength: 200
pattern: ^[^%]*$
- type: 'null'
title: User Name
responses:
'200':
description: Successful Response
Expand Down Expand Up @@ -18361,6 +18385,8 @@
invitedBy:
anyOf:
- type: string
maxLength: 100
minLength: 1
- type: 'null'
title: Invitedby
accountRequestStatus:
Expand All @@ -18369,9 +18395,9 @@
- type: 'null'
accountRequestReviewedBy:
anyOf:
- type: integer
exclusiveMinimum: true
minimum: 0
- type: string
maxLength: 100
minLength: 1
- type: 'null'
title: Accountrequestreviewedby
accountRequestReviewedAt:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from aiohttp import web
from common_library.groups_enums import GroupType
from common_library.users_enums import UserRole
from models_library.basic_types import IDStr
from models_library.groups import (
AccessRightsDict,
Group,
Expand All @@ -17,7 +16,7 @@
StandardGroupCreate,
StandardGroupUpdate,
)
from models_library.users import UserID
from models_library.users import UserID, UserNameID
from simcore_postgres_database.aiopg_errors import UniqueViolation
from simcore_postgres_database.models.users import users
from simcore_postgres_database.utils_products import get_or_create_product_group
Expand Down Expand Up @@ -732,14 +731,30 @@ async def is_user_by_email_in_group(
return user_id is not None


async def is_user_in_group(
app: web.Application,
connection: AsyncConnection | None = None,
*,
user_id: UserID,
group_id: GroupID,
) -> bool:
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
result = await conn.scalar(
sa.select(user_to_groups.c.uid).where(
(user_to_groups.c.uid == user_id) & (user_to_groups.c.gid == group_id)
)
)
return result is not None


async def add_new_user_in_group(
app: web.Application,
connection: AsyncConnection | None = None,
*,
group_id: GroupID,
# either user_id or user_name
new_user_id: UserID | None = None,
new_user_name: IDStr | None = None,
new_user_name: UserNameID | None = None,
access_rights: AccessRightsDict | None = None,
) -> None:
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from contextlib import suppress

from aiohttp import web
from models_library.basic_types import IDStr
from models_library.emails import LowerCaseEmailStr
from models_library.groups import (
AccessRightsDict,
Expand All @@ -13,7 +12,7 @@
StandardGroupUpdate,
)
from models_library.products import ProductName
from models_library.users import UserID
from models_library.users import UserID, UserNameID
from pydantic import EmailStr

from ..products.models import Product
Expand Down Expand Up @@ -262,6 +261,14 @@ async def is_user_by_email_in_group(
)


async def is_user_in_group(
app: web.Application, *, user_id: UserID, group_id: GroupID
) -> bool:
return await _groups_repository.is_user_in_group(
app, user_id=user_id, group_id=group_id
)


async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None:
user: dict = await users_service.get_user(app, user_id)
return await _groups_repository.auto_add_user_to_groups(app, user=user)
Expand All @@ -288,7 +295,7 @@ async def add_user_in_group(
*,
# identifies
new_by_user_id: UserID | None = None,
new_by_user_name: IDStr | None = None,
new_by_user_name: UserNameID | None = None,
new_by_user_email: EmailStr | None = None,
access_rights: AccessRightsDict | None = None,
) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
get_product_group_for_user,
get_user_profile_groups,
is_user_by_email_in_group,
is_user_in_group,
list_all_user_groups_ids,
list_group_members,
list_user_groups_ids_with_read_access,
Expand All @@ -23,6 +24,7 @@
"get_product_group_for_user",
"get_user_profile_groups",
"is_user_by_email_in_group",
"is_user_in_group",
"list_all_user_groups_ids",
"list_group_members",
"list_user_groups_ids_with_read_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@

from ..._meta import API_VTAG as VTAG
from ...login.decorators import login_required
from ...security.decorators import permission_required
from ...security.decorators import (
group_or_role_permission_required,
permission_required,
)
from ...utils_aiohttp import envelope_json_response
from .. import _service, products_web
from .._repository import ProductRepository
Expand Down Expand Up @@ -46,7 +49,7 @@ async def _get_current_product_price(request: web.Request):

@routes.get(f"/{VTAG}/products/{{product_name}}", name="get_product")
@login_required
@permission_required("product.details.*")
@group_or_role_permission_required("product.details.*")
@handle_rest_requests_exceptions
async def _get_product(request: web.Request):
req_ctx = ProductsRequestContext.model_validate(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import aiofiles
from aiohttp import web
from models_library.products import ProductName
from models_library.users import UserID
from simcore_postgres_database.utils_products_prices import ProductPriceInfo

from .._resources import webserver_resources
from ..constants import RQ_PRODUCT_KEY
from ..groups import api as groups_service
from . import _service
from ._web_events import APP_PRODUCTS_TEMPLATES_DIR_KEY
from .errors import (
Expand Down Expand Up @@ -38,6 +40,22 @@ def get_current_product(request: web.Request) -> Product:
return current_product


async def is_user_in_product_support_group(
request: web.Request, *, user_id: UserID
) -> bool:
"""Checks if the user belongs to the support group of the given product.
If the product does not have a support group, returns False.
"""
product = get_current_product(request)
if product.support_standard_group_id is None:
return False
return await groups_service.is_user_in_group(
app=request.app,
user_id=user_id,
group_id=product.support_standard_group_id,
)


def _get_current_product_or_none(request: web.Request) -> Product | None:
with contextlib.suppress(ProductNotFoundError, UnknownProductError):
product: Product = get_current_product(request)
Expand Down
Loading
Loading