Skip to content

Allow limits on user settings, with per-group overrides - #9594

Open
GitHK wants to merge 18 commits into
ITISFoundation:masterfrom
GitHK:pr-osparc-user-preferences-enhancements
Open

Allow limits on user settings, with per-group overrides#9594
GitHK wants to merge 18 commits into
ITISFoundation:masterfrom
GitHK:pr-osparc-user-preferences-enhancements

Conversation

@GitHK

@GitHK GitHK commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What do these changes do?

What this does

User settings (things like the inactivity timeout, theme, or "ask before deleting") can now have limits attached to them, so that people can't save a value that doesn't make sense.

Each setting can declare a sensible default limit. As a first example, the inactivity timeout is capped at 3 hours.

Why

Until now any value could be saved for a setting, even nonsensical ones. There was also no way to give a particular team or customer different boundaries from everyone else.

What changes for users

  • Trying to save a value outside the allowed range now shows a clear message explaining that the value isn't allowed, instead of silently accepting it.
  • The interface receives the allowed limits along with each setting, so input fields can guide people towards valid values rather than letting them discover the limit by trial and error.
  • Nothing changes for settings that have no limits, and existing saved values are never affected — if someone already has a value that a newly introduced limit would forbid, it keeps working and is still displayed normally. Limits only apply when a value is being changed.

What changes for administrators

  • Limits can now be configured per group and per product, and they take priority over the default defined in the product.
  • This works in both directions: a group can be given a stricter limit, or a more generous one. For example, the 3-hour inactivity cap can be raised to 6 hours for a specific team, while everyone else stays at 3 hours.
  • A configuration mistake cannot break anything. If the limits saved for a group are misspelled or malformed, they are simply ignored and the product's own defaults apply instead. A warning is recorded explaining what is wrong and where to fix it, so the problem is visible without anyone losing access to their settings.
  • When someone belongs to several teams, the outcome is now predictable and always the same, rather than depending on whichever team happened to be read first.
  • Only a fixed, safe set of limit types is accepted. Anything else is ignored, so a typo can never widen or remove a limit by accident.

Reference: limits you can configure

Limits go in the new frontend_preferences_constraints column of the groups_extra_properties table. Each entry maps a setting name to the limits that apply to it:

{
  "userInactivityThreshold": { "ge": 60, "le": 21600 },
  "themeName": { "pattern": "^(dark|light)$" }
}

These are all the limits available:

Entry Meaning Example
ge must be at least this {"ge": 60} — no less than 60
gt must be more than this {"gt": 60} — 61 or above
le must be at most this {"le": 21600} — no more than 21600
lt must be less than this {"lt": 21600} — 21599 or below
min_length minimum number of characters or items {"min_length": 3}
max_length maximum number of characters or items {"max_length": 5}
multiple_of must be a multiple of this {"multiple_of": 60} — whole minutes only
pattern must match this format {"pattern": "^(dark|light)$"}

Several limits can be combined for the same setting, and only the ones you specify replace the product defaults — anything you leave out keeps the default.

Below, a stripped down response from the GET /v0/me endpoint, showcasing the default constraints for the userInactivityThreshold, now enforced by the backend.

{
    "data": {
        "id": 1,
        ...
            "userInactivityThreshold": {
                "defaultValue": 1800,
                "value": 1800,
                "constraints": {
                    "ge": 60,
                    "le": 10800
                }
            },
        ...
    }
}

Related issue/s

How to test

Dev-ops

@GitHK GitHK added this to the Kula Ring milestone Aug 25, 2026
@GitHK GitHK self-assigned this Aug 25, 2026
@github-actions github-actions Bot added a:webserver webserver's codebase. Assigning the area is particularly useful for bugs a:database associated to postgres service and postgres-database package a:models-library labels Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.01%. Comparing base (89a13e5) to head (0b2c976).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9594      +/-   ##
==========================================
+ Coverage   88.18%   91.01%   +2.82%     
==========================================
  Files        1563     1134     -429     
  Lines       60661    46757   -13904     
  Branches     1583      587     -996     
