Skip to content

Commit 95417c0

Browse files
committed
✨ Implement ConfirmationRepository for managing user confirmation tokens
1 parent d928d88 commit 95417c0

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import logging
2+
from datetime import datetime
3+
from typing import Any
4+
5+
import sqlalchemy as sa
6+
from models_library.users import UserID
7+
from servicelib.utils_secrets import generate_passcode
8+
from simcore_postgres_database.models.confirmations import confirmations
9+
from simcore_postgres_database.models.users import users
10+
from simcore_postgres_database.utils_repos import (
11+
pass_or_acquire_connection,
12+
transaction_context,
13+
)
14+
from sqlalchemy.engine import Row
15+
from sqlalchemy.ext.asyncio import AsyncConnection
16+
17+
from ..db.base_repository import BaseRepository
18+
from ._models import ActionLiteralStr, Confirmation
19+
20+
_logger = logging.getLogger(__name__)
21+
22+
23+
def _to_domain(confirmation_row: Row) -> Confirmation:
24+
return Confirmation.model_validate(confirmation_row)
25+
26+
27+
class ConfirmationRepository(BaseRepository):
28+
29+
async def create_confirmation(
30+
self,
31+
connection: AsyncConnection | None = None,
32+
*,
33+
user_id: UserID,
34+
action: ActionLiteralStr,
35+
data: str | None = None,
36+
) -> Confirmation:
37+
"""Create a new confirmation token for a user action."""
38+
async with pass_or_acquire_connection(self.engine, connection) as conn:
39+
# Generate unique code
40+
while True:
41+
# NOTE: use only numbers since front-end does not handle well url encoding
42+
numeric_code: str = generate_passcode(20)
43+
44+
# Check if code already exists
45+
check_query = sa.select(confirmations.c.code).where(
46+
confirmations.c.code == numeric_code
47+
)
48+
result = await conn.execute(check_query)
49+
if result.one_or_none() is None:
50+
break
51+
52+
# Insert confirmation
53+
insert_query = (
54+
sa.insert(confirmations)
55+
.values(
56+
code=numeric_code,
57+
user_id=user_id,
58+
action=action,
59+
data=data,
60+
created_at=datetime.utcnow(),
61+
)
62+
.returning(*confirmations.c)
63+
)
64+
65+
result = await conn.execute(insert_query)
66+
row = result.one()
67+
return _to_domain(row)
68+
69+
async def get_confirmation(
70+
self,
71+
connection: AsyncConnection | None = None,
72+
*,
73+
filter_dict: dict[str, Any],
74+
) -> Confirmation | None:
75+
"""Get a confirmation by filter criteria."""
76+
# Handle legacy "user" key
77+
if "user" in filter_dict:
78+
filter_dict["user_id"] = filter_dict.pop("user")["id"]
79+
80+
# Build where conditions
81+
where_conditions = []
82+
for key, value in filter_dict.items():
83+
if hasattr(confirmations.c, key):
84+
where_conditions.append(getattr(confirmations.c, key) == value)
85+
86+
query = sa.select(*confirmations.c).where(sa.and_(*where_conditions))
87+
88+
async with pass_or_acquire_connection(self.engine, connection) as conn:
89+
result = await conn.execute(query)
90+
if row := result.one_or_none():
91+
return _to_domain(row)
92+
return None
93+
94+
async def delete_confirmation(
95+
self,
96+
connection: AsyncConnection | None = None,
97+
*,
98+
confirmation: Confirmation,
99+
) -> None:
100+
"""Delete a confirmation token."""
101+
query = sa.delete(confirmations).where(
102+
confirmations.c.code == confirmation.code
103+
)
104+
105+
async with pass_or_acquire_connection(self.engine, connection) as conn:
106+
await conn.execute(query)
107+
108+
async def delete_confirmation_and_user(
109+
self,
110+
connection: AsyncConnection | None = None,
111+
*,
112+
user_id: UserID,
113+
confirmation: Confirmation,
114+
) -> None:
115+
"""Atomically delete confirmation and user."""
116+
async with transaction_context(self.engine, connection) as conn:
117+
# Delete confirmation
118+
await conn.execute(
119+
sa.delete(confirmations).where(
120+
confirmations.c.code == confirmation.code
121+
)
122+
)
123+
124+
# Delete user
125+
await conn.execute(sa.delete(users).where(users.c.id == user_id))
126+
127+
async def delete_confirmation_and_update_user(
128+
self,
129+
connection: AsyncConnection | None = None,
130+
*,
131+
user_id: UserID,
132+
updates: dict[str, Any],
133+
confirmation: Confirmation,
134+
) -> None:
135+
"""Atomically delete confirmation and update user."""
136+
async with transaction_context(self.engine, connection) as conn:
137+
# Delete confirmation
138+
await conn.execute(
139+
sa.delete(confirmations).where(
140+
confirmations.c.code == confirmation.code
141+
)
142+
)
143+
144+
# Update user
145+
await conn.execute(
146+
sa.update(users).where(users.c.id == user_id).values(**updates)
147+
)

services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ async def delete_confirmation_and_update_user(
125125
conn, self.user_tbl, {"id": user_id}, updates
126126
)
127127

128+
# NOTE: This class is deprecated. Use ConfirmationRepository instead.
129+
# Keeping for backwards compatibility during migration.
130+
128131

129132
def get_plugin_storage(app: web.Application) -> AsyncpgStorage:
130133
storage = cast(AsyncpgStorage, app.get(APP_LOGIN_STORAGE_KEY))

services/web/server/src/simcore_service_webserver/login/_models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
from collections.abc import Callable
2+
from datetime import datetime
3+
from typing import Literal
24

5+
from models_library.users import UserID
36
from pydantic import BaseModel, ConfigDict, SecretStr, ValidationInfo
47

58
from .constants import MSG_PASSWORD_MISMATCH
69

10+
ActionLiteralStr = Literal[
11+
"REGISTRATION", "INVITATION", "RESET_PASSWORD", "CHANGE_EMAIL"
12+
]
13+
14+
15+
class Confirmation(BaseModel):
16+
model_config = ConfigDict(from_attributes=True)
17+
18+
code: str
19+
user_id: UserID
20+
action: ActionLiteralStr
21+
data: str | None
22+
created_at: datetime
23+
724

825
class InputSchema(BaseModel):
926
model_config = ConfigDict(

0 commit comments

Comments
 (0)