Skip to content

β™»οΈπŸŽ¨ web-server: enhances product domain #7294

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 46 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
398d99d
controler folder
pcrespov Feb 28, 2025
36d8d34
baserepo
pcrespov Feb 28, 2025
48e6571
drafting changes
pcrespov Feb 28, 2025
a65de8f
service: load products
pcrespov Mar 3, 2025
840de2b
set auto_create_products_groups
pcrespov Mar 3, 2025
e66c649
refactor pg utils
pcrespov Mar 3, 2025
2e65cb3
fixes tests
pcrespov Mar 3, 2025
8d0d0e1
fixes tests
pcrespov Mar 3, 2025
01ce11f
fix pylint
pcrespov Mar 4, 2025
81847fb
cleanup
pcrespov Mar 4, 2025
5e805da
get default product name
pcrespov Mar 4, 2025
ad6ba6a
setup rpc
pcrespov Mar 4, 2025
462e6af
cleanup
pcrespov Mar 4, 2025
ffd34fa
cleanup
pcrespov Mar 4, 2025
59429e5
migrated aiopg -> aymcpg in pg products
pcrespov Mar 4, 2025
265fb19
rm deprecated
pcrespov Mar 5, 2025
75c718d
cleanup
pcrespov Mar 5, 2025
db351a1
exception handlers
pcrespov Mar 5, 2025
7ce8f61
errors
pcrespov Mar 6, 2025
c12b080
product and get|create
pcrespov Mar 6, 2025
5b339c3
fixes
pcrespov Mar 6, 2025
f3029ad
cleanup
pcrespov Mar 6, 2025
320f0bd
droping old dependencies
pcrespov Mar 6, 2025
80b9b62
cleanup
pcrespov Mar 6, 2025
17697a2
fixing tests
pcrespov Mar 6, 2025
73e72df
fix tests
pcrespov Mar 6, 2025
d9c2449
fix tests
pcrespov Mar 6, 2025
ffecbde
fix tests
pcrespov Mar 6, 2025
62ab51d
extend coverage
pcrespov Mar 6, 2025
9062390
fix mypy
pcrespov Mar 7, 2025
854d604
rename and doc
pcrespov Mar 7, 2025
d9def7d
renames
pcrespov Mar 7, 2025
c9c869f
doc
pcrespov Mar 7, 2025
4a223f3
replaces base repo
pcrespov Mar 7, 2025
8f53f9f
rename
pcrespov Mar 7, 2025
7288b0e
schema models
pcrespov Mar 7, 2025
baf0c44
schemaresultget
pcrespov Mar 7, 2025
c9bd637
priductsrip
pcrespov Mar 7, 2025
02d14cf
dataclass
pcrespov Mar 7, 2025
dc8152f
FIX
pcrespov Mar 7, 2025
38694a3
test fix
pcrespov Mar 7, 2025
a8c6a74
changes in api-keys
pcrespov Mar 7, 2025
3cfc3ce
changes in api-keys
pcrespov Mar 7, 2025
418d134
fixes api-keys tests
pcrespov Mar 7, 2025
0816621
cleanup
pcrespov Mar 7, 2025
6cb7179
cleanup
pcrespov Mar 7, 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
18 changes: 8 additions & 10 deletions api/specs/web-server/_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Annotated

from fastapi import APIRouter, Depends
from models_library.api_schemas_webserver.product import (
from models_library.api_schemas_webserver.products import (
CreditPriceGet,
InvitationGenerate,
InvitationGenerated,
Expand All @@ -17,7 +17,9 @@
)
from models_library.generics import Envelope
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.products._rest_schemas import ProductsRequestParams
from simcore_service_webserver.products._controller.rest_schemas import (
ProductsRequestParams,
)

router = APIRouter(
prefix=f"/{API_VTAG}",
Expand All @@ -31,8 +33,7 @@
"/credits-price",
response_model=Envelope[CreditPriceGet],
)
async def get_current_product_price():
...
async def get_current_product_price(): ...


@router.get(
Expand All @@ -43,16 +44,14 @@ async def get_current_product_price():
"po",
],
)
async def get_product(_params: Annotated[ProductsRequestParams, Depends()]):
...
async def get_product(_params: Annotated[ProductsRequestParams, Depends()]): ...


@router.get(
"/products/current/ui",
response_model=Envelope[ProductUIGet],
)
async def get_current_product_ui():
...
async def get_current_product_ui(): ...


@router.post(
Expand All @@ -62,5 +61,4 @@ async def get_current_product_ui():
"po",
],
)
async def generate_invitation(_body: InvitationGenerate):
...
async def generate_invitation(_body: InvitationGenerate): ...
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Any, TypeAlias

