Skip to content

Commit 501de76

Browse files
authored
🎨 database+web-server: Extending user pre-registration workflow and asyncpg upgrades 🗃️ (#7709)
1 parent 00e6733 commit 501de76

File tree

18 files changed

+1147
-168
lines changed

18 files changed

+1147
-168
lines changed

packages/common-library/src/common_library/users_enums.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,11 @@ class UserStatus(str, Enum):
5757
BANNED = "BANNED"
5858
# This user is inactive because it was marked for deletion
5959
DELETED = "DELETED"
60+
61+
62+
class AccountRequestStatus(str, Enum):
63+
"""Status of the request for an account"""
64+
65+
PENDING = "PENDING" # Pending PO review to approve/reject the request
66+
APPROVED = "APPROVED" # PO approved the request
67+
REJECTED = "REJECTED" # PO rejected the request
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""new pre-registration columns
2+
3+
Revision ID: ba9c4816a31b
4+
Revises: b39f2dc87ccd
5+
Create Date: 2025-05-19 15:21:40.182354+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "ba9c4816a31b"
14+
down_revision = "b39f2dc87ccd"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
# Create the enum type first before using it
22+
account_request_status = sa.Enum(
23+
"PENDING", "APPROVED", "REJECTED", name="accountrequeststatus"
24+
)
25+
account_request_status.create(op.get_bind(), checkfirst=True)
26+
27+
op.add_column(
28+
"users_pre_registration_details",
29+
sa.Column(
30+
"id",
31+
sa.BigInteger(),
32+
sa.Identity(always=False, start=1, cycle=False),
33+
nullable=False,
34+
),
35+
)
36+
op.add_column(
37+
"users_pre_registration_details",
38+
sa.Column(
39+
"account_request_status",
40+
account_request_status, # Use the created enum type
41+
server_default="PENDING", # Simply use the string value as default
42+
nullable=False,
43+
),
44+
)
45+
op.add_column(
46+
"users_pre_registration_details",
47+
sa.Column(
48+
"account_request_reviewed_by",
49+
sa.Integer(),
50+
nullable=True,
51+
),
52+
)
53+
op.add_column(
54+
"users_pre_registration_details",
55+
sa.Column(
56+
"account_request_reviewed_at",
57+
sa.DateTime(timezone=True),
58+
nullable=True,
59+
),
60+
)
61+
op.add_column(
62+
"users_pre_registration_details",
63+
sa.Column("product_name", sa.String(), nullable=True),
64+
)
65+
op.drop_constraint(
66+
"users_pre_registration_details_pre_email_key",
67+
"users_pre_registration_details",
68+
type_="unique",
69+
)
70+
op.create_foreign_key(
71+
"fk_users_pre_registration_details_product_name",
72+
"users_pre_registration_details",
73+
"products",
74+
["product_name"],
75+
["name"],
76+
onupdate="CASCADE",
77+
ondelete="SET NULL",
78+
)
79+
# Add foreign key for account_request_reviewed_by
80+
op.create_foreign_key(
81+
"fk_users_pre_registration_reviewed_by_user_id",
82+
"users_pre_registration_details",
83+
"users",
84+
["account_request_reviewed_by"],
85+
["id"],
86+
onupdate="CASCADE",
87+
ondelete="SET NULL",
88+
)
89+
# Set primary key on id column
90+
op.create_primary_key(
91+
"users_pre_registration_details_pk",
92+
"users_pre_registration_details",
93+
["id"],
94+
)
95+
# Add composite unique constraint on pre_email and product_name
96+
op.create_unique_constraint(
97+
"users_pre_registration_details_pre_email_product_name_key",
98+
"users_pre_registration_details",
99+
["pre_email", "product_name"],
100+
)
101+
# ### end Alembic commands ###
102+
103+
104+
def downgrade():
105+
# ### commands auto generated by Alembic - please adjust! ###
106+
# Drop the composite unique constraint
107+
op.drop_constraint(
108+
"users_pre_registration_details_pre_email_product_name_key",
109+
"users_pre_registration_details",
110+
type_="unique",
111+
)
112+
op.drop_constraint(
113+
"users_pre_registration_details_pk",
114+
"users_pre_registration_details",
115+
type_="primary",
116+
)
117+
op.drop_constraint(
118+
"fk_users_pre_registration_reviewed_by_user_id",
119+
"users_pre_registration_details",
120+
type_="foreignkey",
121+
)
122+
op.drop_constraint(
123+
"fk_users_pre_registration_details_product_name",
124+
"users_pre_registration_details",
125+
type_="foreignkey",
126+
)
127+
op.create_unique_constraint(
128+
"users_pre_registration_details_pre_email_key",
129+
"users_pre_registration_details",
130+
["pre_email"],
131+
)
132+
op.drop_column("users_pre_registration_details", "product_name")
133+
op.drop_column("users_pre_registration_details", "account_request_reviewed_at")
134+
op.drop_column("users_pre_registration_details", "account_request_reviewed_by")
135+
op.drop_column("users_pre_registration_details", "account_request_status")
136+
op.drop_column("users_pre_registration_details", "id")
137+
138+
# Drop the enum type in downgrade
139+
sa.Enum(name="accountrequeststatus").drop(op.get_bind(), checkfirst=True)
140+
# ### end Alembic commands ###

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

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import sqlalchemy as sa
2+
from common_library.users_enums import AccountRequestStatus
23
from sqlalchemy.dialects import postgresql
34

45
from ._common import (
56
RefActions,
7+
column_created_by_user,
68
column_created_datetime,
79
column_modified_datetime,
810
register_modified_datetime_auto_update_trigger,
911
)
1012
from .base import metadata
13+
from .products import products # Import the products table
1114
from .users import users
1215

1316
users_pre_registration_details = sa.Table(
@@ -18,6 +21,13 @@
1821
# a row can be added in this table during pre-registration i.e. even before the `users` row exists.
1922
#
2023
metadata,
24+
sa.Column(
25+
"id",
26+
sa.BigInteger,
27+
sa.Identity(start=1, cycle=False),
28+
primary_key=True,
29+
doc="Primary key for the pre-registration entry",
30+
),
2131
sa.Column(
2232
"user_id",
2333
sa.Integer,
@@ -34,7 +44,6 @@
3444
"pre_email",
3545
sa.String(),
3646
nullable=False,
37-
unique=True,
3847
doc="Email of the user on pre-registration (copied to users.email upon registration)",
3948
),
4049
sa.Column(
@@ -53,6 +62,45 @@
5362
doc="Phone provided on pre-registration"
5463
"NOTE: this is not copied upon registration since it needs to be confirmed",
5564
),
65+
# Account Request
66+
sa.Column(
67+
"account_request_status",
68+
sa.Enum(AccountRequestStatus),
69+
nullable=False,
70+
server_default=AccountRequestStatus.PENDING.value,
71+
doc="Status of review for the account request",
72+
),
73+
sa.Column(
74+
"account_request_reviewed_by",
75+
sa.Integer,
76+
sa.ForeignKey(
77+
users.c.id,
78+
onupdate=RefActions.CASCADE,
79+
ondelete=RefActions.SET_NULL,
80+
name="fk_users_pre_registration_reviewed_by_user_id",
81+
),
82+
nullable=True,
83+
doc="Tracks who approved or rejected the account request",
84+
),
85+
sa.Column(
86+
"account_request_reviewed_at",
87+
sa.DateTime(timezone=True),
88+
nullable=True,
89+
doc="Timestamp when the account request was reviewed",
90+
),
91+
# Product the user is requesting access to
92+
sa.Column(
93+
"product_name",
94+
sa.String,
95+
sa.ForeignKey(
96+
products.c.name,
97+
onupdate=RefActions.CASCADE,
98+
ondelete=RefActions.SET_NULL,
99+
name="fk_users_pre_registration_details_product_name",
100+
),
101+
nullable=True,
102+
doc="Product that the user is requesting an account for",
103+
),
56104
# Billable address columns:
57105
sa.Column("institution", sa.String(), doc="the name of a company or university"),
58106
sa.Column("address", sa.String()),
@@ -67,19 +115,16 @@
67115
doc="Extra information provided in the form but still not defined as a column.",
68116
),
69117
# Other related users
70-
sa.Column(
71-
"created_by",
72-
sa.Integer,
73-
sa.ForeignKey(
74-
users.c.id,
75-
onupdate=RefActions.CASCADE,
76-
ondelete=RefActions.SET_NULL,
77-
),
78-
nullable=True,
79-
doc="PO user that issued this pre-registration",
80-
),
118+
column_created_by_user(users_table=users, required=False),
81119
column_created_datetime(timezone=False),
82120
column_modified_datetime(timezone=False),
121+
# CONSTRAINTS:
122+
# Composite unique constraint to ensure a user can only have one pre-registration per product
123+
sa.UniqueConstraint(
124+
"pre_email",
125+
"product_name",
126+
name="users_pre_registration_details_pre_email_product_name_key",
127+
),
83128
)
84129