==========================================
- Hits        53496    42557   -10939     
+ Misses       6743     4053    -2690     
+ Partials      422      147     -275     
Flag Coverage Δ
integrationtests 71.86% <ø> (-0.06%) ⬇️
unittests 89.55% <100.00%> (+2.52%) ⬆️
Components Coverage Δ
pkg_aws_library ∅ <ø> (∅)
pkg_celery_library ∅ <ø> (∅)
pkg_dask_task_models_library ∅ <ø> (∅)
pkg_models_library 92.71% <100.00%> (+0.09%) ⬆️
pkg_notifications_library ∅ <ø> (∅)
pkg_postgres_database ∅ <ø> (∅)
pkg_service_integration 73.01% <ø> (ø)
pkg_service_library ∅ <ø> (∅)
pkg_settings_library ∅ <ø> (∅)
pkg_simcore_sdk 86.37% <ø> (ø)
agent 93.91% <ø> (ø)
api_server 92.97% <ø> (ø)
autoscaling 95.21% <ø> (ø)
catalog 92.47% <ø> (ø)
clusters_keeper 98.61% <ø> (ø)
dask_sidecar 93.45% <ø> (+0.23%) ⬆️
datcore_adapter 98.08% <ø> (ø)
director 79.01% <ø> (-0.16%) ⬇️
director_v2 91.96% <ø> (-0.11%) ⬇️
dynamic_scheduler 96.17% <ø> (ø)
dynamic_sidecar 88.66% <ø> (ø)
efs_guardian 89.40% <ø> (ø)
invitations 91.63% <ø> (ø)
payments 92.49% <ø> (ø)
resource_usage_tracker 91.78% <ø> (+0.15%) ⬆️
storage 87.99% <ø> (+0.03%) ⬆️
webclient ∅ <ø> (∅)
webserver ∅ <ø> (∅)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 89a13e5...0b2c976. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds configurable group-scoped validation constraints for frontend user preferences, including an inactivity-threshold cap.

Changes:

  • Adds dynamic preference validation and group overrides.
  • Persists constraints in PostgreSQL JSONB storage.
  • Adds error handling, migrations, and model/service tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Summary Final review
services/web/server/tests/unit/with_dbs/03/test_user_preferences_service.py Tests group constraint behavior. No final comments.
services/web/server/src/simcore_service_webserver/user_preferences/errors.py Defines preference validation errors. No final comments.
services/web/server/src/simcore_service_webserver/user_preferences/_service.py Applies group constraints during updates. Critical (1 vote): Missing groups_extra_properties rows can cause preference writes to fail with an unhandled 500.
services/web/server/src/simcore_service_webserver/user_preferences/_models.py Defines inactivity threshold constraints. No final comments.
services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py Maps invalid values to HTTP 422. Nit (3 votes): Add endpoint-level coverage for the invalid-value mapping.
packages/postgres-database/src/simcore_postgres_database/utils_groups_extra_properties.py Exposes constraint data in aggregated properties. No final comments.
packages/postgres-database/src/simcore_postgres_database/models/groups_extra_properties.py Adds the JSONB constraint column. No final comments.
packages/postgres-database/src/simcore_postgres_database/migration/versions/9f24c8e1a3b7_add_frontend_preferences_constraints.py Migrates the database schema. No final comments.
packages/models-library/tests/test_user_preferences.py Tests dynamic constraint validation. No final comments.
packages/models-library/src/models_library/user_preferences.py Implements cached Pydantic validators. No final comments.
Suppressed comments (4)

packages/postgres-database/src/simcore_postgres_database/utils_groups_extra_properties.py:38

  • This field is aggregated by _merge_extra_properties_booleans, which keeps the first value for every non-bool field. For a user in multiple standard groups, an earlier row with the migration's default {} therefore masks a later group's configured constraints; because the query orders only by group type, which row wins is not deterministic. Merge this constraint map per preference (with an explicit conflict precedence) and cover the multiple-standard-group case.
    frontend_preferences_constraints: dict[str, dict[str, Any]]

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:27

  • This changes an existing user-facing message (the placeholder changed from frontend_preference_name to frontend_preference_identifier) but leaves _version unset. The translation pipeline uses _version to track modified catalog entries, so bump the message version here.
        user_message("Provided {frontend_preference_identifier} not found"),

