Skip to content

Commit

Permalink
🎨 Extends user table with username and first/second name for a user 🗃️ (
Browse files Browse the repository at this point in the history
  • Loading branch information
pcrespov authored Jan 16, 2024
1 parent 942bd7f commit 0cdb35b
Show file tree
Hide file tree
Showing 48 changed files with 693 additions and 469 deletions.
8 changes: 4 additions & 4 deletions api/specs/web-server/_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
PermissionGet,
ProfileGet,
ProfileUpdate,
Token,
ThirdPartyToken,
TokenCreate,
)

Expand Down Expand Up @@ -60,15 +60,15 @@ async def set_frontend_preference(

@router.get(
"/me/tokens",
response_model=Envelope[list[Token]],
response_model=Envelope[list[ThirdPartyToken]],
)
async def list_tokens():
...


@router.post(
"/me/tokens",
response_model=Envelope[Token],
response_model=Envelope[ThirdPartyToken],
status_code=status.HTTP_201_CREATED,
)
async def create_token(_token: TokenCreate):
Expand All @@ -77,7 +77,7 @@ async def create_token(_token: TokenCreate):

@router.get(
"/me/tokens/{service}",
response_model=Envelope[Token],
response_model=Envelope[ThirdPartyToken],
)
async def get_token(_params: Annotated[_TokenPathParams, Depends()]):
...
Expand Down
35 changes: 20 additions & 15 deletions packages/models-library/src/models_library/projects_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@
Ownership and access rights
"""

import re
from enum import Enum
from typing import Any, ClassVar

from models_library.basic_types import IDStr
from models_library.users import FirstNameStr, LastNameStr
from pydantic import BaseModel, Extra, Field
from pydantic.types import ConstrainedStr, PositiveInt
from pydantic.types import PositiveInt


class GroupIDStr(ConstrainedStr):
regex = re.compile(r"^\S+$")

class Config:
frozen = True
class GroupIDStr(IDStr):
...


class AccessEnum(str, Enum):
Expand All @@ -23,9 +22,9 @@ class AccessEnum(str, Enum):


class AccessRights(BaseModel):
read: bool = Field(..., description="gives read access")
write: bool = Field(..., description="gives write access")
delete: bool = Field(..., description="gives deletion rights")
read: bool = Field(..., description="has read access")
write: bool = Field(..., description="has write access")
delete: bool = Field(..., description="has deletion rights")

class Config:
extra = Extra.forbid
Expand All @@ -41,12 +40,18 @@ def __modify_schema__(cls, field_schema):

class Owner(BaseModel):
user_id: PositiveIntWithExclusiveMinimumRemoved = Field(
...,
description="Owner's identifier when registered in the user's database table",
examples=[2],
..., description="Owner's user id"
)
first_name: str = Field(..., description="Owner first name", examples=["John"])
last_name: str = Field(..., description="Owner last name", examples=["Smith"])
first_name: FirstNameStr | None = Field(..., description="Owner's first name")
last_name: LastNameStr | None = Field(..., description="Owner's last name")

class Config:
extra = Extra.forbid
schema_extra: ClassVar[dict[str, Any]] = {
"examples": [
# NOTE: None and empty string are both defining an undefined value
{"user_id": 1, "first_name": None, "last_name": None},
{"user_id": 2, "first_name": "", "last_name": ""},
{"user_id": 3, "first_name": "John", "last_name": "Smith"},
]
}
11 changes: 10 additions & 1 deletion packages/models-library/src/models_library/users.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from typing import TypeAlias

from pydantic import PositiveInt
from pydantic import ConstrainedStr, PositiveInt

UserID: TypeAlias = PositiveInt
GroupID: TypeAlias = PositiveInt


class FirstNameStr(ConstrainedStr):
strip_whitespace = True
max_length = 255


class LastNameStr(FirstNameStr):
...
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""new user name cols
Revision ID: f9f9a650bf4b
Revises: 392a86f2e446
Create Date: 2024-01-12 06:29:40.364669+00:00
"""
import re
import secrets
import string

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "f9f9a650bf4b"
down_revision = "392a86f2e446"
branch_labels = None
depends_on = None

SEPARATOR = "." # Based on this info UserNameConverter: 'first_name.lastname'


def upgrade():
# new columns
op.add_column("users", sa.Column("first_name", sa.String(), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(), nullable=True))

# fill new and update existing
connection = op.get_bind()
result = connection.execute(sa.text("SELECT id, name FROM users"))

used = set()

for user_id, name in result:
# from name -> generate name
new_name = re.sub(r"[^a-zA-Z0-9]", "", name).lower()
while new_name in used:
new_name += f"{''.join(secrets.choice(string.digits) for _ in range(4))}"

# from name -> create first_name, last_name
parts = name.split(SEPARATOR, 1)
first_name = parts[0].capitalize()
last_name = parts[1].capitalize() if len(parts) == 2 else None

query = sa.text(
"UPDATE users SET first_name=:first, last_name=:last, name=:uname WHERE id=:id"
)
values = {
"first": first_name,
"last": last_name,
"id": user_id,
"uname": new_name,
}

connection.execute(query, values)
used.add(new_name)

op.create_unique_constraint("user_name_ukey", "users", ["name"])


def downgrade():
connection = op.get_bind()
op.drop_constraint("user_name_ukey", "users", type_="unique")

result = connection.execute(sa.text("SELECT id, first_name, last_name FROM users"))

for user_id, first_name, last_name in result:
name = f"{first_name or ''}.{last_name or ''}".strip(".")
connection.execute(
sa.text("UPDATE users SET name=:name WHERE id=:id"),
{"name": name, "id": user_id},
)

# delete
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
""" Users table
- List of users in the framework
- Users they have a role within the framework that provides
them different access levels to it
"""
from enum import Enum
from functools import total_ordering
from typing import Final, NamedTuple

import sqlalchemy as sa

Expand Down Expand Up @@ -78,32 +71,47 @@ class UserStatus(Enum):
metadata,
sa.Column(
"id",
sa.BigInteger,
sa.BigInteger(),
nullable=False,
doc="Primary key for user identifier",
doc="Primary key index for user identifier",
),
sa.Column(
"name",
sa.String,
sa.String(),
nullable=False,
doc="Display name. NOTE: this is NOT a user name since uniqueness is NOT guaranteed",
doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ...",
),
sa.Column(
"first_name",
sa.String(),
doc="User's first name",
),
sa.Column(
"last_name",
sa.String(),
doc="User's last/family name",
),
sa.Column(
"email",
sa.String,
sa.String(),
nullable=False,
doc="User email is used as username since it is a unique human-readable identifier",
doc="Validated email",
),
sa.Column(
"phone",
sa.String,
sa.String(),
nullable=True, # since 2FA can be configured optional
doc="Confirmed user phone used e.g. to send a code for a two-factor-authentication",
),
sa.Column("password_hash", sa.String, nullable=False),
sa.Column(
"password_hash",
sa.String(),
nullable=False,
doc="Hashed password",
),
sa.Column(
"primary_gid",
sa.BigInteger,
sa.BigInteger(),
sa.ForeignKey(
"groups.gid",
name="fk_users_gid_groups",
Expand Down Expand Up @@ -150,6 +158,7 @@ class UserStatus(Enum):
),
# ---------------------------
sa.PrimaryKeyConstraint("id", name="user_pkey"),
sa.UniqueConstraint("name", name="user_name_ukey"),
sa.UniqueConstraint("email", name="user_login_key"),
sa.UniqueConstraint(
"phone",
Expand All @@ -159,50 +168,6 @@ class UserStatus(Enum):
)


class FullNameTuple(NamedTuple):
first_name: str
last_name: str


class UserNameConverter:
"""Helper functions to convert full-name to name in both directions"""

#
# CONVENTION: Instead of having first and last name in the database
# we collapse it in the column name as 'first_name.lastname'.
#
# NOTE: there is a plan to change this https://github.com/ITISFoundation/osparc-simcore/issues/1574
SEPARATOR: Final[str] = "."
TOKEN: Final[str] = "#"

@classmethod
def get_full_name(cls, name: str) -> FullNameTuple:
"""Parses value from users.name and returns separated full and last name in a tuple"""
first_name, last_name = name, ""

if cls.SEPARATOR in name:
first_name, last_name = name.split(cls.SEPARATOR, maxsplit=1)

return FullNameTuple(
first_name.replace(cls.TOKEN, cls.SEPARATOR),
last_name.replace(cls.TOKEN, cls.SEPARATOR),
)

@classmethod
def _safe_string(cls, value: str) -> str:
# removes any possible token in value (unlikely)
value = value.replace(cls.TOKEN, "")
# substitutes matching separators symbol with an alternative
return value.replace(cls.SEPARATOR, cls.TOKEN)

@classmethod
def get_name(cls, first_name: str, last_name: str) -> str:
"""Composes value for users.name column"""
return (
cls._safe_string(first_name) + cls.SEPARATOR + cls._safe_string(last_name)
)


# ------------------------ TRIGGERS

new_user_trigger = sa.DDL(
Expand All @@ -228,7 +193,7 @@ def get_name(cls, first_name: str, last_name: str) -> str:
INSERT INTO "groups" ("name", "description", "type") VALUES (NEW.name, 'primary group', 'PRIMARY') RETURNING gid INTO group_id;
INSERT INTO "user_to_groups" ("uid", "gid") VALUES (NEW.id, group_id);
UPDATE "users" SET "primary_gid" = group_id WHERE "id" = NEW.id;
-- set everyone goup
-- set everyone group
INSERT INTO "user_to_groups" ("uid", "gid") VALUES (NEW.id, (SELECT "gid" FROM "groups" WHERE "type" = 'EVERYONE'));
ELSIF TG_OP = 'UPDATE' THEN
UPDATE "groups" SET "name" = NEW.name WHERE "gid" = NEW.primary_gid;
Expand All @@ -240,7 +205,11 @@ def get_name(cls, first_name: str, last_name: str) -> str:
"""
)

sa.event.listen(users, "after_create", set_user_groups_procedure)
sa.event.listen(
users,
"after_create",
set_user_groups_procedure,
)
sa.event.listen(
users,
"after_create",
Expand Down
Loading

0 comments on commit 0cdb35b

Please sign in to comment.