Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3389868
✨ Implement ConfirmationRepository for managing user confirmation tokens
pcrespov Jul 23, 2025
c25904a
✨ Refactor confirmation service and repository integration for improv…
pcrespov Jul 23, 2025
988e059
✨ Enhance confirmation service integration: add methods for managing …
pcrespov Jul 23, 2025
db89598
✨ Refactor invitation service: integrate confirmation service and rem…
pcrespov Jul 23, 2025
601fe0b
✨ Refactor confirmation service integration: centralize service retri…
pcrespov Jul 23, 2025
c62bed5
✨ Refactor login module: replace legacy repository with confirmation …
pcrespov Jul 23, 2025
3575989
rm legacy
pcrespov Jul 23, 2025
d4bdb58
rm legacy and move models
pcrespov Jul 23, 2025
b19eb75
imports
pcrespov Jul 28, 2025
8d69500
new column
pcrespov Jul 28, 2025
adff46c
migration
pcrespov Jul 28, 2025
e715324
repositions migration
pcrespov Sep 11, 2025
0f14b2b
fix: update down_revision in migration and import app_setup_func in g…
pcrespov Sep 25, 2025
838783e
write operations
pcrespov Sep 25, 2025
4ba3d20
fixes test
pcrespov Sep 25, 2025
fe4fc57
fixe pylint
pcrespov Sep 25, 2025
85d7da7
fix: enhance error logging in change email process and correct variab…
pcrespov Sep 25, 2025
661099c
minor fix
pcrespov Sep 25, 2025
e1bff2b
feat: implement confirmation service setup and integrate with invitat…
pcrespov Sep 26, 2025
0c39aa2
Merge branch 'master' into is40/refactoring-confirmation-repository
pcrespov Sep 26, 2025
51b00a6
Merge branch 'master' into is40/refactoring-confirmation-repository
pcrespov Sep 26, 2025
685a8d2
Merge branch 'master' into is40/refactoring-confirmation-repository
pcrespov Sep 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""update confirmation created column

Revision ID: 9dddb16914a4
Revises: 06eafd25d004
Create Date: 2025-07-28 17:25:06.534720+00:00

"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "9dddb16914a4"
down_revision = "7e92447558e0"
branch_labels = None
depends_on = None


def upgrade():
# Step 1: Add new column as nullable first
op.add_column(
"confirmations",
sa.Column(
"created",
sa.DateTime(timezone=True),
nullable=True,
),
)

# Step 2: Copy data from created_at to created, assuming UTC timezone for existing data
op.execute(
"UPDATE confirmations SET created = created_at AT TIME ZONE 'UTC' WHERE created_at IS NOT NULL"
)

# Step 3: Make the column non-nullable with default
op.alter_column(
"confirmations",
"created",
nullable=False,
server_default=sa.text("now()"),
)

# Step 4: Drop old column
op.drop_column("confirmations", "created_at")