services/web/server/src/simcore_service_webserver/user_preferences/_service.py:142

  • validate_value deliberately raises InvalidValueConstraintsError for unsupported or type-incompatible group overrides, but this block catches only ValidationError. Since the new JSONB configuration is not schema-validated and this exception is absent from the REST map, one malformed group constraint makes preference PATCH requests escape as unhandled 500 errors; validate the stored configuration before use or handle this configuration failure explicitly.
    try:
        preference_class.validate_value(
            value,
            group_extra_properties.frontend_preferences_constraints.get(frontend_preference_identifier),
        )
        preference = preference_class.model_validate({"value": value})
    except ValidationError as e:
        raise FrontendUserPreferenceValueIsInvalidError(
            frontend_preference_identifier=frontend_preference_identifier, value=value
        ) from e

services/web/server/tests/unit/with_dbs/03/test_user_preferences_service.py:219

  • This fixture updates every groups_extra_properties row for the product, so Everyone, standard, and primary groups all receive the same map. It therefore does not exercise the new aggregation behavior when only one group supplies constraints or when group maps conflict; add group-specific setup/assertions so a regression in _aggregate cannot pass these tests.
                groups_extra_properties.update()
                .where(groups_extra_properties.c.product_name == product_name)
                .values(frontend_preferences_constraints={_INACTIVITY_IDENTIFIER: constraints} if constraints else {})

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread services/web/server/src/simcore_service_webserver/user_preferences/_service.py Outdated
@GitHK
GitHK requested a lite review from Copilot August 25, 2026 14:50
@GitHK GitHK changed the title Pr osparc user preferences enhancements Allow limits on user settings, with per-group overrides Aug 25, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:27

  • This user-facing catalog message was modified without a version marker. Add _version=1 so the message key is tracked consistently with the other REST error messages.

