Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 = "06eafd25d004"
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 servicelib.logging_utils import set_parent_module_log_level

from ..application_settings import get_application_settings
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,153 @@
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 pass_or_acquire_connection(self.engine, connection) as conn:
# Generate unique code
while True:
# 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 pass_or_acquire_connection(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