diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index 3040d44..74395a7 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] poetry-version: ["1.8.3"] os: [ubuntu-latest] runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 6416ffe..faf8128 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Merchants +# Fastapi-Merchants [![PyPI version](https://badge.fury.io/py/merchants.svg)](https://badge.fury.io/py/merchants) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -6,6 +6,7 @@ [![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)](https://fastapi.tiangolo.com) [![Downloads](https://pepy.tech/badge/merchants)](https://pepy.tech/project/merchants) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![pre-commit.ci sandbox](https://results.pre-commit.ci/badge/github/mariofix/merchants/sandbox.svg)](https://results.pre-commit.ci/latest/github/mariofix/merchants/sandbox) A unified payment processing toolkit for FastAPI applications, inspired by django-payments. @@ -17,7 +18,7 @@ flexible interface for handling different payment methods. ## Features -- Easy integration with FastAPI applications +- Easy integration with Starlette/FastAPI applications - Support for multiple payment gateways - Customizable payment workflows - Webhook handling for payment status updates diff --git a/alembic/README b/alembic/README index 98e4f9c..2500aa1 100644 --- a/alembic/README +++ b/alembic/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/alembic/env.py b/alembic/env.py index ac8de3f..4bcecf0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,10 +1,8 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool from alembic import context -import os # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -15,12 +13,13 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) +from merchants.config import settings # noqa + # add your model's MetaData object here # for 'autogenerate' support -from merchants.models import SQLModel # noqa -from merchants.config import settings # noqa +from merchants.models import DatabaseModel # noqa -target_metadata = SQLModel.metadata +target_metadata = DatabaseModel.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -29,7 +28,7 @@ def get_url(): - return str(settings.SQLALCHEMY_DATABASE_URI) + return str(settings.SQLALCHEMY_DATABASE_URL) def run_migrations_offline(): diff --git a/alembic/script.py.mako b/alembic/script.py.mako index 217a9a8..2c01563 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -7,7 +7,6 @@ Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa -import sqlmodel.sql.sqltypes ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/alembic/versions/270409ec074b_re_2_init.py b/alembic/versions/270409ec074b_re_2_init.py deleted file mode 100644 index 565d066..0000000 --- a/alembic/versions/270409ec074b_re_2_init.py +++ /dev/null @@ -1,58 +0,0 @@ -"""(re-2)Init - -Revision ID: 270409ec074b -Revises: -Create Date: 2024-09-17 02:00:35.300894 - -""" - -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = "270409ec074b" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "broker", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), - sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("integration_class", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("config", sqlmodel.sql.sqltypes.AutoString(length=2048), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_broker_slug"), "broker", ["slug"], unique=True) - op.create_table( - "user", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("username", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), - sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("scopes", sqlmodel.sql.sqltypes.AutoString(length=2048), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) - op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_user_username"), table_name="user") - op.drop_index(op.f("ix_user_email"), table_name="user") - op.drop_table("user") - op.drop_index(op.f("ix_broker_slug"), table_name="broker") - op.drop_table("broker") - # ### end Alembic commands ### diff --git a/alembic/versions/96ae93cc9125_re_7_init.py b/alembic/versions/96ae93cc9125_re_7_init.py new file mode 100644 index 0000000..92d7a50 --- /dev/null +++ b/alembic/versions/96ae93cc9125_re_7_init.py @@ -0,0 +1,82 @@ +"""(re-7)Init + +Revision ID: 96ae93cc9125 +Revises: +Create Date: 2024-09-19 04:51:55.007209 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "96ae93cc9125" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "integration", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.Column("integration_class", sa.String(length=255), nullable=True), + sa.Column("config", sa.JSON(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False + ), + sa.Column( + "modified_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(length=3), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password", sa.String(length=255), nullable=False), + sa.Column("scopes", sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("username"), + ) + op.create_table( + "payment", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("transaction", sa.String(length=255), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("customer_email", sa.String(length=255), nullable=False), + sa.Column("integration_slug", sa.String(length=255), nullable=False), + sa.Column("integration_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=10), nullable=False), + sa.Column("integration_payload", sa.JSON(), nullable=True), + sa.Column("integration_response", sa.JSON(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False + ), + sa.Column( + "modified_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False + ), + sa.ForeignKeyConstraint( + ["integration_id"], + ["integration.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("transaction"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("payment") + op.drop_table("user") + op.drop_table("integration") + # ### end Alembic commands ### diff --git a/merchants/FastapiAdmin.py b/merchants/FastapiAdmin.py index 2fe4d75..73aa02c 100644 --- a/merchants/FastapiAdmin.py +++ b/merchants/FastapiAdmin.py @@ -1,8 +1,8 @@ -from merchants.models import User, Broker -from merchants.database import engine -from merchants.config import settings +from starlette_admin.contrib.sqla import Admin, ModelView -from starlette_admin.contrib.sqlmodel import Admin, ModelView +from merchants.config import settings +from merchants.database import engine +from merchants.models import Integration, Payment, User admin = Admin(engine, title=settings.PROJECT_NAME) @@ -13,7 +13,7 @@ class UserAdmin(ModelView): exclude_fields_from_edit = ["id"] -class BrokerAdmin(ModelView): +class IntegrationAdmin(ModelView): exclude_fields_from_list = ["id", "config"] exclude_fields_from_create = ["id"] exclude_fields_from_edit = ["id"] @@ -21,5 +21,12 @@ class BrokerAdmin(ModelView): searchable_fields = ["name", "slug", "integration_class", "config"] +class PaymentAdmin(ModelView): + exclude_fields_from_list = ["id", "integration_slug", "integration_payload", "integration_response", "modified_at"] + exclude_fields_from_create = ["id"] + exclude_fields_from_edit = ["id"] + + admin.add_view(UserAdmin(User, icon="fas fa-person")) -admin.add_view(BrokerAdmin(Broker, icon="fas fa-list")) +admin.add_view(PaymentAdmin(Payment, icon="fas fa-wallet")) +admin.add_view(IntegrationAdmin(Integration, icon="fas fa-list")) diff --git a/merchants/FastapiApp.py b/merchants/FastapiApp.py index a852cad..11cab8f 100644 --- a/merchants/FastapiApp.py +++ b/merchants/FastapiApp.py @@ -1,48 +1,17 @@ -from fastapi import FastAPI, Depends, Request -from fastapi.routing import APIRoute -from sqlmodel import Session -from collections.abc import Generator -from typing import Annotated -from merchants.version import __version__ as __merchants_version__ +from debug_toolbar.middleware import DebugToolbarMiddleware +from fastapi import FastAPI from merchants.config import settings -from merchants.database import engine - from merchants.FastapiAdmin import admin - - -from debug_toolbar.middleware import DebugToolbarMiddleware -from debug_toolbar.panels.sqlalchemy import SQLAlchemyPanel - - -def custom_generate_unique_id(route: APIRoute) -> str: - return f"{route.tags[0]}-{route.name}" - - -def get_db() -> Generator[Session, None, None]: - with Session(engine) as session: - yield session - - -SessionDep = Annotated[Session, Depends(get_db)] - - -class SQLModelPanel(SQLAlchemyPanel): - async def add_engines(self, request: Request): - self.engines.add(engine) - +from merchants.version import __version__ as __merchants_version__ app = FastAPI( title=settings.PROJECT_NAME, - # generate_unique_id_function=custom_generate_unique_id, version=__merchants_version__, description="Universal Payment Processing System", debug=True, ) -app.add_middleware( - DebugToolbarMiddleware, - panels=["merchants.FastapiApp.SQLModelPanel"], -) +app.add_middleware(DebugToolbarMiddleware) admin.mount_to(app) diff --git a/merchants/config.py b/merchants/config.py index 4e0b8d8..cfc6e5a 100644 --- a/merchants/config.py +++ b/merchants/config.py @@ -1,5 +1,5 @@ import secrets -from typing import List + from pydantic import HttpUrl, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -21,9 +21,11 @@ def server_host(self) -> str: PROJECT_NAME: str = "Merchants" SENTRY_DSN: HttpUrl | None = None - SQLALCHEMY_DATABASE_URI: str = "sqlite:///merchants.db" + SQLALCHEMY_DATABASE_URL: str = "sqlite:///merchants.db" + + ALLOWED_DOMAINS: list[str] | None = None - ALLOWED_DOMAINS: List[str] | None = None + PROCESS_ON_SAVE: bool = True settings = Settings() # type: ignore diff --git a/merchants/database.py b/merchants/database.py index b7e6ac0..86a7040 100644 --- a/merchants/database.py +++ b/merchants/database.py @@ -1,9 +1,18 @@ -from sqlmodel import Session, create_engine, select -from merchants.config import settings +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from merchants.config import settings engine = create_engine( - settings.SQLALCHEMY_DATABASE_URI, + settings.SQLALCHEMY_DATABASE_URL, pool_recycle=1800, pool_pre_ping=True, + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, ) +DatabaseModel = declarative_base() diff --git a/merchants/models.py b/merchants/models.py index eb3defb..ce1a4a7 100644 --- a/merchants/models.py +++ b/merchants/models.py @@ -1,78 +1,133 @@ -from typing import List, Any, Dict -from sqlmodel import SQLModel, Field -import uuid -from pydantic import EmailStr import datetime -from sqlalchemy import JSON, Column, event -from slugify import slugify +from decimal import Decimal +from typing import Any +from pydantic import BaseModel, EmailStr +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String, event +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func +from sqlalchemy_utils import generic_repr +from merchants.config import settings +from merchants.database import DatabaseModel + +# SQLAlchemy Models + + +@generic_repr +class User(DatabaseModel): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(3), nullable=False, unique=True) + email: Mapped[EmailStr] = mapped_column(String(255), nullable=False, unique=True) + password: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = True + is_superuser: Mapped[bool] = False + scopes: Mapped[list[str]] = mapped_column(JSON, nullable=True) + + +@generic_repr +class Integration(DatabaseModel): + __tablename__ = "integration" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + is_active: Mapped[bool] = True + integration_class: Mapped[str] = mapped_column(String(255), nullable=True) + config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + payments: Mapped["Payment"] = relationship("Payment", back_populates="integration") + + created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + modified_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), onupdate=func.now(), server_default=func.now() + ) + + +@generic_repr +class Payment(DatabaseModel): + __tablename__ = "payment" + + id: Mapped[int] = mapped_column(primary_key=True) + transaction: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + customer_email: Mapped[EmailStr] = mapped_column(String(255), nullable=False) + integration_slug: Mapped[str] = mapped_column(String(255), nullable=False) + integration_id: Mapped[str] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(10)) + integration_payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + integration_response: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + integration_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("integration.id")) + integration: Mapped[Integration | None] = relationship("Integration", back_populates="payments") + + created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + modified_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), onupdate=func.now(), server_default=func.now() + ) + + @classmethod + def __declare_last__(cls): + """ + From Claude: to add the before_insert event directly on the Main model + + """ + if settings.PROCESS_ON_SAVE: + event.listen(cls, "before_insert", cls.event_before_insert) + + @staticmethod + def event_before_insert(mapper, connection, target): + """ + SQLAlchemy event listener that calls process_payment before inserting a new record. + + :param mapper: The Mapper object which is the target of this event + :param connection: The Connection being used for the operation + :param target: The mapped instance being persisted + """ + print(f"{target=}") + return target + + +# Pydantic Models # Generic message -class Message(SQLModel): +class Message(BaseModel): message: str # JSON payload containing access token -class Token(SQLModel): +class Token(BaseModel): access_token: str token_type: str = "bearer" # Contents of JWT token -class TokenPayload(SQLModel): +class TokenPayload(BaseModel): sub: str | None = None -class User(SQLModel, table=True): - id: int | None = Field(default=None, primary_key=True) - username: str = Field(unique=True, index=True, max_length=255) - email: EmailStr = Field(unique=True, index=True, max_length=255) - password: str - is_active: bool = True - is_superuser: bool = False - scopes: List[str] | None = Field(sa_column=Column(JSON, nullable=True)) - +class UserPublic(BaseModel): + id: int -class UserPublic(SQLModel): - id: uuid.UUID - -class UsersPublic(SQLModel): +class UsersPublic(BaseModel): data: list[UserPublic] count: int -class Broker(SQLModel, table=True): - id: int | None = Field(default=None, primary_key=True) - name: str = Field(max_length=255) - slug: str | None = Field(unique=True, index=True, max_length=255) - is_active: bool = True - integration_class: str - config: Dict[str, Any] | None = Field(sa_column=Column(JSON, nullable=True)) - created_at: datetime.datetime = Field( - default=datetime.datetime.now, - ) - - # @classmethod - # def __declare_last__(cls): - # event.listen(cls, "before_insert", cls.event_before_insert) +class IntegrationPublic(BaseModel): + slug: str - # @staticmethod - # def event_before_insert(mapper, connection, target): - # """ - # SQLAlchemy event listener that calls process_payment before inserting a new record. - # :param mapper: The Mapper object which is the target of this event - # :param connection: The Connection being used for the operation - # :param target: The mapped instance being persisted - # """ - # target.slug = slugify(target.name) +class IntegrationsPublic(BaseModel): + count: int + data: list[IntegrationPublic] -class BrokerPublic(SQLModel): - slug: str +class PaymentPublic(BaseModel): + transaction: str -class BrokersPublic(SQLModel): +class PaymentsPublic(BaseModel): count: int - data: list[BrokerPublic] + data: list[PaymentPublic] diff --git a/pyproject.toml b/pyproject.toml index 3bf8fca..450c5a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] -name = "merchants" +name = "fastapi-merchants" version = "2024.9.16" -description = "A simple gateway platform to process payments" +description = "A unified payment processing toolkit for Starlette/FastAPI applications" authors = ["Mario Hernandez "] readme = "README.md" license = "MIT" @@ -19,7 +19,6 @@ keywords = [ packages = [{ include = "merchants" }] classifiers = [ "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -28,7 +27,7 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.9" +python = "^3.10" thankyou = "^0.0.3" pendulum = "^3.0.0" click = "^8.1.7" @@ -37,14 +36,16 @@ httpx = "^0.27.0" fastapi = { extras = ["all"], version = "^0.114.2" } uvicorn = { extras = ["standard"], version = "^0.30.6" } -sqlmodel = "^0.0.22" alembic = "^1.13.2" -sqladmin = { extras = ["full"], version = "^0.19.0" } +starlette-admin = "^0.14.1" +sqlalchemy = "^2.0.35" black = "^24.8.0" fastapi-debug-toolbar = "^0.6.3" python-slugify = "^8.0.4" -starlette-admin = "^0.14.1" +sqlalchemy-utils = "^0.41.2" +pre-commit = "^3.8.0" + [tool.poetry.group.dev.dependencies] diff --git a/tests/test_core.py b/tests/test_core.py index 8c7681a..2939958 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,8 +1,9 @@ import pytest from fastapi.testclient import TestClient from sqlmodel import Session -from merchants.FastapiApp import app + from merchants.database import engine +from merchants.FastapiApp import app # Create a test client client = TestClient(app)