This issue also appears on line 31 of the same file.

        user_message("Provided {frontend_preference_identifier} not found"),

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:31

  • The new 422 mapping is not covered at the REST boundary. test_user_preferences_rest.py tests the 404 mapping but does not PATCH an invalid preference value, so a regression in the decorator/map could return a 500 or the wrong error envelope while the service-level tests still pass. Add an endpoint test for userInactivityThreshold above the allowed limit and assert the 422 response and message.
    FrontendUserPreferenceValueIsInvalidError: HttpErrorInfo(
        status.HTTP_422_UNPROCESSABLE_ENTITY,
        user_message("The value {value} is not allowed for {frontend_preference_identifier}"),

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:31

  • This new user-facing message omits _version=N. The web-server error maps consistently version user_message templates (for example, projects/_controller/_rest_exceptions.py:61), and the repository guideline requires versioning so translated catalog entries can evolve safely. Add the version argument here.
        user_message("The value {value} is not allowed for {frontend_preference_identifier}"),

services/web/server/src/simcore_service_webserver/user_preferences/_service.py:113

  • These overrides are read from the already aggregated group row, but the aggregation code does not merge this new dict: it returns the primary row immediately and treats non-boolean fields as value1 when combining standard rows. Thus a constraint configured only on a standard/everyone group is lost for users with a primary row, and competing standard-group values depend on row order. The tests avoid this by updating every row; aggregate this map with explicit precedence and add a group-specific test.
        constraints = preference.get_value_constraints(
            group_extra_properties.frontend_preferences_constraints.get(preference.preference_identifier)
        )

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:23

  • All user_message(...) calls in this exceptions map should include an explicit _version=N for i18n/versioning consistency (many other web-server controllers do, e.g. services/web/server/src/simcore_service_webserver/products/_controller/rest_exceptions.py:16).

This issue also appears in the following locations of the same file:

  • line 25
  • line 29
    CouldNotCreateOrUpdateUserPreferenceError: HttpErrorInfo(
        status.HTTP_400_BAD_REQUEST,
        user_message(
            "Could not create or modify preferences",
        ),

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:28

  • This user-facing user_message(...) should include _version=N for i18n extraction/versioning consistency (see e.g. services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py:27).
    FrontendUserPreferenceIsNotDefinedError: HttpErrorInfo(
        status.HTTP_404_NOT_FOUND,
        user_message("Provided {frontend_preference_identifier} not found"),
    ),

services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py:32

  • This user-facing user_message(...) should include _version=N for i18n extraction/versioning consistency (many other web-server controllers do, e.g. services/web/server/src/simcore_service_webserver/tasks/_controller/_rest_exceptions.py:26).
    FrontendUserPreferenceValueIsInvalidError: HttpErrorInfo(
        status.HTTP_422_UNPROCESSABLE_ENTITY,
        user_message("The value {value} is not allowed for {frontend_preference_identifier}"),
    ),

Comment thread packages/models-library/src/models_library/user_preferences.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

packages/postgres-database/src/simcore_postgres_database/utils_groups_extra_properties.py:39

  • frontend_preferences_constraints is typed as dict[str, dict[str, Any]], but the rest of this PR explicitly tolerates malformed/misspelled overrides coming from the DB (e.g. values that are not mappings). Keeping this field narrow encourages callers to assume the nested value is always a dict, which is not guaranteed and is intentionally handled later (via InvalidValueConstraintsError). Consider loosening the type to reflect the DB reality ("untrusted JSON"), e.g. dict[str, Any].
    modified: datetime.datetime
    enable_efs: bool
    mount_data: bool
    frontend_preferences_constraints: dict[str, dict[str, Any]]

packages/models-library/src/models_library/user_preferences.py:116

  • _VALUE_VALIDATOR_CLASSES is a process-global cache keyed by (preference class, serialized constraints). Because constraint overrides come from the DB and can change over time, this cache can grow without bound in a long-running service (each distinct override set creates a new Pydantic model class and is retained forever). Consider bounding the cache (simple eviction is fine) to avoid unbounded memory growth.
        cache_key = (cls, json_dumps(constraints, sort_keys=True, default=str))
        if cache_key not in _VALUE_VALIDATOR_CLASSES:
            value_annotation = cls.model_fields["value"].annotation
            try:
                _VALUE_VALIDATOR_CLASSES[cache_key] = create_model(

@GitHK
GitHK marked this pull request as ready for review August 26, 2026 08:48
@GitHK
GitHK requested a review from odeimaiz August 26, 2026 08:49

@sanderegg sanderegg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thanks this looks nice

class UserInactivityThresholdFrontendUserPreference(FrontendUserPreference):
preference_identifier: PreferenceIdentifier = "userInactivityThreshold"
value: int = 30 * _MINUTE # in seconds
value_constraints: ClassVar[dict[str, Any]] = {"ge": 1 * _MINUTE, "le": 3 * _HOUR}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

so that is the default.
minor and probably too late: why we do not use timedelta for all these timings? would that not be a bit easier?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's a bit late since the db is full of these values, also not staring forward to migrate. If we attempt doing so it will require a different PR. Also it requires frontend coordination to properly adapt

@github-actions github-actions Bot added the a:api framework api, data schemas, label Aug 26, 2026
class PreferenceConstraints(OutputSchema):
"""Limits applying to a preference value, used by the frontend to render its widget."""

ge: int | float | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

how do you handle conflicting constraints? e.g.

x>5 and x<3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The same way Pydantic does, by doing nothing. This would be a configuration issue and will break.

python
Python 3.13.9 (main, Nov 17 2025, 09:40:22) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pydantic import BaseModel, Field
...
... class Model(BaseModel):
...     x: int = Field(gt=5, lt=3)
...
... Model(x=4)
...
Traceback (most recent call last):
  File "<python-input-0>", line 6, in <module>
    Model(x=4)
    ~~~~~^^^^^
  File "/home/silenthk/work/pr-osparc-user-preferences-enhancements/.venv/lib/python3.13/site-packages/pydantic/main.py", line 263, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
pydantic_core._pydantic_core.ValidationError: 1 validation error for Model
x
  Input should be less than 3 [type=less_than, input_value=4, input_type=int]
    For further information visit https://errors.pydantic.dev/2.13/v/less_than

"""Limits applying to a preference value, used by the frontend to render its widget."""

ge: int | float | None = None
gt: int | float | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This reminded me so much pydantci's internal code.

SEE https://github.com/pydantic/pydantic/blob/main/pydantic/fields.py#L67-L71

IMO it is worth exploring the possibility of using annotated_types library (already shipped with pydantic) and take advantage of all the goodies it comes with ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Short answer, current approach is better.

Long answer. You could use annotated_types, which do bring a better way to express the "constraints", at least in python.
But you still don't have anything to help you with serialisation to and from the database. It will only introduce more overhead just to use annotated_types.

I think this version is more compact and simple to maintain, for our limited purposes here.

@GitHK GitHK modified the milestones: Kula Ring, War Pigs Aug 27, 2026
@GitHK
GitHK requested a review from pcrespov August 28, 2026 06:11
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a:api framework api, data schemas, a:database associated to postgres service and postgres-database package a:models-library a:webserver webserver's codebase. Assigning the area is particularly useful for bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants