Skip to content

Commit 4ed3e04

Browse files
committed
moves package to models library
1 parent c66c520 commit 4ed3e04

File tree

6 files changed

+150
-151
lines changed

6 files changed

+150
-151
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import re
2+
from datetime import date
3+
from enum import Enum
4+
from typing import Annotated, Literal
5+
6+
from models_library.api_schemas_webserver.groups import MyGroupsGet
7+
from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences
8+
from models_library.basic_types import IDStr
9+
from models_library.emails import LowerCaseEmailStr
10+
from models_library.users import FirstNameStr, LastNameStr, UserID
11+
from pydantic import BaseModel, ConfigDict, Field, field_validator
12+
13+
from ._base import InputSchema, OutputSchema
14+
15+
16+
class ProfilePrivacyGet(OutputSchema):
17+
hide_fullname: bool
18+
hide_email: bool
19+
20+
21+
class ProfilePrivacyUpdate(InputSchema):
22+
hide_fullname: bool | None = None
23+
hide_email: bool | None = None
24+
25+
26+
class ProfileGet(BaseModel):
27+
# WARNING: do not use InputSchema until front-end is updated!
28+
id: UserID
29+
user_name: Annotated[
30+
IDStr, Field(description="Unique username identifier", alias="userName")
31+
]
32+
first_name: FirstNameStr | None = None
33+
last_name: LastNameStr | None = None
34+
login: LowerCaseEmailStr
35+
36+
role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"]
37+
groups: MyGroupsGet | None = None
38+
gravatar_id: Annotated[str | None, Field(deprecated=True)] = None
39+
40+
expiration_date: Annotated[
41+
date | None,
42+
Field(
43+
description="If user has a trial account, it sets the expiration date, otherwise None",
44+
alias="expirationDate",
45+
),
46+
] = None
47+
48+
privacy: ProfilePrivacyGet
49+
preferences: AggregatedPreferences
50+
51+
model_config = ConfigDict(
52+
# NOTE: old models have an hybrid between snake and camel cases!
53+
# Should be unified at some point
54+
populate_by_name=True,
55+
json_schema_extra={
56+
"examples": [
57+
{
58+
"id": 42,
59+
"login": "[email protected]",
60+
"userName": "bla42",
61+
"role": "admin", # pre
62+
"expirationDate": "2022-09-14", # optional
63+
"preferences": {},
64+
"privacy": {"hide_fullname": 0, "hide_email": 1},
65+
},
66+
]
67+
},
68+
)
69+
70+
@field_validator("role", mode="before")
71+
@classmethod
72+
def _to_upper_string(cls, v):
73+
if isinstance(v, str):
74+
return v.upper()
75+
if isinstance(v, Enum):
76+
return v.name.upper()
77+
return v
78+
79+
80+
class ProfileUpdate(BaseModel):
81+
# WARNING: do not use InputSchema until front-end is updated!
82+
first_name: FirstNameStr | None = None
83+
last_name: LastNameStr | None = None
84+
user_name: Annotated[IDStr | None, Field(alias="userName")] = None
85+
86+
privacy: ProfilePrivacyUpdate | None = None
87+
88+
model_config = ConfigDict(
89+
json_schema_extra={
90+
"example": {
91+
"first_name": "Pedro",
92+
"last_name": "Crespo",
93+
}
94+
}
95+
)
96+
97+
@field_validator("user_name")
98+
@classmethod
99+
def _validate_user_name(cls, value: str):
100+
# Ensure valid characters (alphanumeric + . _ -)
101+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value):
102+
msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'."
103+
raise ValueError(msg)
104+
105+
# Ensure no consecutive special characters
106+
if re.search(r"[_.-]{2,}", value):
107+
msg = f"Username '{value}' cannot contain consecutive special characters like '__'."
108+
raise ValueError(msg)
109+
110+
# Ensure it doesn't end with a special character
111+
if {value[0], value[-1]}.intersection({"_", "-", "."}):
112+
msg = f"Username '{value}' cannot end or start with a special character."
113+
raise ValueError(msg)
114+
115+
# Check reserved words (example list; extend as needed)
116+
reserved_words = {
117+
"admin",
118+
"root",
119+
"system",
120+
"null",
121+
"undefined",
122+
"support",
123+
"moderator",
124+
# NOTE: add here extra via env vars
125+
}
126+
if any(w in value.lower() for w in reserved_words):
127+
msg = f"Username '{value}' cannot be used."
128+
raise ValueError(msg)
129+
130+
return value

