Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9f99d4a
rename as profile
pcrespov Oct 20, 2025
3736675
@matusdrobuliak66 case-insensitive email search
pcrespov Oct 20, 2025
7839c40
Enh InvitationContent and tests consecutive codes
pcrespov Oct 20, 2025
9cae515
new OrderingQueryParams
pcrespov Oct 20, 2025
58284ea
list operations sorting
pcrespov Oct 20, 2025
8183919
Refactor ordering query parsing and enhance validation error handling
pcrespov Oct 20, 2025
3e86f7a
Enhance user account query parameters with ordering support and add u…
pcrespov Oct 20, 2025
17857ba
Add mapping and validation functions for order fields with literals
pcrespov Oct 20, 2025
9329cfb
minor
pcrespov Oct 20, 2025
02b6e20
fixing oas
pcrespov Oct 21, 2025
9802715
Add ordering support to list_merged_pre_and_registered_users and rela…
pcrespov Oct 21, 2025
1dad1b3
Enhance list_merged_pre_and_registered_users with detailed parameter …
pcrespov Oct 21, 2025
bd49d9e
Add limit parameter to search_merged_pre_and_registered_users to prev…
pcrespov Oct 21, 2025
bec4870
Refactor list_merged_pre_and_registered_users to use MergedUserData t…
pcrespov Oct 21, 2025
a47d141
Refactor user account functions to enhance parameter documentation an…
pcrespov Oct 21, 2025
8231658
Refactor check_ordering_list to enhance parameter documentation and t…
pcrespov Oct 21, 2025
a9c5cad
Refactor user pre-registration functions to improve parameter documen…
pcrespov Oct 21, 2025
5a93bc9
updates doc rules
pcrespov Oct 21, 2025
513de41
mionr
pcrespov Oct 21, 2025
9f41455
fixes mypy after merge
pcrespov Oct 21, 2025
f2162bd
drop Generic
pcrespov Oct 21, 2025
de47d01
adds link to Review Users section provided in https://github.com/ITIS…
pcrespov Oct 21, 2025
edc2260
fix: ensure host URLs are properly formatted by stripping trailing sl…
pcrespov Oct 21, 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
57 changes: 46 additions & 11 deletions .github/instructions/python.instructions.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,73 @@
---
applyTo: '**/*.py'
---
Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.

## 🛠️Coding Instructions for Python in This Repository

Follow these rules **strictly** when generating Python code:

### 1. Python Version
### Python Version

* Use Python 3.13: Ensure all code uses features and syntax compatible with Python 3.13.

### 2. **Type Annotations**
### Type Annotations

* Always use full type annotations for all functions and class attributes.
* ❗ **Exception**: Do **not** add return type annotations in `test_*` functions.

### 3. **Code Style & Formatting**
### Documentation with Annotated Types

* Use `annotated_types.doc()` for parameter and return type documentation instead of traditional docstring Args/Returns sections
* **Apply documentation only for non-obvious parameters/returns**:
- Document complex behaviors that can't be deduced from parameter name and type
- Document validation rules, side effects, or special handling
- Skip documentation for self-explanatory parameters (e.g., `engine: AsyncEngine`, `product_name: ProductName`)
* **Import**: Always add `from annotated_types import doc` when using documentation annotations

**Examples:**
```python
from typing import Annotated
from annotated_types import doc

async def process_users(
engine: AsyncEngine, # No doc needed - self-explanatory
filter_statuses: Annotated[
list[Status] | None,
doc("Only returns users with these statuses")
] = None,
limit: int = 50, # No doc needed - obvious
) -> Annotated[
tuple[list[dict], int],
doc("(user records, total count)")
]:
"""Process users with filtering.

Raises:
ValueError: If no filters provided
"""
```

* **Docstring conventions**:
- Keep docstrings **concise**, focusing on overall function purpose
- Include `Raises:` section for exceptions
- Avoid repeating information already captured in type annotations
- Most information should be deducible from function name, parameter names, types, and annotations

### Code Style & Formatting

* Follow [Python Coding Conventions](../../docs/coding-conventions.md) **strictly**.
* Format code with `black` and `ruff`.
* Lint code with `ruff` and `pylint`.

### 4. **Library Compatibility**
### Library Compatibility

Ensure compatibility with the following library versions:

* `sqlalchemy` ≥ 2.x
* `pydantic` ≥ 2.x
* `fastapi` ≥ 0.100


### 5. **Code Practices**
### Code Practices

