Skip to content

Commit 30d6398

Browse files
committed
feat: add new pre-registration columns and update related user handling
1 parent 6513314 commit 30d6398

File tree

5 files changed

+217
-70
lines changed

5 files changed

+217
-70
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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("product_name", sa.String(), nullable=True),
48+
)
49+
op.drop_constraint(
50+
"users_pre_registration_details_pre_email_key",
51+
"users_pre_registration_details",
52+
type_="unique",
53+
)
54+
op.create_foreign_key(
55+
"fk_users_pre_registration_details_product_name",
56+
"users_pre_registration_details",
57+
"products",
58+
["product_name"],
59+
["name"],
60+
onupdate="CASCADE",
61+
ondelete="SET NULL",
62+
)
63+
# Set primary key on id column
64+
op.create_primary_key(
65+
"users_pre_registration_details_pk",
66+
"users_pre_registration_details",
67+
["id"],
68+
)
69+
# Add composite unique constraint on pre_email and product_name
70+
op.create_unique_constraint(
71+
"users_pre_registration_details_pre_email_product_name_key",
72+
"users_pre_registration_details",
73+
["pre_email", "product_name"],
74+
)
75+
# ### end Alembic commands ###
76+
77+
78+
def downgrade():
79+
# ### commands auto generated by Alembic - please adjust! ###
80+
# Drop the composite unique constraint
81+
op.drop_constraint(
82+
"users_pre_registration_details_pre_email_product_name_key",
83+
"users_pre_registration_details",
84+
type_="unique",
85+
)
86+
op.drop_constraint(
87+
"users_pre_registration_details_pk",
88+
"users_pre_registration_details",
89+
type_="primary",
90+
)
91+
op.drop_constraint(
92+
"fk_users_pre_registration_details_product_name",
93+
"users_pre_registration_details",
94+
type_="foreignkey",
95+
)
96+
op.create_unique_constraint(
97+
"users_pre_registration_details_pre_email_key",
98+
"users_pre_registration_details",
99+
["pre_email"],
100+
)
101+
op.drop_column("users_pre_registration_details", "product_name")
102+
op.drop_column("users_pre_registration_details", "account_request_status")
103+
op.drop_column("users_pre_registration_details", "id")
104+
105+
# Drop the enum type in downgrade
106+
sa.Enum(name="accountrequeststatus").drop(op.get_bind(), checkfirst=True)
107+
# ### end Alembic commands ###

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
"pre_email",
4545
sa.String(),
4646
nullable=False,
47-
unique=False, # Same email could be used for different products
4847
doc="Email of the user on pre-registration (copied to users.email upon registration)",
4948
),
5049
sa.Column(
@@ -68,7 +67,8 @@
6867
"account_request_status",
6968
sa.Enum(AccountRequestStatus),
7069
nullable=False,
71-
server_default=sa.text("'PENDING'::account_request_status"),
70+
server_default=AccountRequestStatus.PENDING.value,
71+
doc="Status of approval of the account request",
7272
),
7373
# Product the user is requesting access to
7474
sa.Column(
@@ -100,6 +100,13 @@
100100
column_created_by_user(users_table=users, required=False),
101101
column_created_datetime(timezone=False),
102102
column_modified_datetime(timezone=False),
103+
# CONSTRAINTS:
104+
# Composite unique constraint to ensure a user can only have one pre-registration per product
105+
sa.UniqueConstraint(
106+
"pre_email",
107+
"product_name",
108+
name="users_pre_registration_details_pre_email_product_name_key",
109+
),
103110
)
104111

105112
register_modified_datetime_auto_update_trigger(users_pre_registration_details)

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

Lines changed: 10 additions & 13 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,7 +138,7 @@ 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 details := result.first():
145142
await conn.execute(
146143
users.update()
147144
.where(users.c.id == new_user_id)

packages/postgres-database/tests/test_users_details.py

Lines changed: 86 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,75 +3,104 @@
33
# pylint: disable=unused-variable
44
# pylint: disable=too-many-arguments
55

6+
from collections.abc import AsyncIterable
67
from dataclasses import dataclass
8+
from typing import Any
79

810
import pytest
911
import sqlalchemy as sa
10-
from aiopg.sa.connection import SAConnection
1112
from aiopg.sa.result import RowProxy
1213
from faker import Faker
1314
from pytest_simcore.helpers.faker_factories import (
1415
random_pre_registration_details,
16+
random_product,
1517
random_user,
1618
)
19+
from pytest_simcore.helpers.postgres_tools import (
20+
insert_and_get_row_lifespan,
21+
)
22+
from simcore_postgres_database.models.products import products
1723
from simcore_postgres_database.models.users import UserRole, UserStatus, users
1824
from simcore_postgres_database.models.users_details import (
1925
users_pre_registration_details,
2026
)
27+
from simcore_postgres_database.utils_repos import (
28+
pass_or_acquire_connection,
29+
transaction_context,
30+
)
2131
from simcore_postgres_database.utils_users import UsersRepo
32+
from sqlalchemy.ext.asyncio import AsyncEngine
2233

2334

2435
@pytest.fixture
25-
async def po_user(
36+
async def product_name(
2637
faker: Faker,
27-
connection: SAConnection,
28-
):
29-
user_id = await connection.scalar(
30-
users.insert()
31-
.values(**random_user(faker, role=UserRole.PRODUCT_OWNER))
32-
.returning(users.c.id)
33-
)
34-
assert user_id
38+
asyncpg_engine: AsyncEngine,
39+
) -> AsyncIterable[str]:
40+
async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup
41+
asyncpg_engine,
42+
table=products,
43+
values=random_product(fake=faker, name="s4l"),
44+
pk_col=products.c.name,
45+
) as row:
46+
yield row["name"]
3547

