Skip to content

Commit

Permalink
✨ web-api: user's privacy settings (ITISFoundation#6904)
Browse files Browse the repository at this point in the history
  • Loading branch information
pcrespov authored Dec 6, 2024
1 parent fcb92ea commit 9a15e0c
Show file tree
Hide file tree
Showing 22 changed files with 696 additions and 245 deletions.
6 changes: 3 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ 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)
-->


## Related issue/s
Expand All @@ -31,9 +34,6 @@ or from https://gitmoji.dev/
- resolves ITISFoundation/osparc-issues#428
- fixes #26
If openapi changes are provided, optionally point to the swagger editor with new changes
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)
-->


Expand Down
15 changes: 12 additions & 3 deletions api/specs/web-server/_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Annotated

from fastapi import APIRouter, Depends, status
from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate
from models_library.api_schemas_webserver.users_preferences import PatchRequestBody
from models_library.generics import Envelope
from models_library.user_preferences import PreferenceIdentifier
Expand All @@ -24,8 +25,6 @@
from simcore_service_webserver.users._tokens_handlers import _TokenPathParams
from simcore_service_webserver.users.schemas import (
PermissionGet,
ProfileGet,
ProfileUpdate,
ThirdPartyToken,
TokenCreate,
)
Expand All @@ -41,14 +40,24 @@ async def get_my_profile():
...


