Skip to content

Commit 9a15e0c

Browse files
authored
✨ web-api: user's privacy settings (ITISFoundation#6904)
1 parent fcb92ea commit 9a15e0c

File tree

22 files changed

+696
-245
lines changed

22 files changed

+696
-245
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ or from https://gitmoji.dev/
2222

2323
## What do these changes do?
2424

25+
<!-- Badge to openapi specs
26+
[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=HERE-URL-TO-RAW-FILE)
27+
-->
2528

2629

2730
## Related issue/s
@@ -31,9 +34,6 @@ or from https://gitmoji.dev/
3134
3235
- resolves ITISFoundation/osparc-issues#428
3336
- fixes #26
34-
35-
If openapi changes are provided, optionally point to the swagger editor with new changes
36-
Example [openapi.json specs](https://editor.swagger.io/?url=https://raw.githubusercontent.com/<github-username>/osparc-simcore/is1133/create-api-for-creation-of-pricing-plan/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml)
3737
-->
3838

3939

api/specs/web-server/_users.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Annotated
88

99
from fastapi import APIRouter, Depends, status
10+
from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate
1011
from models_library.api_schemas_webserver.users_preferences import PatchRequestBody
1112
from models_library.generics import Envelope
1213
from models_library.user_preferences import PreferenceIdentifier
@@ -24,8 +25,6 @@
2425
from simcore_service_webserver.users._tokens_handlers import _TokenPathParams
2526
from simcore_service_webserver.users.schemas import (
2627
PermissionGet,
27-
ProfileGet,
28-
ProfileUpdate,
2928
ThirdPartyToken,
3029
TokenCreate,
3130
)
@@ -41,14 +40,24 @@ async def get_my_profile():
4140
...
4241

4342

44-
@router.put(
43+
@router.patch(
4544
"/me",
4645
status_code=status.HTTP_204_NO_CONTENT,
4746
)
4847
async def update_my_profile(_profile: ProfileUpdate):
4948
...
5049

5150

51+
@router.put(
52+
"/me",
53+
status_code=status.HTTP_204_NO_CONTENT,
54+
deprecated=True,
55+
description="Use PATCH instead",
56+
)
57+
async def replace_my_profile(_profile: ProfileUpdate):
58+
...
59+
60+
5261
@router.patch(
5362
"/me/preferences/{preference_id}",
5463
status_code=status.HTTP_204_NO_CONTENT,
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": "bla@foo.com",
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""new user privacy columns
2+
3+
Revision ID: 38c9ac332c58
4+
Revises: e5555076ef50
5+
Create Date: 2024-12-05 14:29:27.739650+00:00
6+
7+
"""
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = "38c9ac332c58"
13+
down_revision = "e5555076ef50"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
# ### commands auto generated by Alembic - please adjust! ###
20+
op.add_column(
21+
"users",
22+
sa.Column(
23+
"privacy_hide_fullname",
24+
sa.Boolean(),
25+
server_default=sa.text("true"),
26+
nullable=False,
27+
),
28+
)
29+
op.add_column(
30+
"users",
31+
sa.Column(
32+
"privacy_hide_email",
33+
sa.Boolean(),
34+
server_default=sa.text("true"),
35+
nullable=False,
36+
),
37+
)
38+
# ### end Alembic commands ###
39+
40+
41+
def downgrade():
42+
# ### commands auto generated by Alembic - please adjust! ###
43+
op.drop_column("users", "privacy_hide_email")
44+
op.drop_column("users", "privacy_hide_fullname")
45+
# ### end Alembic commands ###

packages/postgres-database/src/simcore_postgres_database/models/users.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from functools import total_ordering
33

44
import sqlalchemy as sa
5+
from sqlalchemy.sql import expression
56

67
from ._common import RefActions
78
from .base import metadata
@@ -67,6 +68,9 @@ class UserStatus(str, Enum):
6768
users = sa.Table(
6869
"users",
6970
metadata,
71+
#
72+
# User Identifiers ------------------
73+
#
7074
sa.Column(
7175
"id",
7276
sa.BigInteger(),
@@ -77,8 +81,23 @@ class UserStatus(str, Enum):
7781
"name",
7882
sa.String(),
7983
nullable=False,
80-
doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ...",
84+
doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ..."
85+
"This identifier **is public**.",
8186
),
87+
sa.Column(
88+
"primary_gid",
89+
sa.BigInteger(),
90+
sa.ForeignKey(
91+
"groups.gid",
92+
name="fk_users_gid_groups",
93+
onupdate=RefActions.CASCADE,
94+
ondelete=RefActions.RESTRICT,
95+
),
96+
doc="User's group ID",
97+
),
98+
#
99+
# User Information ------------------
100+
#
82101
sa.Column(
83102
"first_name",
84103
sa.String(),
@@ -102,37 +121,52 @@ class UserStatus(str, Enum):
102121
doc="Confirmed user phone used e.g. to send a code for a two-factor-authentication."
103122
"NOTE: new policy (NK) is that the same phone can be reused therefore it does not has to be unique",
104123
),
124+
#
125+
# User Secrets ------------------
126+
#
105127
sa.Column(
106128
"password_hash",
107129
sa.String(),
108130
nullable=False,
109131
doc="Hashed password",
110132
),
111-
sa.Column(
112-
"primary_gid",
113-
sa.BigInteger(),
114-
sa.ForeignKey(
115-
"groups.gid",
116-
name="fk_users_gid_groups",
117-
onupdate=RefActions.CASCADE,
118-
ondelete=RefActions.RESTRICT,
119-
),
120-
doc="User's group ID",
121-
),
133+
#
134+
# User Account ------------------
135+
#
122136
sa.Column(
123137
"status",
124138
sa.Enum(UserStatus),
125139
nullable=False,
126140
default=UserStatus.CONFIRMATION_PENDING,
127-
doc="Status of the user account. SEE UserStatus",
141+
doc="Current status of the user's account",
128142
),
129143
sa.Column(
130144
"role",
131145
sa.Enum(UserRole),
132146
nullable=False,
133147
default=UserRole.USER,
134-
doc="Use for role-base authorization",
148+
doc="Used for role-base authorization",
149+
),
150+
#
151+
# User Privacy Rules ------------------
152+
#
153+
sa.Column(
154+
"privacy_hide_fullname",
155+
sa.Boolean,
156+
nullable=False,
157+
server_default=expression.true(),
158+
doc="If true, it hides users.first_name, users.last_name to others",
159+
),
160+
sa.Column(
161+
"privacy_hide_email",
162+
sa.Boolean,
163+
nullable=False,
164+
server_default=expression.true(),
165+
doc="If true, it hides users.email to others",
135166
),
167+
#
168+
# Timestamps ---------------
169+
#
136170
sa.Column(
137171
"created_at",
138172
sa.DateTime(),

0 commit comments

Comments
 (0)