36-
result = await connection.execute(sa.select(users).where(users.c.id == user_id))
37-
yield await result.first()
3848

39-
users.delete().where(users.c.id == user_id)
49+
@pytest.fixture
50+
async def po_user(
51+
faker: Faker,
52+
asyncpg_engine: AsyncEngine,
53+
) -> AsyncIterable[dict[str, Any]]:
54+
async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup
55+
asyncpg_engine,
56+
table=users,
57+
values=random_user(faker, role=UserRole.PRODUCT_OWNER),
58+
pk_col=users.c.id,
59+
) as row:
60+
yield row
4061

4162

4263
@pytest.mark.acceptance_test(
4364
"pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138"
4465
)
4566
async def test_user_creation_workflow(
46-
connection: SAConnection, faker: Faker, po_user: RowProxy
67+
asyncpg_engine: AsyncEngine,
68+
faker: Faker,
69+
po_user: dict[str, Any],
70+
product_name: str,
4771
):
4872
# a PO creates an invitation
4973
fake_pre_registration_data = random_pre_registration_details(
50-
faker, created_by=po_user.id
74+
faker, created_by=po_user["id"], product_name=product_name
5175
)
5276

53-
pre_email = await connection.scalar(
54-
sa.insert(users_pre_registration_details)
55-
.values(**fake_pre_registration_data)
56-
.returning(users_pre_registration_details.c.pre_email)
57-
)
77+
async with transaction_context(asyncpg_engine) as connection:
78+
pre_email = await connection.scalar(
79+
sa.insert(users_pre_registration_details)
80+
.values(**fake_pre_registration_data)
81+
.returning(users_pre_registration_details.c.pre_email)
82+
)
5883
assert pre_email is not None
5984
assert pre_email == fake_pre_registration_data["pre_email"]
6085

61-
# user gets created
62-
new_user = await UsersRepo.new_user(
63-
connection,
64-
email=pre_email,
65-
password_hash="123456", # noqa: S106
66-
status=UserStatus.ACTIVE,
67-
expires_at=None,
68-
)
69-
await UsersRepo.join_and_update_from_pre_registration_details(
70-
connection, new_user.id, new_user.email
71-
)
86+
async with transaction_context(asyncpg_engine) as connection:
87+
# user gets created
88+
new_user = await UsersRepo.new_user(
89+
connection,
90+
email=pre_email,
91+
password_hash="123456", # noqa: S106
92+
status=UserStatus.ACTIVE,
93+
expires_at=None,
94+
)
95+
await UsersRepo.link_and_update_user_from_pre_registration(
96+
connection, new_user_id=new_user.id, new_user_email=new_user.email
97+
)
7298

73-
invoice_data = await UsersRepo.get_billing_details(connection, user_id=new_user.id)
74-
assert invoice_data is not None
99+
async with pass_or_acquire_connection(asyncpg_engine) as connection:
100+
invoice_data = await UsersRepo.get_billing_details(
101+
connection, user_id=new_user.id
102+
)
103+
assert invoice_data is not None
75104

76105
# drafts converting data models from https://github.com/ITISFoundation/osparc-simcore/pull/5402
77106
@dataclass
@@ -84,7 +113,11 @@ class UserAddress:
84113

85114
@classmethod
86115
def create_from_db(cls, row: RowProxy):
87-
parts = (row[c] for c in ("institution", "address") if row[c])
116+
parts = (
117+
getattr(row, col_name)
118+
for col_name in ("institution", "address")
119+
if getattr(row, col_name)
120+
)
88121
return cls(
89122
line1=". ".join(parts),
90123
state=row.state,
@@ -110,28 +143,30 @@ def create_from_db(cls, row: RowProxy):
110143
assert user_address.country == fake_pre_registration_data["country"]
111144

112145
# now let's update the user
113-
result = await connection.execute(
114-
users.update()
115-
.values(first_name="My New Name")
116-
.where(users.c.id == new_user.id)
117-
.returning("*")
118-
)
119-
updated_user = await result.fetchone()
146+
async with transaction_context(asyncpg_engine) as connection:
147+
result = await connection.execute(
148+
users.update()
149+
.values(first_name="My New Name")
150+
.where(users.c.id == new_user.id)
151+
.returning("*")
152+
)
153+
updated_user = result.one()
120154

121155
assert updated_user
122156
assert updated_user.first_name == "My New Name"
123157
assert updated_user.id == new_user.id
124158

125159
for _ in range(2):
126-
await UsersRepo.join_and_update_from_pre_registration_details(
127-
connection, new_user.id, new_user.email
128-
)
160+
async with transaction_context(asyncpg_engine) as connection:
161+
await UsersRepo.link_and_update_user_from_pre_registration(
162+
connection, new_user_id=new_user.id, new_user_email=new_user.email
163+
)
129164

130-
result = await connection.execute(
131-
users.select().where(users.c.id == new_user.id)
132-
)
133-
current_user = await result.fetchone()
134-
assert current_user
165+
result = await connection.execute(
166+
users.select().where(users.c.id == new_user.id)
167+
)
168+
current_user = result.one()
169+
assert current_user
135170

136-
# overriden!
137-
assert current_user.first_name != updated_user.first_name
171+
# overriden!
172+
assert current_user.first_name != updated_user.first_name

0 commit comments

Comments
 (0)