@router.put(
@router.patch(
"/me",
status_code=status.HTTP_204_NO_CONTENT,
)
async def update_my_profile(_profile: ProfileUpdate):
...


@router.put(
"/me",
status_code=status.HTTP_204_NO_CONTENT,
deprecated=True,
description="Use PATCH instead",
)
async def replace_my_profile(_profile: ProfileUpdate):
...


@router.patch(
"/me/preferences/{preference_id}",
status_code=status.HTTP_204_NO_CONTENT,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import re
from datetime import date
from enum import Enum
from typing import Annotated, Literal

from models_library.api_schemas_webserver.groups import MyGroupsGet
from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences
from models_library.basic_types import IDStr
from models_library.emails import LowerCaseEmailStr
from models_library.users import FirstNameStr, LastNameStr, UserID
from pydantic import BaseModel, ConfigDict, Field, field_validator

from ._base import InputSchema, OutputSchema


class ProfilePrivacyGet(OutputSchema):
hide_fullname: bool
hide_email: bool


class ProfilePrivacyUpdate(InputSchema):
hide_fullname: bool | None = None
hide_email: bool | None = None


class ProfileGet(BaseModel):
# WARNING: do not use InputSchema until front-end is updated!
id: UserID
user_name: Annotated[
IDStr, Field(description="Unique username identifier", alias="userName")
]
first_name: FirstNameStr | None = None
last_name: LastNameStr | None = None
login: LowerCaseEmailStr

role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"]
groups: MyGroupsGet | None = None
gravatar_id: Annotated[str | None, Field(deprecated=True)] = None

expiration_date: Annotated[
date | None,
Field(
description="If user has a trial account, it sets the expiration date, otherwise None",
alias="expirationDate",
),
] = None

privacy: ProfilePrivacyGet
preferences: AggregatedPreferences

model_config = ConfigDict(
# NOTE: old models have an hybrid between snake and camel cases!
# Should be unified at some point
populate_by_name=True,
json_schema_extra={
"examples": [
{
"id": 42,
"login": "[email protected]",
"userName": "bla42",
"role": "admin", # pre
"expirationDate": "2022-09-14", # optional
"preferences": {},
"privacy": {"hide_fullname": 0, "hide_email": 1},
},
]
},
)

@field_validator("role", mode="before")
@classmethod
def _to_upper_string(cls, v):
if isinstance(v, str):
return v.upper()
if isinstance(v, Enum):
return v.name.upper()
return v


class ProfileUpdate(BaseModel):
# WARNING: do not use InputSchema until front-end is updated!
first_name: FirstNameStr | None = None
last_name: LastNameStr | None = None
user_name: Annotated[IDStr | None, Field(alias="userName")] = None

privacy: ProfilePrivacyUpdate | None = None

model_config = ConfigDict(
json_schema_extra={
"example": {
"first_name": "Pedro",
"last_name": "Crespo",
}
}
)

@field_validator("user_name")
@classmethod
def _validate_user_name(cls, value: str):
# Ensure valid characters (alphanumeric + . _ -)
if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value):
msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'."
raise ValueError(msg)

# Ensure no consecutive special characters
if re.search(r"[_.-]{2,}", value):
msg = f"Username '{value}' cannot contain consecutive special characters like '__'."
raise ValueError(msg)

# Ensure it doesn't end with a special character
if {value[0], value[-1]}.intersection({"_", "-", "."}):
msg = f"Username '{value}' cannot end or start with a special character."
raise ValueError(msg)

# Check reserved words (example list; extend as needed)
reserved_words = {
"admin",
"root",
"system",
"null",
"undefined",
"support",
"moderator",
# NOTE: add here extra via env vars
}
if any(w in value.lower() for w in reserved_words):
msg = f"Username '{value}' cannot be used."
raise ValueError(msg)

return value
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""new user privacy columns
Revision ID: 38c9ac332c58
Revises: e5555076ef50
Create Date: 2024-12-05 14:29:27.739650+00:00
"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "38c9ac332c58"
down_revision = "e5555076ef50"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"users",
sa.Column(
"privacy_hide_fullname",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
)
op.add_column(
"users",
sa.Column(
"privacy_hide_email",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "privacy_hide_email")
op.drop_column("users", "privacy_hide_fullname")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from functools import total_ordering

import sqlalchemy as sa
from sqlalchemy.sql import expression

from ._common import RefActions
from .base import metadata
Expand Down Expand Up @@ -67,6 +68,9 @@ class UserStatus(str, Enum):
users = sa.Table(
"users",
metadata,
#
# User Identifiers ------------------
#
sa.Column(
"id",
sa.BigInteger(),
Expand All @@ -77,8 +81,23 @@ class UserStatus(str, Enum):
"name",
sa.String(),
nullable=False,
doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ...",
doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ..."
"This identifier **is public**.",
),
sa.Column(
"primary_gid",
sa.BigInteger(),
sa.ForeignKey(
"groups.gid",
name="fk_users_gid_groups",
onupdate=RefActions.CASCADE,
ondelete=RefActions.RESTRICT,
),
doc="User's group ID",
),
#
# User Information ------------------
#
sa.Column(
"first_name",
sa.String(),
Expand All @@ -102,37 +121,52 @@ class UserStatus(str, Enum):
doc="Confirmed user phone used e.g. to send a code for a two-factor-authentication."
"NOTE: new policy (NK) is that the same phone can be reused therefore it does not has to be unique",
),
#
# User Secrets ------------------
#
sa.Column(
"password_hash",
sa.String(),
nullable=False,
doc="Hashed password",
),
sa.Column(
"primary_gid",
sa.BigInteger(),
sa.ForeignKey(
"groups.gid",
name="fk_users_gid_groups",
onupdate=RefActions.CASCADE,
ondelete=RefActions.RESTRICT,
),
doc="User's group ID",
),
#
# User Account ------------------
#
sa.Column(
"status",
sa.Enum(UserStatus),
nullable=False,
default=UserStatus.CONFIRMATION_PENDING,
doc="Status of the user account. SEE UserStatus",
doc="Current status of the user's account",
),
sa.Column(
"role",
sa.Enum(UserRole),
nullable=False,
default=UserRole.USER,
doc="Use for role-base authorization",
doc="Used for role-base authorization",
),
#
# User Privacy Rules ------------------
#
sa.Column(
"privacy_hide_fullname",
sa.Boolean,
nullable=False,
server_default=expression.true(),
doc="If true, it hides users.first_name, users.last_name to others",
),
sa.Column(
"privacy_hide_email",
sa.Boolean,
nullable=False,
server_default=expression.true(),
doc="If true, it hides users.email to others",
),
#
# Timestamps ---------------
#
sa.Column(
"created_at",
sa.DateTime(),
Expand Down
Loading

0 comments on commit 9a15e0c

Please sign in to comment.