* Use `f-string` formatting for all string interpolation except for logging message strings.
* Use **relative imports** within the same package/module.
Expand All @@ -40,13 +76,12 @@ Ensure compatibility with the following library versions:
* Place **all imports at the top** of the file.
* Document functions when the code is not self-explanatory or if asked explicitly.


### 6. **JSON Serialization**
### JSON Serialization

* Prefer `json_dumps` / `json_loads` from `common_library.json_serialization` instead of the built-in `json.dumps` / `json.loads`.
* When using Pydantic models, prefer methods like `model.model_dump_json()` for serialization.

### 7. **aiohttp Framework**
### aiohttp Framework

* **Application Keys**: Always use `web.AppKey` for type-safe application storage instead of string keys
- Define keys with specific types: `APP_MY_KEY: Final = web.AppKey("APP_MY_KEY", MySpecificType)`
Expand All @@ -58,6 +93,6 @@ Ensure compatibility with the following library versions:
* **Error Handling**: Use the established exception handling decorators and patterns
* **Route Definitions**: Use `web.RouteTableDef()` and organize routes logically within modules

### 8. **Running tests**
### Running tests
* Use `--keep-docker-up` flag when testing to keep docker containers up between sessions.
* Always activate the python virtual environment before running pytest.
2 changes: 1 addition & 1 deletion api/specs/web-server/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def as_query(model_class: type[BaseModel]) -> type[BaseModel]:
for field_name, field_info in model_class.model_fields.items():