85130
register_modified_datetime_auto_update_trigger(users_pre_registration_details)

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

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,15 @@ async def new_user(
8888
users.c.status,
8989
).where(users.c.id == user_id)
9090
)
91-
row = await maybe_await(result.first())
92-
from aiopg.sa.result import RowProxy
93-
94-
assert isinstance(row, RowProxy) # nosec
95-
return row
91+
return await maybe_await(result.first())
9692

9793
@staticmethod
98-
async def join_and_update_from_pre_registration_details(
99-
conn: DBConnection, new_user_id: int, new_user_email: str
94+
async def link_and_update_user_from_pre_registration(
95+
conn: DBConnection,
96+
*,
97+
new_user_id: int,
98+
new_user_email: str,
99+
update_user: bool = True,
100100
) -> None:
101101
"""After a user is created, it can be associated with information provided during invitation
102102
@@ -113,11 +113,8 @@ async def join_and_update_from_pre_registration_details(
113113
.values(user_id=new_user_id)
114114
)
115115

116-
from aiopg.sa.result import ResultProxy
117-
118-
assert isinstance(result, ResultProxy) # nosec
119-
120-
if result.rowcount:
116+
if update_user:
117+
# COPIES some pre-registration details to the users table
121118
pre_columns = (
122119
users_pre_registration_details.c.pre_first_name,
123120
users_pre_registration_details.c.pre_last_name,
@@ -141,13 +138,14 @@ async def join_and_update_from_pre_registration_details(
141138
users_pre_registration_details.c.pre_email == new_user_email
142139
)
143140
)
144-
if details := await maybe_await(result.fetchone()):
141+
if pre_registration_details_data := result.first():
142+
# NOTE: could have many products! which to use?
145143
await conn.execute(
146144
users.update()
147145
.where(users.c.id == new_user_id)
148146
.values(
149-
first_name=details.pre_first_name, # type: ignore[union-attr]
150-
last_name=details.pre_last_name, # type: ignore[union-attr]
147+
first_name=pre_registration_details_data.pre_first_name, # type: ignore[union-attr]
148+
last_name=pre_registration_details_data.pre_last_name, # type: ignore[union-attr]
151149
)
152150
)
153151

0 commit comments

Comments
 (0)