From 122234e9a66329ec9308dd6545652a7eafce955a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 16 Nov 2024 22:38:59 +0100 Subject: [PATCH 1/6] Get, create and activate devices --- app/core/myeclpay/cruds_myeclpay.py | 44 ++++++ app/core/myeclpay/endpoints_myeclpay.py | 189 ++++++++++++++++++++++++ app/core/myeclpay/schemas_myeclpay.py | 14 ++ tests/test_myeclpay.py | 117 ++++++++++++++- 4 files changed, 362 insertions(+), 2 deletions(-) diff --git a/app/core/myeclpay/cruds_myeclpay.py b/app/core/myeclpay/cruds_myeclpay.py index ca06080b7e..059355d4ff 100644 --- a/app/core/myeclpay/cruds_myeclpay.py +++ b/app/core/myeclpay/cruds_myeclpay.py @@ -10,6 +10,7 @@ from app.core.myeclpay.types_myeclpay import ( TransactionStatus, TransactionType, + WalletDeviceStatus, WalletType, ) @@ -52,6 +53,49 @@ async def get_wallet_device( return result.scalars().first() +async def get_wallet_devices_by_wallet_id( + wallet_id: UUID, + db: AsyncSession, +) -> Sequence[models_myeclpay.WalletDevice]: + result = await db.execute( + select(models_myeclpay.WalletDevice).where( + models_myeclpay.WalletDevice.wallet_id == wallet_id, + ), + ) + return result.scalars().all() + + +async def get_wallet_device_by_activation_token( + activation_token: str, + db: AsyncSession, +) -> models_myeclpay.WalletDevice | None: + result = await db.execute( + select(models_myeclpay.WalletDevice).where( + models_myeclpay.WalletDevice.activation_token == activation_token, + ), + ) + return result.scalars().first() + + +async def create_wallet_device( + wallet_device: models_myeclpay.WalletDevice, + db: AsyncSession, +) -> None: + db.add(wallet_device) + + +async def update_wallet_device_status( + wallet_device_id: UUID, + status: WalletDeviceStatus, + db: AsyncSession, +) -> None: + await db.execute( + update(models_myeclpay.WalletDevice) + .where(models_myeclpay.WalletDevice.id == wallet_device_id) + .values(status=status), + ) + + async def increment_wallet_balance( wallet_id: UUID, amount: int, diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index b7393fa166..6b21e737f9 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -7,9 +7,11 @@ from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession +from app.core import security from app.core.config import Settings from app.core.models_core import CoreUser from app.core.myeclpay import cruds_myeclpay, schemas_myeclpay +from app.core.myeclpay.models_myeclpay import WalletDevice from app.core.myeclpay.types_myeclpay import ( HistoryType, TransactionStatus, @@ -189,6 +191,193 @@ async def sign_cgu( ) +@router.get( + "/myeclpay/users/me/wallet/devices", + status_code=200, + response_model=list[schemas_myeclpay.WalletDevice], +) +async def get_user_devices( + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_an_ecl_member), +): + """ + Get user devices. + + **The user must be authenticated to use this endpoint** + """ + # Check if user is already registered + user_payment = await cruds_myeclpay.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None or not is_user_latest_cgu_signed(user_payment): + raise HTTPException( + status_code=400, + detail="User is not registered for MyECL Pay", + ) + + wallet_devices = await cruds_myeclpay.get_wallet_devices_by_wallet_id( + wallet_id=user_payment.wallet_id, + db=db, + ) + + return wallet_devices + + +@router.post( + "/myeclpay/users/me/wallet/devices", + status_code=201, + response_model=schemas_myeclpay.WalletDevice, +) +async def create_user_devices( + wallet_device_creation: schemas_myeclpay.WalletDeviceCreation, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_an_ecl_member), + settings: Settings = Depends(get_settings), +): + """ + Create a new device for the user. + The user will need to activate it using a token sent by email. + + **The user must be authenticated to use this endpoint** + """ + # Check if user is already registered + user_payment = await cruds_myeclpay.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None or not is_user_latest_cgu_signed(user_payment): + raise HTTPException( + status_code=400, + detail="User is not registered for MyECL Pay", + ) + + activation_token = security.generate_token(nbytes=16) + + wallet_device_db = WalletDevice( + id=uuid.uuid4(), + name=wallet_device_creation.name, + wallet_id=user_payment.wallet_id, + ed25519_public_key=wallet_device_creation.ed25519_public_key, + creation=datetime.now(UTC), + status=WalletDeviceStatus.UNACTIVE, + activation_token=activation_token, + ) + + await cruds_myeclpay.create_wallet_device( + wallet_device=wallet_device_db, + db=db, + ) + + await db.commit() + + # TODO: use the correct template content + if settings.SMTP_ACTIVE: + account_exists_content = templates.get_template( + "account_exists_mail.html", + ).render() + background_tasks.add_task( + send_email, + recipient=user.email, + subject="MyECL - activate your device", + content=account_exists_content, + settings=settings, + ) + else: + hyperion_error_logger.warning( + f"MyECLPay: activate your device using the token: {activation_token}", + ) + + return wallet_device_db + + +@router.get( + "/myeclpay/users/me/wallet/devices/activate/{activation_token}", + status_code=200, +) +async def activate_user_device( + activation_token: str, + db: AsyncSession = Depends(get_db), +): + """ + Activate a wallet device + """ + + wallet_device = await cruds_myeclpay.get_wallet_device_by_activation_token( + activation_token=activation_token, + db=db, + ) + + if wallet_device is None: + raise HTTPException( + status_code=404, + detail="Invalid token", + ) + + if wallet_device.status != WalletDeviceStatus.UNACTIVE: + raise HTTPException( + status_code=400, + detail="Wallet device is already activated or revoked", + ) + + await cruds_myeclpay.update_wallet_device_status( + wallet_device_id=wallet_device.id, + status=WalletDeviceStatus.ACTIVE, + db=db, + ) + + return "Wallet device activated" + + +@router.post( + "/myeclpay/users/me/wallet/devices/{wallet_device_id}/revoke", + status_code=204, +) +async def revoke_user_devices( + wallet_device_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_an_ecl_member), +): + """ + Revoke a device for the user. + + **The user must be authenticated to use this endpoint** + """ + # Check if user is already registered + user_payment = await cruds_myeclpay.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None or not is_user_latest_cgu_signed(user_payment): + raise HTTPException( + status_code=400, + detail="User is not registered for MyECL Pay", + ) + + wallet_device = await cruds_myeclpay.get_wallet_device( + wallet_device_id=wallet_device_id, db=db + ) + + if wallet_device is None: + raise HTTPException( + status_code=404, + detail="Wallet device does not exist", + ) + + if wallet_device.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=400, + detail="Wallet device does not belong to the user", + ) + + await cruds_myeclpay.update_wallet_device_status( + wallet_device_id=wallet_device_id, + status=WalletDeviceStatus.DISABLED, + db=db, + ) + + @router.get( "/myeclpay/users/me/wallet/history", response_model=list[schemas_myeclpay.History], diff --git a/app/core/myeclpay/schemas_myeclpay.py b/app/core/myeclpay/schemas_myeclpay.py index e291cf1450..f34ca54249 100644 --- a/app/core/myeclpay/schemas_myeclpay.py +++ b/app/core/myeclpay/schemas_myeclpay.py @@ -6,6 +6,7 @@ from app.core.myeclpay.types_myeclpay import ( HistoryType, TransactionStatus, + WalletDeviceStatus, ) @@ -59,3 +60,16 @@ class QRCodeContentData(BaseModel): iat: datetime key: UUID store: bool + + +class WalletDevice(BaseModel): + id: UUID + name: str + wallet_id: UUID + creation: datetime + status: WalletDeviceStatus + + +class WalletDeviceCreation(BaseModel): + name: str + ed25519_public_key: Base64Bytes diff --git a/tests/test_myeclpay.py b/tests/test_myeclpay.py index 7954b91d64..dc7f9a25a2 100644 --- a/tests/test_myeclpay.py +++ b/tests/test_myeclpay.py @@ -4,7 +4,10 @@ import pytest_asyncio from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) from fastapi.testclient import TestClient from pytest_mock import MockerFixture @@ -32,6 +35,7 @@ ecl_user_access_token: str ecl_user_wallet: models_myeclpay.Wallet ecl_user_wallet_device_private_key: Ed25519PrivateKey +ecl_user_wallet_device_public_key: Ed25519PublicKey ecl_user_wallet_device: models_myeclpay.WalletDevice ecl_user_wallet_device_unactive: models_myeclpay.WalletDevice ecl_user_payment: models_myeclpay.UserPayment @@ -59,6 +63,10 @@ store_seller_no_permission_user_access_token: str store_seller_can_bank_user_access_token: str +unregistered_ecl_user_access_token: str + +UNIQUE_TOKEN = "UNIQUE_TOKEN" + @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects() -> None: @@ -90,8 +98,10 @@ async def init_objects() -> None: global \ ecl_user_wallet_device, \ ecl_user_wallet_device_private_key, \ + ecl_user_wallet_device_public_key, \ ecl_user_wallet_device_unactive ecl_user_wallet_device_private_key = Ed25519PrivateKey.generate() + ecl_user_wallet_device_public_key = ecl_user_wallet_device_private_key.public_key() ecl_user_wallet_device = models_myeclpay.WalletDevice( id=uuid4(), name="Test device", @@ -308,6 +318,12 @@ async def init_objects() -> None: ) await add_object_to_db(store_seller_can_bank) + global unregistered_ecl_user_access_token + unregistered_ecl_user = await create_user_with_groups( + groups=[GroupType.student], + ) + unregistered_ecl_user_access_token = create_api_access_token(unregistered_ecl_user) + async def get_cgu_for_unregistred_user(client: TestClient): unregistred_ecl_user = await create_user_with_groups( @@ -389,6 +405,104 @@ async def test_sign_cgu(client: TestClient): assert response.status_code == 204 +async def test_get_user_devices_with_unregistred_user(client: TestClient): + unregistred_ecl_user = await create_user_with_groups( + groups=[GroupType.student], + ) + unregistred_ecl_user_access_token = create_api_access_token(unregistred_ecl_user) + + response = client.get( + "/myeclpay/users/me/wallet/devices", + headers={"Authorization": f"Bearer {unregistred_ecl_user_access_token}"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "User is not registered for MyECL Pay" + + +async def test_get_user_devices(client: TestClient): + response = client.get( + "/myeclpay/users/me/wallet/devices", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 2 + + +async def test_create_user_device_unregistred_user(client: TestClient): + response = client.post( + "/myeclpay/users/me/wallet/devices", + headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, + json={ + "name": "MyDevice", + "ed25519_public_key": base64.b64encode( + ecl_user_wallet_device_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + ).decode("utf-8"), + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "User is not registered for MyECL Pay" + + +async def test_create_and_activate_user_device( + mocker: MockerFixture, + client: TestClient, +) -> None: + # NOTE: we don't want to mock app.core.security.generate_token but + # app.core.users.endpoints_users.security.generate_token which is the imported version of the function + mocker.patch( + "app.core.users.endpoints_users.security.generate_token", + return_value=UNIQUE_TOKEN, + ) + + response = client.post( + "/myeclpay/users/me/wallet/devices", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json={ + "name": "MySuperDevice", + "ed25519_public_key": base64.b64encode( + ecl_user_wallet_device_public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + ).decode("utf-8"), + }, + ) + assert response.status_code == 201 + assert response.json()["id"] is not None + + response = client.get( + f"/myeclpay/users/me/wallet/devices/activate/{UNIQUE_TOKEN}", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert response.json() == "Wallet device activated" + + +async def test_activate_non_existing_device( + client: TestClient, +) -> None: + response = client.get( + "/myeclpay/users/me/wallet/devices/activate/invalidtoken", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Invalid token" + + +async def test_activate_already_activated_device( + client: TestClient, +) -> None: + response = client.get( + "/myeclpay/users/me/wallet/devices/activate/activation_token_ecl_user_wallet_device", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Wallet device is already activated or revoked" + + async def test_get_transactions_unregistered(client: TestClient): unregistred_ecl_user = await create_user_with_groups( groups=[GroupType.student], @@ -814,7 +928,6 @@ async def test_store_scan_store_from_wallet_with_old_cgu_version(client: TestCli ecl_user = await create_user_with_groups( groups=[GroupType.student], ) - ecl_user_access_token = create_api_access_token(ecl_user) ecl_user_wallet = models_myeclpay.Wallet( id=uuid4(), From 37602554eee8d53ca56674bddd1aae8d5b9a2f4a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 16 Nov 2024 22:55:01 +0100 Subject: [PATCH 2/6] Use existing availableassociationmembership --- migrations/versions/26-MyECLPay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/26-MyECLPay.py b/migrations/versions/26-MyECLPay.py index ae6c70ecfd..5fdd7cabca 100644 --- a/migrations/versions/26-MyECLPay.py +++ b/migrations/versions/26-MyECLPay.py @@ -41,7 +41,7 @@ def upgrade() -> None: sa.Column("name", sa.String(), nullable=False), sa.Column( "membership", - sa.Enum("aeecl", "useecl", name="availableassociationmembership"), + sa.Enum(name="availableassociationmembership", create_type=False), nullable=False, ), sa.Column("wallet_id", sa.Uuid(), nullable=False, unique=True), From 66ae9ec2f44132fda4a02b09d96c53bbe395b30d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 16 Nov 2024 23:14:29 +0100 Subject: [PATCH 3/6] Use postgresql.ENUM --- migrations/README.md | 12 ++++++++++++ migrations/versions/26-MyECLPay.py | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/migrations/README.md b/migrations/README.md index 65d643340a..8099592675 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -65,3 +65,15 @@ with op.batch_alter_table("table_name") as batch_op: **Boolean** You need to use `server_default=sa.sql.false()` + +### Use an existing Enum for a column + +```python +from sqlalchemy.dialects import postgresql +``` + +You need to use: + +```python +postgresql.ENUM(name="availableassociationmembership", create_type=False), +``` diff --git a/migrations/versions/26-MyECLPay.py b/migrations/versions/26-MyECLPay.py index 5fdd7cabca..4d5b913c15 100644 --- a/migrations/versions/26-MyECLPay.py +++ b/migrations/versions/26-MyECLPay.py @@ -11,6 +11,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects import postgresql from app.types.sqlalchemy import TZDateTime @@ -41,7 +42,7 @@ def upgrade() -> None: sa.Column("name", sa.String(), nullable=False), sa.Column( "membership", - sa.Enum(name="availableassociationmembership", create_type=False), + postgresql.ENUM(name="availableassociationmembership", create_type=False), nullable=False, ), sa.Column("wallet_id", sa.Uuid(), nullable=False, unique=True), From 79afc11a01a2912b0e6292d8fc600c783f91ef18 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 16 Nov 2024 23:22:00 +0100 Subject: [PATCH 4/6] Define Enum in migration file --- migrations/versions/26-MyECLPay.py | 53 +++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/migrations/versions/26-MyECLPay.py b/migrations/versions/26-MyECLPay.py index 4d5b913c15..d095e213bd 100644 --- a/migrations/versions/26-MyECLPay.py +++ b/migrations/versions/26-MyECLPay.py @@ -4,6 +4,7 @@ """ from collections.abc import Sequence +from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -22,6 +23,40 @@ depends_on: str | Sequence[str] | None = None +class TransactionType(str, Enum): + DIRECT = "direct" + REQUEST = "request" + + +class WalletType(str, Enum): + USER = "user" + STORE = "store" + + +class TransferType(str, Enum): + HELLO_ASSO = "hello_asso" + CHECK = "check" + CASH = "cash" + BANK_TRANSFER = "bank_transfer" + + +class WalletDeviceStatus(str, Enum): + UNACTIVE = "unactive" + ACTIVE = "active" + DISABLED = "disabled" + + +class TransactionStatus(str, Enum): + CONFIRMED = "confirmed" + CANCELED = "canceled" + + +class RequestStatus(str, Enum): + PROPOSED = "proposed" + ACCEPTED = "accepted" + REFUSED = "refused" + + def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( @@ -32,7 +67,7 @@ def upgrade() -> None: op.create_table( "myeclpay_wallet", sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("type", sa.Enum("USER", "STORE", name="wallettype"), nullable=False), + sa.Column("type", sa.Enum(WalletType), nullable=False), sa.Column("balance", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("id"), ) @@ -58,13 +93,7 @@ def upgrade() -> None: sa.Column("id", sa.Uuid(), nullable=False), sa.Column( "type", - sa.Enum( - "HELLO_ASSO", - "CHECK", - "CASH", - "BANK_TRANSFER", - name="transfertype", - ), + sa.Enum(TransferType), nullable=False, ), sa.Column("transfer_identifier", sa.String(), nullable=False), @@ -103,7 +132,7 @@ def upgrade() -> None: sa.Column("creation", TZDateTime(), nullable=False), sa.Column( "status", - sa.Enum("UNACTIVE", "ACTIVE", "DISABLED", name="walletdevicestatus"), + sa.Enum(WalletDeviceStatus), nullable=False, ), sa.Column("activation_token", sa.String(), nullable=False, unique=True), @@ -136,7 +165,7 @@ def upgrade() -> None: sa.Column("receiver_wallet_id", sa.Uuid(), nullable=False), sa.Column( "transaction_type", - sa.Enum("DIRECT", "REQUEST", name="transactiontype"), + sa.Enum(TransactionType), nullable=False, ), sa.Column("seller_user_id", sa.String(), nullable=True), @@ -144,7 +173,7 @@ def upgrade() -> None: sa.Column("creation", TZDateTime(), nullable=False), sa.Column( "status", - sa.Enum("CONFIRMED", "CANCELED", name="transactionstatus"), + sa.Enum(TransactionStatus), nullable=False, ), sa.Column("store_note", sa.String(), nullable=True), @@ -178,7 +207,7 @@ def upgrade() -> None: sa.Column("callback", sa.String(), nullable=False), sa.Column( "status", - sa.Enum("PROPOSED", "ACCEPTED", "REFUSED", name="requeststatus"), + sa.Enum(RequestStatus), nullable=False, ), sa.Column("transaction_id", sa.Uuid(), nullable=True), From 685c6eca668a603ed27b551f4805a2706a9fb0b1 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 17 Nov 2024 00:05:36 +0100 Subject: [PATCH 5/6] Get device --- app/core/myeclpay/endpoints_myeclpay.py | 52 ++++++++++++++++++++++++- app/core/myeclpay/schemas_myeclpay.py | 10 +++-- tests/test_myeclpay.py | 36 +++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index 6b21e737f9..6ec864fd66 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -9,9 +9,10 @@ from app.core import security from app.core.config import Settings +from app.core.groups.groups_type import GroupType from app.core.models_core import CoreUser from app.core.myeclpay import cruds_myeclpay, schemas_myeclpay -from app.core.myeclpay.models_myeclpay import WalletDevice +from app.core.myeclpay.models_myeclpay import Store, WalletDevice from app.core.myeclpay.types_myeclpay import ( HistoryType, TransactionStatus, @@ -34,6 +35,7 @@ get_notification_tool, get_request_id, get_settings, + is_user_a_member_of, is_user_an_ecl_member, ) from app.utils.communication.notifications import NotificationTool @@ -116,6 +118,8 @@ async def register_user( db=db, ) + await db.commit() + # Create new payment user with wallet await cruds_myeclpay.create_user_payment( user_id=user.id, @@ -224,6 +228,52 @@ async def get_user_devices( return wallet_devices +@router.get( + "/myeclpay/users/me/wallet/devices/{wallet_device_id}", + status_code=200, + response_model=schemas_myeclpay.WalletDevice, +) +async def get_user_device( + wallet_device_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_an_ecl_member), +): + """ + Get user devices. + + **The user must be authenticated to use this endpoint** + """ + # Check if user is already registered + user_payment = await cruds_myeclpay.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None or not is_user_latest_cgu_signed(user_payment): + raise HTTPException( + status_code=400, + detail="User is not registered for MyECL Pay", + ) + + wallet_devices = await cruds_myeclpay.get_wallet_device( + wallet_device_id=wallet_device_id, + db=db, + ) + + if wallet_devices is None: + raise HTTPException( + status_code=404, + detail="Wallet device does not exist", + ) + + if wallet_devices.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=400, + detail="Wallet device does not belong to the user", + ) + + return wallet_devices + + @router.post( "/myeclpay/users/me/wallet/devices", status_code=201, diff --git a/app/core/myeclpay/schemas_myeclpay.py b/app/core/myeclpay/schemas_myeclpay.py index f34ca54249..2db2eac191 100644 --- a/app/core/myeclpay/schemas_myeclpay.py +++ b/app/core/myeclpay/schemas_myeclpay.py @@ -62,14 +62,16 @@ class QRCodeContentData(BaseModel): store: bool -class WalletDevice(BaseModel): - id: UUID +class WalletDeviceBase(BaseModel): name: str + + +class WalletDevice(WalletDeviceBase): + id: UUID wallet_id: UUID creation: datetime status: WalletDeviceStatus -class WalletDeviceCreation(BaseModel): - name: str +class WalletDeviceCreation(WalletDeviceBase): ed25519_public_key: Base64Bytes diff --git a/tests/test_myeclpay.py b/tests/test_myeclpay.py index dc7f9a25a2..4aa911628f 100644 --- a/tests/test_myeclpay.py +++ b/tests/test_myeclpay.py @@ -428,6 +428,42 @@ async def test_get_user_devices(client: TestClient): assert len(response.json()) == 2 +async def test_get_user_device_non_existing_user(client: TestClient): + response = client.get( + "/myeclpay/users/me/wallet/devices/f33a6034-0420-4c08-8afd-46ef662d0b28", + headers={"Authorization": f"Bearer {unregistered_ecl_user_access_token}"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "User is not registered for MyECL Pay" + + +async def test_get_user_device_non_existing_device(client: TestClient): + response = client.get( + "/myeclpay/users/me/wallet/devices/f33a6034-0420-4c08-8afd-46ef662d0b28", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Wallet device does not exist" + + +async def test_get_user_device_with_device_from_an_other_user(client: TestClient): + response = client.get( + f"/myeclpay/users/me/wallet/devices/{store_wallet_device.id}", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Wallet device does not belong to the user" + + +async def test_get_user_device(client: TestClient): + response = client.get( + f"/myeclpay/users/me/wallet/devices/{ecl_user_wallet_device.id}", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert response.json()["id"] is not None + + async def test_create_user_device_unregistred_user(client: TestClient): response = client.post( "/myeclpay/users/me/wallet/devices", From eeb0c61d96d3a83c6445830a2ba03fc605e103e5 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 17 Nov 2024 00:05:45 +0100 Subject: [PATCH 6/6] Payment admin --- app/core/myeclpay/cruds_myeclpay.py | 65 ++++++++++- app/core/myeclpay/endpoints_myeclpay.py | 143 ++++++++++++++++++++++++ app/core/myeclpay/schemas_myeclpay.py | 25 +++++ 3 files changed, 232 insertions(+), 1 deletion(-) diff --git a/app/core/myeclpay/cruds_myeclpay.py b/app/core/myeclpay/cruds_myeclpay.py index 059355d4ff..9452d927d8 100644 --- a/app/core/myeclpay/cruds_myeclpay.py +++ b/app/core/myeclpay/cruds_myeclpay.py @@ -2,7 +2,7 @@ from datetime import datetime from uuid import UUID -from sqlalchemy import or_, select, update +from sqlalchemy import delete, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -15,6 +15,69 @@ ) +async def create_store( + store: models_myeclpay.Store, + db: AsyncSession, +) -> None: + db.add(store) + + +async def create_seller( + user_id: str, + store_id: UUID, + can_bank: bool, + can_see_historic: bool, + can_cancel: bool, + can_manage_sellers: bool, + store_admin: bool, + db: AsyncSession, +) -> None: + wallet = models_myeclpay.Seller( + user_id=user_id, + store_id=store_id, + can_bank=can_bank, + can_see_historic=can_see_historic, + can_cancel=can_cancel, + can_manage_sellers=can_manage_sellers, + store_admin=store_admin, + ) + db.add(wallet) + + +async def get_admin_sellers( + store_id: UUID, + db: AsyncSession, +) -> Sequence[models_myeclpay.Seller]: + result = await db.execute( + select(models_myeclpay.Seller).where( + models_myeclpay.Seller.store_id == store_id, + models_myeclpay.Seller.store_admin.is_(True), + ), + ) + return result.scalars().all() + + +async def get_seller( + seller_id: UUID, + db: AsyncSession, +) -> models_myeclpay.Seller | None: + result = await db.execute( + select(models_myeclpay.Seller).where( + models_myeclpay.Seller.id == seller_id, + ), + ) + return result.scalars().first() + + +async def delete_seller( + seller_id: UUID, + db: AsyncSession, +) -> models_myeclpay.Seller | None: + await db.execute( + delete(models_myeclpay.Seller).where(models_myeclpay.Seller.id == seller_id), + ) + + async def create_wallet( wallet_id: UUID, wallet_type: WalletType, diff --git a/app/core/myeclpay/endpoints_myeclpay.py b/app/core/myeclpay/endpoints_myeclpay.py index 6ec864fd66..f0054f92a5 100644 --- a/app/core/myeclpay/endpoints_myeclpay.py +++ b/app/core/myeclpay/endpoints_myeclpay.py @@ -50,6 +50,149 @@ hyperion_error_logger = logging.getLogger("hyperion.error") +# Members of group Payment # + + +@router.post( + "/myeclpay/stores", + status_code=200, + response_model=schemas_myeclpay.Store, +) +async def create_store( + store: schemas_myeclpay.StoreBase, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_a_member_of(GroupType.payment)), +): + """ + Create a store + + **The user must be a member of group Payment** + """ + # Create new wallet for user + wallet_id = uuid.uuid4() + await cruds_myeclpay.create_wallet( + wallet_id=wallet_id, + wallet_type=WalletType.USER, + balance=0, + db=db, + ) + + store_db = Store( + id=uuid.uuid4(), + name=store.name, + membership=store.membership, + wallet_id=wallet_id, + ) + # Check if user is already registered + await cruds_myeclpay.create_store( + store=store_db, + db=db, + ) + + await db.commit() + + return store_db + + +@router.post( + "/myeclpay/stores/{store_id}/admins", + status_code=204, +) +async def create_store_admin_seller( + store_id: UUID, + seller: schemas_myeclpay.SellerAdminCreation, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_a_member_of(GroupType.payment)), +): + """ + Create a store admin seller. + + This admin will have permissions: + - can_bank + - can_see_historic + - can_cancel + - can_manage_sellers + - store_admin + + **The user must be a member of group Payment** + """ + await cruds_myeclpay.create_seller( + user_id=seller.user_id, + store_id=store_id, + can_bank=True, + can_see_historic=True, + can_cancel=True, + can_manage_sellers=True, + store_admin=True, + db=db, + ) + + await db.commit() + + +@router.get( + "/myeclpay/stores/{store_id}/admins", + status_code=200, + response_model=list[schemas_myeclpay.Seller], +) +async def get_store_admin_seller( + store_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_a_member_of(GroupType.payment)), +): + """ + Get all sellers that have the `store_admin` permission for the given store. + + **The user must be a member of group Payment** + """ + sellers = await cruds_myeclpay.get_admin_sellers( + store_id=store_id, + db=db, + ) + + return sellers + + +@router.delete( + "/myeclpay/stores/{store_id}/admins/{seller_id}", + status_code=204, +) +async def delete_store_admin_seller( + store_id: UUID, + seller_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_a_member_of(GroupType.payment)), +): + """ + Delete a store admin seller. + + **The user must be a member of group Payment** + """ + await cruds_myeclpay.get_seller( + seller_id=seller_id, + db=db, + ) + + if seller_id is None: + raise HTTPException( + status_code=404, + detail="Seller does not exist", + ) + + if seller_id.store_id != store_id: + raise HTTPException( + status_code=400, + detail="Seller does not belong to the store", + ) + + await cruds_myeclpay.delete_seller( + seller_id=seller_id, + db=db, + ) + + await db.commit() + + @router.get( "/myeclpay/users/me/cgu", status_code=200, diff --git a/app/core/myeclpay/schemas_myeclpay.py b/app/core/myeclpay/schemas_myeclpay.py index 2db2eac191..685f7ee832 100644 --- a/app/core/myeclpay/schemas_myeclpay.py +++ b/app/core/myeclpay/schemas_myeclpay.py @@ -8,6 +8,31 @@ TransactionStatus, WalletDeviceStatus, ) +from app.types.membership import AvailableAssociationMembership + + +class StoreBase(BaseModel): + name: str + membership: AvailableAssociationMembership + wallet_id: UUID + + +class Store(StoreBase): + id: UUID + + +class SellerAdminCreation(BaseModel): + user_id: str + + +class Seller(BaseModel): + user_id: str + store_id: UUID + can_bank: bool + can_see_historic: bool + can_cancel: bool + can_manage_sellers: bool + store_admin: bool class CGUSignature(BaseModel):