from common_library.basic_types import DEFAULT_FACTORY
from pydantic import (
BaseModel,
ConfigDict,
Field,
HttpUrl,
Expand All @@ -19,6 +21,28 @@
from ._base import InputSchema, OutputSchema


class CreditResultRpcGet(BaseModel):
product_name: ProductName
credit_amount: Decimal

@staticmethod
def _update_json_schema_extra(schema: JsonDict) -> None:
schema.update(
{
"examples": [
{
"product_name": "s4l",
"credit_amount": Decimal("15.5"), # type: ignore[dict-item]
},
]
}
)

model_config = ConfigDict(
json_schema_extra=_update_json_schema_extra,
)


class CreditPriceGet(OutputSchema):
product_name: str
usd_per_credit: Annotated[
Expand Down
31 changes: 0 additions & 31 deletions packages/models-library/src/models_library/products.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,5 @@
from decimal import Decimal
from typing import TypeAlias

from pydantic import BaseModel, ConfigDict, Field

ProductName: TypeAlias = str
StripePriceID: TypeAlias = str
StripeTaxRateID: TypeAlias = str


class CreditResultGet(BaseModel):
product_name: ProductName
credit_amount: Decimal = Field(..., description="")

model_config = ConfigDict(
json_schema_extra={
"examples": [
{"product_name": "s4l", "credit_amount": Decimal(15.5)}, # type: ignore[dict-item]
]
}
)


class ProductStripeInfoGet(BaseModel):
stripe_price_id: StripePriceID
stripe_tax_rate_id: StripeTaxRateID
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"stripe_price_id": "stripe-price-id",
"stripe_tax_rate_id": "stripe-tax-rate-id",
},
]
}
)
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
""" aiopg errors
"""aiopg errors

StandardError
|__ Warning
|__ Error
|__ InterfaceError
|__ DatabaseError
|__ DataError
|__ OperationalError
|__ IntegrityError
|__ InternalError
|__ ProgrammingError
|__ NotSupportedError
WARNING: these errors are not raised by asyncpg. Therefore all code using new sqlalchemy.ext.asyncio
MUST use instead import sqlalchemy.exc exceptions!!!!

- aiopg reuses DBAPI exceptions
SEE https://aiopg.readthedocs.io/en/stable/core.html?highlight=Exception#exceptions
SEE http://initd.org/psycopg/docs/module.html#dbapi-exceptions
SEE https://www.postgresql.org/docs/current/errcodes-appendix.html
StandardError
|__ Warning
|__ Error
|__ InterfaceError
|__ DatabaseError
|__ DataError
|__ OperationalError
|__ IntegrityError
|__ InternalError
|__ ProgrammingError
|__ NotSupportedError

- aiopg reuses DBAPI exceptions
SEE https://aiopg.readthedocs.io/en/stable/core.html?highlight=Exception#exceptions
SEE http://initd.org/psycopg/docs/module.html#dbapi-exceptions
SEE https://www.postgresql.org/docs/current/errcodes-appendix.html
"""

# NOTE: psycopg2.errors are created dynamically
# pylint: disable=no-name-in-module
from psycopg2 import DatabaseError, DataError
from psycopg2 import (
DatabaseError,
DataError,
)
from psycopg2 import Error as DBAPIError
from psycopg2 import (
IntegrityError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from aiopg.sa.connection import SAConnection
from aiopg.sa.result import ResultProxy, RowProxy

from . import errors
from . import aiopg_errors
from .models.payments_transactions import PaymentTransactionState, payments_transactions

_logger = logging.getLogger(__name__)
Expand All @@ -29,16 +29,13 @@ def __bool__(self):
return False


class PaymentAlreadyExists(PaymentFailure):
...
class PaymentAlreadyExists(PaymentFailure): ...


class PaymentNotFound(PaymentFailure):
...
class PaymentNotFound(PaymentFailure): ...


class PaymentAlreadyAcked(PaymentFailure):
...
class PaymentAlreadyAcked(PaymentFailure): ...


async def insert_init_payment_transaction(
Expand Down Expand Up @@ -69,7 +66,7 @@ async def insert_init_payment_transaction(
initiated_at=initiated_at,
)
)
except errors.UniqueViolation:
except aiopg_errors.UniqueViolation:
return PaymentAlreadyExists(payment_id)

return payment_id
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
""" Common functions to access products table