services/web/server/src/simcore_service_webserver/users/_handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33

44
from aiohttp import web
5+
from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate
56
from models_library.users import UserID
67
from pydantic import BaseModel, Field
78
from servicelib.aiohttp import status
@@ -28,7 +29,6 @@
2829
UserNameDuplicateError,
2930
UserNotFoundError,
3031
)
31-
from .schemas import ProfileGet, ProfileUpdate
3232

3333
_logger = logging.getLogger(__name__)
3434

services/web/server/src/simcore_service_webserver/users/_models.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,6 @@
22

33
from pydantic import BaseModel, ConfigDict, Field
44

5-
6-
#
7-
# REST models
8-
#
9-
class ProfilePrivacyGet(BaseModel):
10-
hide_fullname: bool
11-
hide_email: bool
12-
13-
14-
class ProfilePrivacyUpdate(BaseModel):
15-
hide_fullname: bool | None = None
16-
hide_email: bool | None = None
17-
18-
195
#
206
# DB models
217
#
@@ -35,7 +21,7 @@ def flatten_dict(d: dict, parent_key="", sep="_"):
3521

3622
class ToUserUpdateDB(BaseModel):
3723
"""
38-
Maps ProfileUpdate api model into UserDB db model
24+
Maps ProfileUpdate api-model into UserUpdate db-model
3925
"""
4026

4127
# NOTE: field names are UserDB columns
@@ -57,5 +43,5 @@ def from_api(cls, profile_update) -> Self:
5743
flatten_dict(profile_update.model_dump(exclude_unset=True, by_alias=False))
5844
)
5945

60-
def to_columns(self) -> dict[str, Any]:
46+
def to_db(self) -> dict[str, Any]:
6147
return self.model_dump(exclude_unset=True, by_alias=False)

services/web/server/src/simcore_service_webserver/users/api.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
from aiohttp import web
1515
from aiopg.sa.engine import Engine
1616
from aiopg.sa.result import RowProxy
17+
from models_library.api_schemas_webserver.users import (
18+
ProfileGet,
19+
ProfilePrivacyGet,
20+
ProfileUpdate,
21+
)
1722
from models_library.basic_types import IDStr
1823
from models_library.products import ProductName
1924
from models_library.users import GroupID, UserID
@@ -23,7 +28,6 @@
2328
from simcore_postgres_database.utils_groups_extra_properties import (
2429
GroupExtraPropertiesNotFoundError,
2530
)
26-
from simcore_service_webserver.users._models import ProfilePrivacyGet
2731

2832
from ..db.plugin import get_database_engine
2933
from ..groups.models import convert_groups_db_to_schema
@@ -38,7 +42,6 @@
3842
UserNameDuplicateError,
3943
UserNotFoundError,
4044
)
41-
from .schemas import ProfileGet, ProfileUpdate
4245

4346
_logger = logging.getLogger(__name__)
4447