def downgrade():
# Step 1: Add back the old column
op.add_column(
"confirmations",
sa.Column(
"created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
)

# Step 2: Copy data back, converting timezone-aware to naive timestamp
op.execute(
"UPDATE confirmations SET created_at = created AT TIME ZONE 'UTC' WHERE created IS NOT NULL"
)

# Step 3: Make the column non-nullable
op.alter_column(
"confirmations",
"created_at",
nullable=False,
)

# Step 4: Drop new column
op.drop_column("confirmations", "created")
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
""" User's confirmations table
"""User's confirmations table

- Keeps a list of tokens to identify an action (registration, invitation, reset, etc) authorized
by link to a a user in the framework
- These tokens have an expiration date defined by configuration
- Keeps a list of tokens to identify an action (registration, invitation, reset, etc) authorized
by link to a a user in the framework
- These tokens have an expiration date defined by configuration

"""

import enum

import sqlalchemy as sa

from ._common import RefActions
from ._common import RefActions, column_created_datetime
from .base import metadata
from .users import users

Expand Down Expand Up @@ -47,12 +48,8 @@ class ConfirmationAction(enum.Enum):
sa.Text,
doc="Extra data associated to the action. SEE handlers_confirmation.py::email_confirmation",
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
# NOTE: that here it would be convenient to have a server_default=now()!
doc="Creation date of this code."
column_created_datetime(
doc="Creation date of this code. "
"Can be used as reference to determine the expiration date. SEE ${ACTION}_CONFIRMATION_LIFETIME",
),
# constraints ----------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

from aiohttp.test_utils import TestClient
from servicelib.aiohttp import status
from simcore_service_webserver.login._invitations_service import create_invitation_token
from simcore_service_webserver.login._login_repository_legacy import (
get_plugin_storage,
from simcore_service_webserver.login._controller.rest._rest_dependencies import (
get_confirmation_service,
)
from simcore_service_webserver.login._invitations_service import create_invitation_token
from simcore_service_webserver.login.constants import MSG_LOGGED_IN
from simcore_service_webserver.security import security_service
from yarl import URL
Expand Down Expand Up @@ -132,15 +132,15 @@ def __init__(
self.confirmation = None
self.trial_days = trial_days
self.extra_credits_in_usd = extra_credits_in_usd
self.db = get_plugin_storage(self.app)
self.confirmation_service = get_confirmation_service(client.app)

async def __aenter__(self) -> "NewInvitation":
# creates host user
assert self.client.app
self.user = await super().__aenter__()

self.confirmation = await create_invitation_token(
self.db,
self.client.app,
user_id=self.user["id"],
user_email=self.user["email"],
tag=self.tag,
Expand All @@ -150,5 +150,11 @@ async def __aenter__(self) -> "NewInvitation":
return self

async def __aexit__(self, *args):
if await self.db.get_confirmation(self.confirmation):
await self.db.delete_confirmation(self.confirmation)
if self.confirmation:
# Try to get confirmation by filter and delete if it exists
confirmation = await self.confirmation_service.get_confirmation(
filter_dict={"code": self.confirmation["code"]}
)
if confirmation:
await self.confirmation_service.delete_confirmation(confirmation)
return await super().__aexit__(*args)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from ..application_settings import get_application_settings
from ..application_setup import ModuleCategory, app_setup_func
from ..login.plugin import setup_login_storage
from ..products.plugin import setup_products
from ..projects._projects_repository_legacy import setup_projects_db
from ..redis import setup_redis
Expand Down Expand Up @@ -34,8 +33,6 @@ def setup_garbage_collector(app: web.Application) -> None:

# - project needs access to socketio via notify_project_state_update
setup_socketio(app)
# - project needs access to user-api that is connected to login plugin
setup_login_storage(app)

settings = get_plugin_settings(app)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from aiohttp import web

from ._confirmation_service import ConfirmationService

CONFIRMATION_SERVICE_APPKEY = web.AppKey("CONFIRMATION_SERVICE", ConfirmationService)
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import logging
from typing import Any

import sqlalchemy as sa
from models_library.users import UserID
from servicelib.utils_secrets import generate_passcode
from simcore_postgres_database.models.confirmations import confirmations
from simcore_postgres_database.models.users import users
from simcore_postgres_database.utils_repos import (
pass_or_acquire_connection,
transaction_context,
)
from sqlalchemy.engine import Row
from sqlalchemy.ext.asyncio import AsyncConnection

from ..db.base_repository import BaseRepository
from ._models import ActionLiteralStr, Confirmation

_logger = logging.getLogger(__name__)


def _to_domain(confirmation_row: Row) -> Confirmation:
return Confirmation.model_validate(
{
"code": confirmation_row.code,
"user_id": confirmation_row.user_id,
"action": confirmation_row.action.value, # conversion to literal string
"data": confirmation_row.data,
"created_at": confirmation_row.created, # renames
}
)


class ConfirmationRepository(BaseRepository):

async def create_confirmation(
self,
connection: AsyncConnection | None = None,
*,
user_id: UserID,
action: ActionLiteralStr,
data: str | None = None,
) -> Confirmation:
"""Create a new confirmation token for a user action."""

async with transaction_context(self.engine, connection) as conn:
# We want the same connection checking uniqueness and inserting
while True: # Generate unique code

# NOTE: use only numbers since front-end does not handle well url encoding
numeric_code: str = generate_passcode(20)

# Check if code already exists
check_query = sa.select(confirmations.c.code).where(
confirmations.c.code == numeric_code
)
result = await conn.execute(check_query)
if result.one_or_none() is None:
break

# Insert confirmation
insert_query = (
sa.insert(confirmations)
.values(
code=numeric_code,
user_id=user_id,
action=action,
data=data,
)
.returning(*confirmations.c)
)

result = await conn.execute(insert_query)
row = result.one()
return _to_domain(row)

async def get_confirmation(
self,
connection: AsyncConnection | None = None,
*,
filter_dict: dict[str, Any],
) -> Confirmation | None:
"""Get a confirmation by filter criteria."""
# Handle legacy "user" key
if "user" in filter_dict:
filter_dict["user_id"] = filter_dict.pop("user")["id"]

# Build where conditions
where_conditions = []
for key, value in filter_dict.items():
if hasattr(confirmations.c, key):
where_conditions.append(getattr(confirmations.c, key) == value)

query = sa.select(*confirmations.c).where(sa.and_(*where_conditions))

async with pass_or_acquire_connection(self.engine, connection) as conn:
result = await conn.execute(query)
if row := result.one_or_none():
return _to_domain(row)
return None

async def delete_confirmation(
self,
connection: AsyncConnection | None = None,
*,
confirmation: Confirmation,
) -> None:
"""Delete a confirmation token."""
query = sa.delete(confirmations).where(
confirmations.c.code == confirmation.code
)

async with transaction_context(self.engine, connection) as conn:
await conn.execute(query)

async def delete_confirmation_and_user(
self,
connection: AsyncConnection | None = None,
*,
user_id: UserID,
confirmation: Confirmation,
) -> None:
"""Atomically delete confirmation and user."""
async with transaction_context(self.engine, connection) as conn:
# Delete confirmation
await conn.execute(
sa.delete(confirmations).where(
confirmations.c.code == confirmation.code
)
)

# Delete user
await conn.execute(sa.delete(users).where(users.c.id == user_id))

async def delete_confirmation_and_update_user(
self,
connection: AsyncConnection | None = None,
*,
user_id: UserID,
updates: dict[str, Any],
confirmation: Confirmation,
) -> None:
"""Atomically delete confirmation and update user."""
async with transaction_context(self.engine, connection) as conn:
# Delete confirmation
await conn.execute(
sa.delete(confirmations).where(
confirmations.c.code == confirmation.code
)
)

# Update user
await conn.execute(
sa.update(users).where(users.c.id == user_id).values(**updates)
)
Loading
Loading