"""

import warnings
"""Common functions to access products table"""

import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncConnection

from ._protocols import AiopgConnection, DBConnection
from .models.groups import GroupType, groups
from .models.products import products

# NOTE: outside this module, use instead packages/models-library/src/models_library/users.py
_GroupID = int


async def get_default_product_name(conn: DBConnection) -> str:
class EmptyProductsError(ValueError): ...


async def get_default_product_name(conn: AsyncConnection) -> str:
"""The first row in the table is considered as the default product

:: raises ValueError if undefined
Expand All @@ -23,23 +22,25 @@ async def get_default_product_name(conn: DBConnection) -> str:
sa.select(products.c.name).order_by(products.c.priority)
)
if not product_name:
msg = "No product defined in database"
raise ValueError(msg)
msg = "No product was defined in database. Upon construction, at least one product is added but there are none."
raise EmptyProductsError(msg)

assert isinstance(product_name, str) # nosec
return product_name


async def get_product_group_id(
connection: DBConnection, product_name: str
async def get_product_group_id_or_none(
connection: AsyncConnection, product_name: str
) -> _GroupID | None:
group_id = await connection.scalar(
sa.select(products.c.group_id).where(products.c.name == product_name)
)
return None if group_id is None else _GroupID(group_id)


async def execute_get_or_create_product_group(conn, product_name: str) -> int:
async def get_or_create_product_group(
conn: AsyncConnection, product_name: str
) -> _GroupID:
#
# NOTE: Separated so it can be used in asyncpg and aiopg environs while both
# coexist
Expand Down Expand Up @@ -70,23 +71,3 @@ async def execute_get_or_create_product_group(conn, product_name: str) -> int:
)

return group_id


async def get_or_create_product_group(
connection: AiopgConnection, product_name: str
) -> _GroupID:
"""
Returns group_id of a product. Creates it if undefined
"""
warnings.warn(
f"{__name__}.get_or_create_product_group uses aiopg which has been deprecated in this repo. Please use the asyncpg equivalent version instead"
"See https://github.com/ITISFoundation/osparc-simcore/issues/4529",
DeprecationWarning,
stacklevel=1,
)

async with connection.begin():
group_id = await execute_get_or_create_product_group(
connection, product_name=product_name
)
return _GroupID(group_id)
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import NamedTuple, TypeAlias

import sqlalchemy as sa
from aiopg.sa.connection import SAConnection
from sqlalchemy.ext.asyncio import AsyncConnection

from .constants import QUANTIZE_EXP_ARG
from .models.products_prices import products_prices
Expand All @@ -17,9 +17,9 @@ class ProductPriceInfo(NamedTuple):


async def get_product_latest_price_info_or_none(
conn: SAConnection, product_name: str
conn: AsyncConnection, product_name: str
) -> ProductPriceInfo | None:
"""None menans the product is not billable"""
"""If the product is not billable, it returns None"""
# newest price of a product
result = await conn.execute(
sa.select(
Expand All @@ -30,7 +30,7 @@ async def get_product_latest_price_info_or_none(
.order_by(sa.desc(products_prices.c.valid_from))
.limit(1)
)
row = await result.first()
row = result.one_or_none()

if row and row.usd_per_credit is not None:
assert row.min_payment_amount_usd is not None # nosec
Expand All @@ -43,27 +43,24 @@ async def get_product_latest_price_info_or_none(
return None


async def get_product_latest_stripe_info(
conn: SAConnection, product_name: str
) -> tuple[StripePriceID, StripeTaxRateID]:
async def get_product_latest_stripe_info_or_none(
conn: AsyncConnection, product_name: str
) -> tuple[StripePriceID, StripeTaxRateID] | None:
# Stripe info of a product for latest price
row = await (
await conn.execute(
sa.select(
products_prices.c.stripe_price_id,
products_prices.c.stripe_tax_rate_id,
)
.where(products_prices.c.product_name == product_name)
.order_by(sa.desc(products_prices.c.valid_from))
.limit(1)
result = await conn.execute(
sa.select(
products_prices.c.stripe_price_id,
products_prices.c.stripe_tax_rate_id,
)
).fetchone()
if row is None:
msg = f"Required Stripe information missing from product {product_name=}"
raise ValueError(msg)
return (row.stripe_price_id, row.stripe_tax_rate_id)
.where(products_prices.c.product_name == product_name)
.order_by(sa.desc(products_prices.c.valid_from))
.limit(1)
)

row = result.one_or_none()
return (row.stripe_price_id, row.stripe_tax_rate_id) if row else None


async def is_payment_enabled(conn: SAConnection, product_name: str) -> bool:
async def is_payment_enabled(conn: AsyncConnection, product_name: str) -> bool:
p = await get_product_latest_price_info_or_none(conn, product_name=product_name)
return bool(p) # zero or None is disabled
Loading