field_default = field_info.default
assert not field_info.default_factory # nosec
assert not field_info.default_factory, f"got {field_info=}" # nosec
query_kwargs = {
"alias": field_info.alias,
"title": field_info.title,
Expand Down
21 changes: 17 additions & 4 deletions api/specs/web-server/_users_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from enum import Enum
from typing import Annotated

from _common import as_query
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status
from models_library.api_schemas_webserver.users import (
PageQueryParameters,
UserAccountApprove,
UserAccountGet,
UserAccountReject,
UserAccountSearchQueryParams,
UsersAccountListQueryParams,
UsersForAdminListFilter,
)
from models_library.generics import Envelope
from models_library.rest_pagination import Page
Expand All @@ -26,13 +26,26 @@
_extra_tags: list[str | Enum] = ["admin"]


# NOTE: I still do not have a clean solution for this
#
class _Q(UsersForAdminListFilter, PageQueryParameters): ...


@router.get(
"/admin/user-accounts",
response_model=Page[UserAccountGet],
tags=_extra_tags,
)
async def list_users_accounts(
_query: Annotated[as_query(UsersAccountListQueryParams), Depends()],
_query: Annotated[_Q, Depends()],
order_by: Annotated[
str,
Query(
title="Order By",
description="Comma-separated list of fields for ordering (prefix with '-' for descending).",
example="-created_at,name",
),
] = "",
): ...


Expand Down
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 @@ -27,6 +27,7 @@
from ..groups import AccessRightsDict, Group, GroupID, GroupsByTypeTuple, PrimaryGroupID
from ..products import ProductName
from ..rest_base import RequestParameters
from ..rest_ordering import OrderingQueryParams
from ..string_types import (
GlobPatternSafeStr,
SearchPatternSafeStr,
Expand Down Expand Up @@ -317,7 +318,14 @@ class UsersForAdminListFilter(Filters):
model_config = ConfigDict(extra="forbid")


class UsersAccountListQueryParams(UsersForAdminListFilter, PageQueryParameters): ...
UserAccountOrderFields: TypeAlias = Literal["email", "created_at"]


class UsersAccountListQueryParams(
UsersForAdminListFilter,
PageQueryParameters,
OrderingQueryParams[UserAccountOrderFields],
): ...


class _InvitationDetails(InputSchema):
Expand All @@ -338,7 +346,7 @@ class UserAccountSearchQueryParams(RequestParameters):
email: Annotated[
GlobPatternSafeStr | None,
Field(
description="complete or glob pattern for an email",
description="complete or glob pattern for an email (case insensitive)",
),
] = None
primary_group_id: Annotated[
Expand All @@ -350,7 +358,7 @@ class UserAccountSearchQueryParams(RequestParameters):
user_name: Annotated[
GlobPatternSafeStr | None,
Field(
description="complete or glob pattern for a username",
description="complete or glob pattern for a username (case insensitive)",
),
] = None

Expand Down
76 changes: 47 additions & 29 deletions packages/models-library/src/models_library/invitations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from datetime import datetime, timezone
from typing import Final
from datetime import UTC, datetime
from typing import Annotated, Final

from pydantic import BaseModel, EmailStr, Field, PositiveInt, field_validator
from pydantic import (
AfterValidator,
BaseModel,
EmailStr,
Field,
PositiveInt,
field_validator,
)

from .products import ProductName

Expand All @@ -11,29 +18,40 @@
class InvitationInputs(BaseModel):
"""Input data necessary to create an invitation"""

issuer: str = Field(
...,
description="Identifies who issued the invitation. E.g. an email, a service name etc. NOTE: it will be trimmed if exceeds maximum",
min_length=1,
max_length=_MAX_LEN,
)
guest: EmailStr = Field(
...,
description="Invitee's email. Note that the registration can ONLY be used with this email",
)
trial_account_days: PositiveInt | None = Field(
default=None,
description="If set, this invitation will activate a trial account."
"Sets the number of days from creation until the account expires",
)
extra_credits_in_usd: PositiveInt | None = Field(
default=None,
description="If set, the account's primary wallet will add extra credits corresponding to this ammount in USD",
)
product: ProductName | None = Field(
default=None,
description="If None, it will use INVITATIONS_DEFAULT_PRODUCT",
)
issuer: Annotated[
str,
Field(
description="Identifies who issued the invitation. E.g. an email, a service name etc. NOTE: it will be trimmed if exceeds maximum",
min_length=1,
max_length=_MAX_LEN,
),
]
guest: Annotated[
EmailStr,
AfterValidator(lambda v: v.lower()),
Field(
description="Invitee's email. Note that the registration can ONLY be used with this email",
),
]
trial_account_days: Annotated[
PositiveInt | None,
Field(
description="If set, this invitation will activate a trial account."
"Sets the number of days from creation until the account expires",
),
] = None
extra_credits_in_usd: Annotated[
PositiveInt | None,
Field(
description="If set, the account's primary wallet will add extra credits corresponding to this ammount in USD",
),
] = None
product: Annotated[
ProductName | None,
Field(
description="If None, it will use INVITATIONS_DEFAULT_PRODUCT",
),
] = None

@field_validator("issuer", mode="before")
@classmethod
Expand All @@ -44,10 +62,10 @@ def trim_long_issuers_to_max_length(cls, v):


class InvitationContent(InvitationInputs):
"""Data in an invitation"""
"""Data within an invitation"""

# avoid using default to mark exactly the time
created: datetime = Field(..., description="Timestamp for creation")
created: Annotated[datetime, Field(description="Timestamp for creation")]

def as_invitation_inputs(self) -> InvitationInputs:
return self.model_validate(
Expand All @@ -62,6 +80,6 @@ def create_from_inputs(
kwargs = invitation_inputs.model_dump(exclude_none=True)
kwargs.setdefault("product", default_product)
return cls(
created=datetime.now(tz=timezone.utc),
created=datetime.now(tz=UTC),
**kwargs,
)
70 changes: 70 additions & 0 deletions packages/models-library/src/models_library/list_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""List operation models and helpers

- Ordering: https://google.aip.dev/132#ordering


"""

from enum import Enum
from typing import TYPE_CHECKING, Annotated, Generic, TypeVar

from annotated_types import doc
from pydantic import BaseModel


class OrderDirection(str, Enum):
ASC = "asc"
DESC = "desc"


if TYPE_CHECKING:
from typing import Protocol

class LiteralField(Protocol):
"""Protocol for Literal string types"""

def __str__(self) -> str: ...

TField = TypeVar("TField", bound=LiteralField)
else:
TField = TypeVar("TField", bound=str)


class OrderClause(BaseModel, Generic[TField]):
field: TField
direction: OrderDirection = OrderDirection.ASC


def check_ordering_list(
order_by: Annotated[
list[tuple[TField, OrderDirection]],
doc(
"Duplicates with same direction dropped, conflicting directions raise ValueError"
),
],
) -> Annotated[
list[tuple[TField, OrderDirection]],
doc("Duplicates removed, preserving first occurrence order"),
]:
"""Validates ordering list and removes duplicate entries.

Raises:
ValueError: If a field appears with conflicting directions
"""
seen_fields: dict[TField, OrderDirection] = {}
unique_order_by = []

for field, direction in order_by:
if field in seen_fields:
# Field already seen - check if direction matches
if seen_fields[field] != direction:
msg = f"Field '{field}' appears with conflicting directions: {seen_fields[field].value} and {direction.value}"
raise ValueError(msg)
# Same field and direction - skip duplicate
continue

# First time seeing this field
seen_fields[field] = direction
unique_order_by.append((field, direction))

return unique_order_by
Loading
Loading