Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 13 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,19 @@ or from https://gitmoji.dev/

## What do these changes do?

<!-- Badge to openapi specs
[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=HERE-URL-TO-RAW-FILE)
<!-- Badge to openapi specs (use for API changes)
Comment thread
pcrespov marked this conversation as resolved.
URL structure: https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/[GITHUB_USERNAME]/[REPO]/[COMMIT_HASH_OR_BRANCH]/[PATH_TO_FILE]#tag/[TAG_NAME]/operation/[OPERATION_ID]

Components:
- GITHUB_USERNAME: Your GitHub username (or 'ITISFoundation' if pushing directly)
- REPO: Repository name (osparc-simcore)
- COMMIT_HASH_OR_BRANCH: Full commit SHA or branch name (ensure file exists at this ref)
- PATH_TO_FILE: Path from repo root to openapi.json file
- TAG_NAME (optional): OpenAPI tag to filter by (e.g., 'admin')
- OPERATION_ID (optional): Specific operation to highlight (e.g., 'list_users_accounts')

Example with fragment pointing to specific operation:
[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/pcrespov/osparc-simcore/b75927d3b3b90638a9b825cd17b1e1f17176a5ef/services/web/server/src/simcore_service_webserver/api/v0/openapi.json#tag/admin/operation/list_users_accounts)
-->


Expand Down
9 changes: 6 additions & 3 deletions .github/instructions/python-tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ This is a multi-project monorepo with two main groups of projects in the folders
### File size

- If a test file exceeds 1000 lines, split it into multiple files using descriptive name suffixes (e.g. `test_users_accounts_rest_registration.py` → `test_users_accounts_rest_registration_create.py`, `test_users_accounts_rest_registration_delete.py`, etc.).
- If the split files share fixtures, move shared fixtures to a `conftest.py`. If the number of files warrants it, group them into a subfolder with its own `conftest.py` (e.g. `test_users_accounts_rest_registration/test_*.py` + `test_users_accounts_rest_registration/conftest.py`).
```
- If the split files share fixtures, move shared fixtures to a `conftest.py`. If the number of files warrants it, group them into a subfolder with its own `conftest.py`.
Comment thread
pcrespov marked this conversation as resolved.
- **Test file names must be unique across the entire project test tree**, regardless of which subfolder they live in. Generic names like `test_list.py`, `test_search.py`, or `test_create.py` are forbidden since they can easily collide with unrelated test files elsewhere. Always keep the full original filename as a prefix for the filename, even inside a dedicated subfolder. **Folder names should be a short version of the original test filename, without the `test_` prefix**:
- ✅ `users_accounts_rest_registration/test_users_accounts_rest_registration_search.py`
- ❌ `test_users_accounts_rest_registration/test_users_accounts_rest_registration_search.py`
- ❌ `users_accounts/test_search.py`

## How to Run Tests

See the [`run-python-tests`](../skills/run-python-tests/SKILL.md) skill for the full step-by-step procedure.

---
*Last updated: 2026-03-23*
*Last updated: 2026-04-15*
3 changes: 3 additions & 0 deletions .github/skills/postgres-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,6 @@ This skill **completes** with the generation and validation of the migration scr
| Migration file not generated | Check the logs from `sc-pg review` for errors; verify model changes were saved to disk |
| Migration missing some changes | Manually edit the migration file (Python code) to add missing operations (see Step 5) |
| Adminer URL not accessible | Check Docker logs with `docker logs <postgres_container_id>` or look at `make setup-commit` output |

---
*Last updated: 2026-03-10*
2 changes: 1 addition & 1 deletion .github/skills/run-python-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: run-python-tests
description: 'Run Python tests for any service or package in this monorepo. Use when: running pytest, executing unit tests, running integration tests, test failures, make install-dev, test setup, installing test dependencies.'
description: 'Run Python tests and static analysis for any service or package in this monorepo. Use when: running pytest, executing unit tests, running integration tests, test failures, make install-dev, test setup, installing test dependencies, linting with pylint, and type checking with mypy.'
Comment thread
pcrespov marked this conversation as resolved.
---

# Run Python Tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ class UsersForAdminListFilter(Filters):
# CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED
#
review_status: Literal["PENDING", "REVIEWED"] | None = None
registered: bool | None = None
product_name: ProductName | None = None

model_config = ConfigDict(extra="forbid")
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.81.1
0.82.0
2 changes: 1 addition & 1 deletion services/web/server/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.81.1
current_version = 0.82.0
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "simcore-service-webserver",
"description": "Main service with an interface (http-API & websockets) to the web front-end",
"version": "0.81.1"
"version": "0.82.0"
},
"servers": [
{
Expand Down Expand Up @@ -2710,6 +2710,22 @@
"title": "Review Status"
}
},
{
"name": "registered",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Registered"
}
},
{
"name": "product_name",
"in": "query",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ async def list_merged_pre_and_registered_users(
*,
product_name: ProductName,
filter_any_account_request_status: list[AccountRequestStatus] | None = None,
filter_registered: bool | None = None,
filter_include_deleted: bool = False,
pagination_limit: int = 50,
pagination_offset: int = 0,
Expand All @@ -563,6 +564,8 @@ async def list_merged_pre_and_registered_users(
product_name: Product name to filter by
filter_any_account_request_status: If provided, only returns users with account request status in this list
(only pre-registered users with any of these statuses will be included)
filter_registered: If provided, filters records by registration completion
(True => linked to a user, False => not linked)
filter_include_deleted: Whether to include deleted users
pagination_limit: Maximum number of results to return
pagination_offset: Number of results to skip (for pagination)
Expand Down Expand Up @@ -667,25 +670,34 @@ async def list_merged_pre_and_registered_users(
merged_query: sa.sql.Select | sa.sql.CompoundSelect
merged_query = pre_reg_query if filter_any_account_request_status else pre_reg_query.union_all(users_query)

# Add distinct on email to eliminate duplicates
# Apply optional registration linkage filter on the merged view before pagination/count
merged_query_subq = merged_query.subquery()
filtered_query = sa.select(merged_query_subq).select_from(merged_query_subq)
if filter_registered is True:
filtered_query = filtered_query.where(merged_query_subq.c.user_id.is_not(None))
elif filter_registered is False:
filtered_query = filtered_query.where(merged_query_subq.c.user_id.is_(None))

filtered_query_subq = filtered_query.subquery()

# Add distinct on email to eliminate duplicates
distinct_query = (
sa.select(merged_query_subq)
.select_from(merged_query_subq)
.distinct(merged_query_subq.c.email)
sa.select(filtered_query_subq)
.select_from(filtered_query_subq)
.distinct(filtered_query_subq.c.email)
.order_by(
merged_query_subq.c.email,
filtered_query_subq.c.email,
# Prioritize pre-registration records if duplicate emails exist
merged_query_subq.c.is_pre_registered.desc(),
merged_query_subq.c.created.desc(),
filtered_query_subq.c.is_pre_registered.desc(),
filtered_query_subq.c.created.desc(),
)
.limit(pagination_limit)
.offset(pagination_offset)
)

# Count query (for pagination)
count_query = sa.select(sa.func.count().label("total")).select_from(
sa.select(merged_query_subq.c.email).select_from(merged_query_subq).distinct().subquery()
sa.select(filtered_query_subq.c.email).select_from(filtered_query_subq).distinct().subquery()
)

async with pass_or_acquire_connection(engine, connection) as conn:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from ..products import products_service
from ..products.errors import ProductNotFoundError
from . import _accounts_repository, _users_repository
from ._models import PreviewApproval
from ._models import PreviewApproval, PreviewRejection
from .exceptions import (
AlreadyPreRegisteredError,
PendingPreRegistrationNotFoundError,
Expand Down Expand Up @@ -122,6 +122,10 @@ async def list_user_accounts(
list[AccountRequestStatus] | None,
doc("List of any account request statuses to filter by"),
] = None,
filter_registered: Annotated[
bool | None,
doc("Filters by registration completion status"),
] = None,
pagination_limit: int = 50,
pagination_offset: int = 0,
) -> Annotated[
Expand All @@ -141,6 +145,7 @@ async def list_user_accounts(
engine,
product_name=product_name,
filter_any_account_request_status=filter_any_account_request_status,
filter_registered=filter_registered,
pagination_limit=pagination_limit,
pagination_offset=pagination_offset,
)
Expand Down Expand Up @@ -430,3 +435,47 @@ async def preview_approval_user_account(
invitation_url=invitation_url,
message_content=preview.message_content,
)


async def preview_rejection_user_account(
app: web.Application,
*,
rejection_email: str,
product_name: ProductName,
) -> PreviewRejection:
"""Preview the rejection notification for a user account.

Retrieves user pre-registration data and generates a preview of the
account_rejected email template.

Raises:
PendingPreRegistrationNotFoundError: If no pre-registration is found for the email/product
"""
found = await search_users_accounts(
app,
filter_by_email_glob=rejection_email,
product_name=product_name,
include_products=False,
)

if not found:
raise PendingPreRegistrationNotFoundError(email=rejection_email, product_name=product_name)

user_account = found[0]
assert user_account.email == rejection_email # nosec

preview = await notifications_service.preview_template(
app=app,
product_name=product_name,
ref=TemplateRef(
channel=Channel.email,
template_name="account_rejected",
),
context={
"user": {
"first_name": user_account.first_name,
},
},
)

return PreviewRejection(message_content=preview.message_content)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from common_library.user_messages import user_message
from common_library.users_enums import AccountRequestStatus
from models_library.api_schemas_invitations.invitations import ApiInvitationInputs
from models_library.api_schemas_webserver.notifications import MessageContentGet
from models_library.api_schemas_webserver.users import (
UserAccountApprove,
UserAccountGet,
Expand All @@ -19,10 +18,8 @@
UserAccountSearchQueryParams,
UsersAccountListQueryParams,
)
from models_library.notifications import Channel
from models_library.rest_pagination import Page
from models_library.rest_pagination_utils import paginate_data
from pydantic import TypeAdapter
from servicelib.aiohttp import status
from servicelib.aiohttp.requests_validation import (
parse_request_body_as,
Expand All @@ -34,8 +31,6 @@
from ...._meta import API_VTAG
from ....invitations import api as invitations_service
from ....login.decorators import login_required
from ....notifications import notifications_service
from ....notifications._models import TemplateRef
from ....products import products_service
from ....products.errors import ProductNotFoundError
from ....security.decorators import (
Expand Down Expand Up @@ -97,6 +92,7 @@ async def list_users_accounts(request: web.Request) -> web.Response:
request.app,
product_name=target_product_name,
filter_any_account_request_status=filter_any_account_request_status,
filter_registered=query_params.registered,
pagination_limit=query_params.limit,
pagination_offset=query_params.offset,
)
Expand Down Expand Up @@ -329,31 +325,13 @@ async def preview_rejection_user_account(request: web.Request) -> web.Response:
assert req_ctx.product_name # nosec

rejection_data = await parse_request_body_as(UserAccountPreviewRejection, request)
found = await _accounts_service.search_users_accounts(
request.app,
filter_by_email_glob=rejection_data.email,
product_name=req_ctx.product_name,
include_products=False,
)
user_account = found[0]
assert user_account.email == rejection_data.email # nosec

preview = await notifications_service.preview_template(
app=request.app,
preview_result = await _accounts_service.preview_rejection_user_account(
request.app,
rejection_email=rejection_data.email,
product_name=req_ctx.product_name,
ref=TemplateRef(
channel=Channel.email,
template_name="account_rejected",
),
context={
"user": {
"first_name": user_account.first_name,
},
},
)

response = UserAccountPreviewRejectionGet(
message_content=TypeAdapter(MessageContentGet).validate_python(preview.message_content),
)
response = UserAccountPreviewRejectionGet(**preview_result.model_dump())

return envelope_json_response(response.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY))
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,7 @@ class UserCredentialsTuple(NamedTuple):
class PreviewApproval(BaseModel):
invitation_url: str
message_content: dict[str, Any]


class PreviewRejection(BaseModel):
message_content: dict[str, Any]
Loading
Loading