@@ -167,7 +170,7 @@ async def update_user_profile(
167170
"""
168171
user_id = _parse_as_user(user_id)
169172

170-
if updated_values := ToUserUpdateDB.from_api(update).to_columns():
173+
if updated_values := ToUserUpdateDB.from_api(update).to_db():
171174
async with get_database_engine(app).acquire() as conn:
172175
query = users.update().where(users.c.id == user_id).values(**updated_values)
173176

services/web/server/src/simcore_service_webserver/users/schemas.py

Lines changed: 1 addition & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
1-
import re
2-
from datetime import date
3-
from typing import Annotated, Literal
41
from uuid import UUID
52

63
from models_library.api_schemas_webserver._base import OutputSchema
7-
from models_library.api_schemas_webserver.groups import MyGroupsGet
8-
from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences
9-
from models_library.basic_types import IDStr
10-
from models_library.emails import LowerCaseEmailStr
11-
from models_library.users import FirstNameStr, LastNameStr, UserID
12-
from pydantic import BaseModel, ConfigDict, Field, field_validator
13-
from simcore_postgres_database.models.users import UserRole
14-
15-
from ._models import ProfilePrivacyGet, ProfilePrivacyUpdate
4+
from pydantic import BaseModel, ConfigDict, Field
165

176

187
#
@@ -43,116 +32,6 @@ class TokenCreate(ThirdPartyToken):
4332
...
4433

4534

46-
#
47-
# PROFILE resource
48-
#
49-
50-
51-
class ProfileGet(BaseModel):
52-
id: UserID
53-
user_name: Annotated[
54-
IDStr, Field(description="Unique username identifier", alias="userName")
55-
]
56-
first_name: FirstNameStr | None = None
57-
last_name: LastNameStr | None = None
58-
login: LowerCaseEmailStr
59-
60-
role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"]
61-
groups: MyGroupsGet | None = None
62-
gravatar_id: Annotated[str | None, Field(deprecated=True)] = None
63-
64-
expiration_date: Annotated[
65-
date | None,
66-
Field(
67-
description="If user has a trial account, it sets the expiration date, otherwise None",
68-
alias="expirationDate",
69-
),
70-
] = None
71-
72-
privacy: ProfilePrivacyGet
73-
preferences: AggregatedPreferences
74-
75-
model_config = ConfigDict(
76-
# NOTE: old models have an hybrid between snake and camel cases!
77-
# Should be unified at some point
78-
populate_by_name=True,
79-
json_schema_extra={
80-
"examples": [
81-
{
82-
"id": 42,
83-
"login": "[email protected]",
84-
"userName": "bla42",
85-
"role": "admin", # pre
86-
"expirationDate": "2022-09-14", # optional
87-
"preferences": {},
88-
"privacy": {"hide_fullname": 0, "hide_email": 1},
89-
},
90-
]
91-
},
92-
)
93-
94-
@field_validator("role", mode="before")
95-
@classmethod
96-
def _to_upper_string(cls, v):
97-
if isinstance(v, str):
98-
return v.upper()
99-
if isinstance(v, UserRole):
100-
return v.name.upper()
101-
return v
102-
103-
104-
class ProfileUpdate(BaseModel):
105-
first_name: FirstNameStr | None = None
106-
last_name: LastNameStr | None = None
107-
user_name: Annotated[IDStr | None, Field(alias="userName")] = None
108-
109-
privacy: ProfilePrivacyUpdate | None = None
110-
111-
model_config = ConfigDict(
112-
json_schema_extra={
113-
"example": {
114-
"first_name": "Pedro",
115-
"last_name": "Crespo",
116-
}
117-
}
118-
)
119-
120-
@field_validator("user_name")
121-
@classmethod
122-
def _validate_user_name(cls, value: str):
123-
# Ensure valid characters (alphanumeric + . _ -)
124-
if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value):
125-
msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'."
126-
raise ValueError(msg)
127-
128-
# Ensure no consecutive special characters
129-
if re.search(r"[_.-]{2,}", value):
130-
msg = f"Username '{value}' cannot contain consecutive special characters like '__'."
131-
raise ValueError(msg)
132-
133-
# Ensure it doesn't end with a special character
134-
if {value[0], value[-1]}.intersection({"_", "-", "."}):
135-
msg = f"Username '{value}' cannot end or start with a special character."
136-
raise ValueError(msg)
137-
138-
# Check reserved words (example list; extend as needed)
139-
reserved_words = {
140-
"admin",
141-
"root",
142-
"system",
143-
"null",
144-
"undefined",
145-
"support",
146-
"moderator",
147-
# NOTE: add here extra via env vars
148-
}
149-
if any(w in value.lower() for w in reserved_words):
150-
msg = f"Username '{value}' cannot be used."
151-
raise ValueError(msg)
152-
153-
return value
154-
155-
15635
#
15736
# Permissions
15837
#

0 commit comments

Comments
 (0)