Skip to content

Commit 25c813c

Browse files
authored
✨ Users pre-registration 🗃️ (#5391)
1 parent 03a6cd1 commit 25c813c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2026
-855
lines changed

api/specs/web-server/_users.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
from models_library.generics import Envelope
1212
from models_library.user_preferences import PreferenceIdentifier
1313
from simcore_service_webserver._meta import API_VTAG
14-
from simcore_service_webserver.users._handlers import (
15-
_NotificationPathParams,
16-
_TokenPathParams,
17-
)
14+
from simcore_service_webserver.users._handlers import PreUserProfile, _SearchQueryParams
1815
from simcore_service_webserver.users._notifications import (
1916
UserNotification,
2017
UserNotificationCreate,
2118
UserNotificationPatch,
2219
)
20+
from simcore_service_webserver.users._notifications_handlers import (
21+
_NotificationPathParams,
22+
)
23+
from simcore_service_webserver.users._schemas import UserProfile
24+
from simcore_service_webserver.users._tokens_handlers import _TokenPathParams
2325
from simcore_service_webserver.users.schemas import (
2426
PermissionGet,
2527
ProfileGet,
@@ -124,3 +126,14 @@ async def mark_notification_as_read(
124126
)
125127
async def list_user_permissions():
126128
...
129+
130+
131+
@router.get("/users:search", response_model=Envelope[list[UserProfile]])
132+
async def search_users(_params: Annotated[_SearchQueryParams, Depends()]):
133+
# NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods
134+
...
135+
136+
137+
@router.post("/users:pre-register", response_model=Envelope[UserProfile])
138+
async def pre_register_user(_body: PreUserProfile):
139+
...

packages/postgres-database/setup.cfg

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ commit_args = --no-verify
1010
[tool:pytest]
1111
asyncio_mode = auto
1212
addopts = -W error::sqlalchemy.exc.SAWarning
13-
markers =
13+
markers =
14+
acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows."
1415
testit: "marks test to run during development"
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""new users_pre_registration_details table
2+
3+
Revision ID: 35724106de75
4+
Revises: 20d60d2663ad
5+
Create Date: 2024-03-05 13:13:37.921956+00:00
6+
7+
"""
8+
from typing import Final
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "35724106de75"
15+
down_revision = "20d60d2663ad"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
# auto-update modified
21+
# TRIGGERS ------------------------
22+
_TABLE_NAME: Final[str] = "users_pre_registration_details"
23+
_TRIGGER_NAME: Final[str] = "trigger_auto_update" # NOTE: scoped on table
24+
_PROCEDURE_NAME: Final[
25+
str
26+
] = f"{_TABLE_NAME}_auto_update_modified()" # NOTE: scoped on database
27+
28+
modified_timestamp_trigger = sa.DDL(
29+
f"""
30+
DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};
31+
CREATE TRIGGER {_TRIGGER_NAME}
32+
BEFORE INSERT OR UPDATE ON {_TABLE_NAME}
33+
FOR EACH ROW EXECUTE PROCEDURE {_PROCEDURE_NAME};
34+
"""
35+
)
36+
37+
# PROCEDURES ------------------------
38+
update_modified_timestamp_procedure = sa.DDL(
39+
f"""
40+
CREATE OR REPLACE FUNCTION {_PROCEDURE_NAME}
41+
RETURNS TRIGGER AS $$
42+
BEGIN
43+
NEW.modified := current_timestamp;
44+
RETURN NEW;
45+
END;
46+
$$ LANGUAGE plpgsql;
47+
"""
48+
)
49+
50+
51+
def upgrade():
52+
# ### commands auto generated by Alembic - please adjust! ###
53+
op.create_table(
54+
"users_pre_registration_details",
55+
sa.Column("user_id", sa.Integer(), nullable=True),
56+
sa.Column("pre_email", sa.String(), nullable=False),
57+
sa.Column("pre_first_name", sa.String(), nullable=True),
58+
sa.Column("pre_last_name", sa.String(), nullable=True),
59+
sa.Column("pre_phone", sa.String(), nullable=True),
60+
sa.Column("company_name", sa.String(), nullable=True),
61+
sa.Column("address", sa.String(), nullable=True),
62+
sa.Column("city", sa.String(), nullable=True),
63+
sa.Column("state", sa.String(), nullable=True),
64+
sa.Column("country", sa.String(), nullable=True),
65+
sa.Column("postal_code", sa.String(), nullable=True),
66+
sa.Column("created_by", sa.Integer(), nullable=True),
67+
sa.Column(
68+
"created", sa.DateTime(), server_default=sa.text("now()"), nullable=False
69+
),
70+
sa.Column(
71+
"modified", sa.DateTime(), server_default=sa.text("now()"), nullable=False
72+
),
73+
sa.ForeignKeyConstraint(
74+
["created_by"], ["users.id"], onupdate="CASCADE", ondelete="SET NULL"
75+
),
76+
sa.ForeignKeyConstraint(
77+
["user_id"], ["users.id"], onupdate="CASCADE", ondelete="CASCADE"
78+
),
79+
sa.UniqueConstraint("pre_email"),
80+
)
81+
# ### end Alembic commands ###
82+
83+
# custom
84+
op.execute(update_modified_timestamp_procedure)
85+
op.execute(modified_timestamp_trigger)
86+
87+
88+
def downgrade():
89+
90+
# custom
91+
op.execute(f"DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};")
92+
op.execute(f"DROP FUNCTION {_PROCEDURE_NAME};")
93+
94+
# ### commands auto generated by Alembic - please adjust! ###
95+
op.drop_table("users_pre_registration_details")
96+
# ### end Alembic commands ###
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import sqlalchemy as sa
2+
3+
from ._common import (
4+
column_created_datetime,
5+
column_modified_datetime,
6+
register_modified_datetime_auto_update_trigger,
7+
)
8+
from .base import metadata
9+
from .users import users
10+
11+
users_pre_registration_details = sa.Table(
12+
"users_pre_registration_details",
13+
#
14+
# Provides extra attributes for a user that either not required or that are provided before the user is created.
15+
# The latter state is denoted as "pre-registration" and specific attributes in this state are prefixed with `pre_`. Therefore,
16+
# a row can be added in this table during pre-registration i.e. even before the `users` row exists.
17+
#
18+
metadata,
19+
sa.Column(
20+
"user_id",
21+
sa.Integer,
22+
sa.ForeignKey(
23+
users.c.id,
24+
onupdate="CASCADE",
25+
ondelete="CASCADE",
26+
),
27+
nullable=True,
28+
doc="None if row was added during pre-registration or join column with `users` after registration",
29+
),
30+
# Pre-registration columns: i.e. fields copied to `users` upon registration
31+
sa.Column(
32+
"pre_email",
33+
sa.String(),
34+
nullable=False,
35+
unique=True,
36+
doc="Email of the user on pre-registration (copied to users.email upon registration)",
37+
),
38+
sa.Column(
39+
"pre_first_name",
40+
sa.String(),
41+
doc="First name on pre-registration (copied to users.first_name upon registration)",
42+
),
43+
sa.Column(
44+
"pre_last_name",
45+
sa.String(),
46+
doc="Last name on pre-registration (copied to users.last_name upon registration)",
47+
),
48+
sa.Column(
49+
"pre_phone",
50+
sa.String(),
51+
doc="Phone provided on pre-registration (copied to users.phone upon registration)",
52+
),
53+
# Billable address columns:
54+
sa.Column("company_name", sa.String()),
55+
sa.Column("address", sa.String()),
56+
sa.Column("city", sa.String()),
57+
sa.Column("state", sa.String()),
58+
sa.Column("country", sa.String()),
59+
sa.Column("postal_code", sa.String()),
60+
# Other related users
61+
sa.Column(
62+
"created_by",
63+
sa.Integer,
64+
sa.ForeignKey(
65+
users.c.id,
66+
onupdate="CASCADE",
67+
ondelete="SET NULL",
68+
),
69+
nullable=True,
70+
doc="PO user that issued this pre-registration",
71+
),
72+
column_created_datetime(timezone=False),
73+
column_modified_datetime(timezone=False),
74+
)
75+
76+
register_modified_datetime_auto_update_trigger(users_pre_registration_details)

packages/postgres-database/src/simcore_postgres_database/utils_users.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .errors import UniqueViolation
1616
from .models.users import UserRole, UserStatus, users
17+
from .models.users_details import users_pre_registration_details
1718

1819

1920
class BaseUserRepoError(Exception):
@@ -76,6 +77,79 @@ async def new_user(
7677
assert row # nosec
7778
return row
7879

80+
@staticmethod
81+
async def join_and_update_from_pre_registration_details(
82+
conn: SAConnection, new_user_id: int, new_user_email: str
83+
) -> None:
84+
"""After a user is created, it can be associated with information provided during invitation
85+
86+
WARNING: Use ONLY upon new user creation. It might override user_details.user_id, users.first_name, users.last_name etc if already applied
87+
or changes happen in users table
88+
"""
89+
assert new_user_email # nosec
90+
assert new_user_id > 0 # nosec
91+
92+
# link both tables first
93+
result = await conn.execute(
94+
users_pre_registration_details.update()
95+
.where(users_pre_registration_details.c.pre_email == new_user_email)
96+
.values(user_id=new_user_id)
97+
)
98+
99+
if result.rowcount:
100+
pre_columns = (
101+
users_pre_registration_details.c.pre_first_name,
102+
users_pre_registration_details.c.pre_last_name,
103+
users_pre_registration_details.c.pre_phone,
104+
)
105+
106+
assert {c.name for c in pre_columns} == { # nosec
107+
c.name
108+
for c in users_pre_registration_details.columns
109+
if c != users_pre_registration_details.c.pre_email
110+
and c.name.startswith("pre_")
111+
}, "Different pre-cols detected. This code might need an update update"
112+
113+
result = await conn.execute(
114+
sa.select(*pre_columns).where(
115+
users_pre_registration_details.c.pre_email == new_user_email
116+
)
117+
)
118+
if details := await result.fetchone():
119+
await conn.execute(
120+
users.update()
121+
.where(users.c.id == new_user_id)
122+
.values(
123+
first_name=details.pre_first_name,
124+
last_name=details.pre_last_name,
125+
phone=details.pre_phone,
126+
)
127+
)
128+
129+
@staticmethod
130+
async def get_billing_details(conn: SAConnection, user_id: int) -> RowProxy | None:
131+
result = await conn.execute(
132+
sa.select(
133+
users.c.first_name,
134+
users.c.last_name,
135+
users_pre_registration_details.c.company_name,
136+
users_pre_registration_details.c.address,
137+
users_pre_registration_details.c.city,
138+
users_pre_registration_details.c.state,
139+
users_pre_registration_details.c.country,
140+
users_pre_registration_details.c.postal_code,
141+
users.c.phone,
142+
)
143+
.select_from(
144+
users.join(
145+
users_pre_registration_details,
146+
users.c.id == users_pre_registration_details.c.user_id,
147+
)
148+
)
149+
.where(users.c.id == user_id)
150+
)
151+
return await result.fetchone()
152+
79153
@staticmethod
80154
async def get_role(conn: SAConnection, user_id: int) -> UserRole:
81155
value: UserRole | None = await conn.scalar(
@@ -110,3 +184,23 @@ async def get_active_user_email(conn: SAConnection, user_id: int) -> str:
110184
return value
111185

112186
raise UserNotFoundInRepoError
187+
188+
@staticmethod
189+
async def is_email_used(conn: SAConnection, email: str) -> bool:
190+
email = email.lower()
191+
192+
registered = await conn.scalar(
193+
sa.select(users.c.id).where(users.c.email == email)
194+
)
195+
if registered:
196+
return True
197+
198+
pre_registered = await conn.scalar(
199+
sa.select(users_pre_registration_details.c.user_id).where(
200+
users_pre_registration_details.c.pre_email == email
201+
)
202+
)
203+
if pre_registered:
204+
return True
205+
206+
return False

0 commit comments

Comments
 (0)