feat: implement complete CMT backend with API endpoints and test suite

- Add 7 core API endpoints: users, transactions, partners, products, inventory, payments, credit
- Implement role-based authentication (admin/write/read-only access)
- Add comprehensive database models with proper relationships
- Include full test coverage for all endpoints and business logic
- Set up Alembic migrations and Docker configuration
- Configure FastAPI app with CORS and database integration
This commit is contained in:
2025-09-14 21:04:07 +02:00
parent 49c813778b
commit c086f64363
48 changed files with 6992 additions and 126 deletions
@@ -0,0 +1,206 @@
"""Remove payment_method ENUM - Complexity not worth it when CheckConstraint can serve
Revision ID: 997376dc1774
Revises: e777b1b307b5
Create Date: 2025-08-25 23:18:53.106182
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '997376dc1774'
down_revision: Union[str, Sequence[str], None] = 'e777b1b307b5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
import sqlmodel.sql.sqltypes
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('partner',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tin_number', sa.Integer(), nullable=False),
sa.Column('names', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('type', sa.Enum('CLIENT', 'SUPPLIER', name='partnertype'), nullable=False),
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tin_number')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('role', sa.Enum('ADMIN', 'WRITE', 'READ_ONLY', name='userrole'), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('total_qty', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id')
)
op.create_table('transaction_details',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('qty', sa.Integer(), nullable=False),
sa.Column('selling_price', sa.Integer(), nullable=False),
sa.Column('total_value', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('transcation_type', sa.Enum('SALE', 'PURCHASE', 'CREDIT', name='transactiontype'), nullable=False),
sa.Column('transaction_status', sa.Enum('UNPAID', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', name='transactionstatus'), nullable=False),
sa.Column('total_amount', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('credit_accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=False),
sa.Column('credit_amount', sa.Integer(), nullable=False),
sa.Column('credit_limit', sa.Integer(), nullable=False),
sa.Column('balance', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('partner_id')
)
op.add_column('payment', sa.Column('transaction_id', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('paid_amount', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('payment_date', sa.Date(), nullable=False))
op.add_column('payment', sa.Column('created_by', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('updated_by', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.add_column('payment', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.alter_column('payment', 'payment_method',
existing_type=sa.VARCHAR(length=24),
type_=sa.String(length=10),
existing_nullable=False)
op.drop_constraint(op.f('payment_client_id_fkey'), 'payment', type_='foreignkey')
op.drop_constraint(op.f('payment_supplier_id_fkey'), 'payment', type_='foreignkey')
op.drop_constraint(op.f('payment_product_code_fkey'), 'payment', type_='foreignkey')
op.drop_table('credit')
op.drop_table('supplier')
op.drop_table('client')
op.create_foreign_key(None, 'payment', 'transactions', ['transaction_id'], ['id'])
op.create_foreign_key(None, 'payment', 'user', ['created_by'], ['id'])
op.create_foreign_key(None, 'payment', 'user', ['updated_by'], ['id'])
op.drop_column('payment', 'product_code')
op.drop_column('payment', 'payment_type')
op.drop_column('payment', 'date')
op.drop_column('payment', 'amount')
op.drop_column('payment', 'client_id')
op.drop_column('payment', 'supplier_id')
op.add_column('product', sa.Column('selling_price', sa.Integer(), nullable=False))
op.alter_column('product', 'date_modified',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('product', 'date_modified',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
op.drop_column('product', 'selling_price')
op.add_column('payment', sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True))
op.add_column('payment', sa.Column('payment_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False))
op.drop_constraint(None, 'payment', type_='foreignkey')
op.drop_constraint(None, 'payment', type_='foreignkey')
op.drop_constraint(None, 'payment', type_='foreignkey')
op.create_foreign_key(op.f('payment_product_code_fkey'), 'payment', 'product', ['product_code'], ['product_code'])
op.create_foreign_key(op.f('payment_supplier_id_fkey'), 'payment', 'supplier', ['supplier_id'], ['id'])
op.create_foreign_key(op.f('payment_client_id_fkey'), 'payment', 'client', ['client_id'], ['id'])
op.alter_column('payment', 'payment_method',
existing_type=sa.String(length=10),
type_=sa.VARCHAR(length=24),
existing_nullable=False)
op.drop_column('payment', 'updated_at')
op.drop_column('payment', 'created_at')
op.drop_column('payment', 'updated_by')
op.drop_column('payment', 'created_by')
op.drop_column('payment', 'payment_date')
op.drop_column('payment', 'paid_amount')
op.drop_column('payment', 'transaction_id')
op.create_table('client',
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('client_id_seq'::regclass)"), autoincrement=True, nullable=False),
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='client_pkey'),
sa.UniqueConstraint('tin_number', name='client_tin_number_key', postgresql_include=[], postgresql_nulls_not_distinct=False),
postgresql_ignore_search_path=False
)
op.create_table('credit',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('transcation_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False),
sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('qty', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], name=op.f('credit_client_id_fkey')),
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], name=op.f('credit_product_code_fkey')),
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], name=op.f('credit_supplier_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('credit_pkey'))
)
op.create_table('supplier',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('supplier_pkey')),
sa.UniqueConstraint('tin_number', name=op.f('supplier_tin_number_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.drop_table('credit_accounts')
op.drop_table('transactions')
op.drop_table('transaction_details')
op.drop_table('inventory')
op.drop_table('user')
op.drop_table('partner')
# ### end Alembic commands ###
@@ -0,0 +1,203 @@
"""Update table models to better logic & table relationship (Transaction + payment feed into credit accounts etc...)
Revision ID: a4126dbcfd9e
Revises: 4966e016dd7c
Create Date: 2025-08-25 22:25:57.071318
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = 'a4126dbcfd9e'
down_revision: Union[str, Sequence[str], None] = '4966e016dd7c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('partner',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tin_number', sa.Integer(), nullable=False),
sa.Column('names', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('type', sa.Enum('CLIENT', 'SUPPLIER', name='partnertype'), nullable=False),
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tin_number')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('role', sa.Enum('ADMIN', 'WRITE', 'READ_ONLY', name='userrole'), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('total_qty', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id')
)
op.create_table('transaction_details',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('product_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('qty', sa.Integer(), nullable=False),
sa.Column('selling_price', sa.Integer(), nullable=False),
sa.Column('total_value', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('transcation_type', sa.Enum('SALE', 'PURCHASE', 'CREDIT', name='transactiontype'), nullable=False),
sa.Column('transaction_status', sa.Enum('UNPAID', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', name='transactionstatus'), nullable=False),
sa.Column('total_amount', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('credit_accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=False),
sa.Column('credit_amount', sa.Integer(), nullable=False),
sa.Column('credit_limit', sa.Integer(), nullable=False),
sa.Column('balance', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('partner_id')
)
op.drop_table('client')
op.drop_table('supplier')
op.drop_table('credit')
op.add_column('payment', sa.Column('transaction_id', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('paid_amount', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('payment_date', sa.Date(), nullable=False))
op.add_column('payment', sa.Column('created_by', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('updated_by', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.add_column('payment', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.alter_column('payment', 'payment_method',
existing_type=sa.VARCHAR(length=24),
type_=sa.Enum('MOMO', 'BANK', 'CASH', name='paymentmethod'),
existing_nullable=False)
op.drop_constraint(op.f('payment_supplier_id_fkey'), 'payment', type_='foreignkey')
op.drop_constraint(op.f('payment_client_id_fkey'), 'payment', type_='foreignkey')
op.drop_constraint(op.f('payment_product_code_fkey'), 'payment', type_='foreignkey')
op.create_foreign_key(None, 'payment', 'transactions', ['transaction_id'], ['id'])
op.create_foreign_key(None, 'payment', 'user', ['updated_by'], ['id'])
op.create_foreign_key(None, 'payment', 'user', ['created_by'], ['id'])
op.drop_column('payment', 'product_code')
op.drop_column('payment', 'supplier_id')
op.drop_column('payment', 'client_id')
op.drop_column('payment', 'amount')
op.drop_column('payment', 'date')
op.drop_column('payment', 'payment_type')
op.add_column('product', sa.Column('selling_price', sa.Integer(), nullable=False))
op.alter_column('product', 'date_modified',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('product', 'date_modified',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
op.drop_column('product', 'selling_price')
op.add_column('payment', sa.Column('payment_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True))
op.add_column('payment', sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False))
op.drop_constraint(None, 'payment', type_='foreignkey')
op.drop_constraint(None, 'payment', type_='foreignkey')
op.drop_constraint(None, 'payment', type_='foreignkey')
op.create_foreign_key(op.f('payment_product_code_fkey'), 'payment', 'product', ['product_code'], ['product_code'])
op.create_foreign_key(op.f('payment_client_id_fkey'), 'payment', 'client', ['client_id'], ['id'])
op.create_foreign_key(op.f('payment_supplier_id_fkey'), 'payment', 'supplier', ['supplier_id'], ['id'])
op.alter_column('payment', 'payment_method',
existing_type=sa.Enum('MOMO', 'BANK', 'CASH', name='paymentmethod'),
type_=sa.VARCHAR(length=24),
existing_nullable=False)
op.drop_column('payment', 'updated_at')
op.drop_column('payment', 'created_at')
op.drop_column('payment', 'updated_by')
op.drop_column('payment', 'created_by')
op.drop_column('payment', 'payment_date')
op.drop_column('payment', 'paid_amount')
op.drop_column('payment', 'transaction_id')
op.create_table('credit',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('transcation_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False),
sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('qty', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], name=op.f('credit_client_id_fkey')),
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], name=op.f('credit_product_code_fkey')),
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], name=op.f('credit_supplier_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('credit_pkey'))
)
op.create_table('supplier',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('supplier_pkey')),
sa.UniqueConstraint('tin_number', name=op.f('supplier_tin_number_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_table('client',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('client_pkey')),
sa.UniqueConstraint('tin_number', name=op.f('client_tin_number_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.drop_table('credit_accounts')
op.drop_table('transactions')
op.drop_table('transaction_details')
op.drop_table('inventory')
op.drop_table('user')
op.drop_table('partner')
# ### end Alembic commands ###
@@ -0,0 +1,219 @@
"""product.id to match types with product_id in transaction_detail
Revision ID: e777b1b307b5
Revises: a4126dbcfd9e
Create Date: 2025-08-25 22:34:19.869427
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = 'e777b1b307b5'
down_revision: Union[str, Sequence[str], None] = 'a4126dbcfd9e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# FIRST: Remove foreign key constraints and columns from payment table
op.drop_constraint('payment_supplier_id_fkey', 'payment', type_='foreignkey')
op.drop_constraint('payment_client_id_fkey', 'payment', type_='foreignkey')
op.drop_constraint('payment_product_code_fkey', 'payment', type_='foreignkey')
op.drop_column('payment', 'product_code')
op.drop_column('payment', 'date')
op.drop_column('payment', 'amount')
op.drop_column('payment', 'payment_type')
op.drop_column('payment', 'client_id')
op.drop_column('payment', 'supplier_id')
# THEN: Drop the referenced tables (now safe)
op.drop_table('credit')
op.drop_table('supplier')
op.drop_table('client')
# Create new tables
op.create_table('partner',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tin_number', sa.Integer(), nullable=False),
sa.Column('names', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('type', sa.Enum('CLIENT', 'SUPPLIER', name='partnertype'), nullable=False),
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tin_number')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('role', sa.Enum('ADMIN', 'WRITE', 'READ_ONLY', name='userrole'), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('total_qty', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id')
)
op.create_table('transaction_details',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('qty', sa.Integer(), nullable=False),
sa.Column('selling_price', sa.Integer(), nullable=False),
sa.Column('total_value', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('transcation_type', sa.Enum('SALE', 'PURCHASE', 'CREDIT', name='transactiontype'), nullable=False),
sa.Column('transaction_status', sa.Enum('UNPAID', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', name='transactionstatus'), nullable=False),
sa.Column('total_amount', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('credit_accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=False),
sa.Column('credit_amount', sa.Integer(), nullable=False),
sa.Column('credit_limit', sa.Integer(), nullable=False),
sa.Column('balance', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['partner_id'], ['partner.id'], ),
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('partner_id')
)
# FINALLY: Add new columns and constraints to payment table
op.add_column('payment', sa.Column('transaction_id', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('paid_amount', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('payment_date', sa.Date(), nullable=False))
op.add_column('payment', sa.Column('created_by', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('updated_by', sa.Integer(), nullable=False))
op.add_column('payment', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.add_column('payment', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.execute("CREATE TYPE paymentmethod AS ENUM ('momo', 'bank', 'cash')")
op.alter_column('payment', 'payment_method',
existing_type=sa.VARCHAR(length=24),
type_=sa.Enum('momo', 'bank', 'cash', name='paymentmethod'),
existing_nullable=False,
postgresql_using='payment_method::paymentmethod')
op.create_foreign_key(None, 'payment', 'transactions', ['transaction_id'], ['id'])
op.create_foreign_key(None, 'payment', 'user', ['created_by'], ['id'])
op.create_foreign_key(None, 'payment', 'user', ['updated_by'], ['id'])
op.add_column('product', sa.Column('selling_price', sa.Integer(), nullable=False))
op.alter_column('product', 'date_modified',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('product', 'date_modified',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
op.drop_column('product', 'selling_price')
op.add_column('payment', sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('payment_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False))
op.add_column('payment', sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True))
op.add_column('payment', sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False))
op.drop_constraint(None, 'payment', type_='foreignkey')
op.drop_constraint(None, 'payment', type_='foreignkey')
op.drop_constraint(None, 'payment', type_='foreignkey')
op.create_foreign_key(op.f('payment_product_code_fkey'), 'payment', 'product', ['product_code'], ['product_code'])
op.create_foreign_key(op.f('payment_client_id_fkey'), 'payment', 'client', ['client_id'], ['id'])
op.create_foreign_key(op.f('payment_supplier_id_fkey'), 'payment', 'supplier', ['supplier_id'], ['id'])
op.alter_column('payment', 'payment_method',
existing_type=sa.Enum('MOMO', 'BANK', 'CASH', name='paymentmethod'),
type_=sa.VARCHAR(length=24),
existing_nullable=False)
op.drop_column('payment', 'updated_at')
op.drop_column('payment', 'created_at')
op.drop_column('payment', 'updated_by')
op.drop_column('payment', 'created_by')
op.drop_column('payment', 'payment_date')
op.drop_column('payment', 'paid_amount')
op.drop_column('payment', 'transaction_id')
op.create_table('client',
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('client_id_seq'::regclass)"), autoincrement=True, nullable=False),
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='client_pkey'),
sa.UniqueConstraint('tin_number', name='client_tin_number_key', postgresql_include=[], postgresql_nulls_not_distinct=False),
postgresql_ignore_search_path=False
)
op.create_table('supplier',
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('supplier_id_seq'::regclass)"), autoincrement=True, nullable=False),
sa.Column('tin_number', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('names', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('phone_number', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='supplier_pkey'),
sa.UniqueConstraint('tin_number', name='supplier_tin_number_key', postgresql_include=[], postgresql_nulls_not_distinct=False),
postgresql_ignore_search_path=False
)
op.create_table('credit',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('transcation_type', postgresql.ENUM('BUY', 'SELL', name='tradetype'), autoincrement=False, nullable=False),
sa.Column('product_code', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('supplier_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('qty', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('amount', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], name=op.f('credit_client_id_fkey')),
sa.ForeignKeyConstraint(['product_code'], ['product.product_code'], name=op.f('credit_product_code_fkey')),
sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], name=op.f('credit_supplier_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('credit_pkey'))
)
op.drop_table('credit_accounts')
op.drop_table('transactions')
op.drop_table('transaction_details')
op.drop_table('inventory')
op.drop_table('user')
op.drop_table('partner')
# ### end Alembic commands ###
-30
View File
@@ -1,30 +0,0 @@
"""
"""
from fastapi import Depends
from sqlmodel import Session, SQLModel, select
from app.core.db import get_session
from app.schemas.models import Client
from typing import Type, Optional, Annotated
SessionDep = Annotated[Session, Depends(get_session)]
def exists(session: Session, model: Type[SQLModel], **filters) -> Optional[bool]:
"""
Checks if a request exists in the given model using any filters.
Example:
exists(session, Client, phone="0781232465", tax_number="TIN123")
"""
if not filters:
raise ValueError("At least one filter must be provided")
stmt = select(model)
for field, value in filters.items():
if not hasattr(model, field):
raise ValueError(f"Invalid filter field: {field}")
stmt = stmt.where(getattr(model, field) == value)
result = session.exec(stmt).first()
return result is not None
View File
+198
View File
@@ -0,0 +1,198 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, require_admin
from app.schemas.models import Credit, Partner, Transaction
from app.schemas.schemas import (
CreditCreate,
CreditUpdate,
CreditResponse,
UserResponse
)
from typing import List
router = APIRouter(prefix="/credit", tags=["credit"])
# Create Credit
@router.post("/", response_model=CreditResponse, status_code=status.HTTP_201_CREATED)
def create_credit(
credit: CreditCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Create new credit account (requires write access)."""
# Validate partner exists
partner = session.get(Partner, credit.partner_id)
if not partner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Partner not found"
)
# Validate transaction exists
transaction = session.get(Transaction, credit.transaction_id)
if not transaction:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transaction not found"
)
# Check if credit account already exists for this partner
existing_credit = session.exec(
select(Credit).where(Credit.partner_id == credit.partner_id)
).first()
if existing_credit:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Credit account already exists for this partner"
)
# Create credit with audit fields
credit_data = credit.model_dump()
credit_data["created_by"] = current_user.id
credit_data["updated_by"] = current_user.id
db_credit = Credit(**credit_data)
session.add(db_credit)
session.commit()
session.refresh(db_credit)
return db_credit
# Read all Credit accounts
@router.get("/", response_model=List[CreditResponse])
def read_credits(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get all credit accounts (requires authentication)."""
credits = session.exec(
select(Credit).offset(skip).limit(limit)
).all()
return credits
# Read Credit by partner
@router.get("/partner/{partner_id}", response_model=CreditResponse)
def read_credit_by_partner(
partner_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get credit account for a specific partner (requires authentication)."""
# Validate partner exists
partner = session.get(Partner, partner_id)
if not partner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Partner not found"
)
credit = session.exec(
select(Credit).where(Credit.partner_id == partner_id)
).first()
if not credit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credit account not found for this partner"
)
return credit
# Read single Credit by ID
@router.get("/{credit_id}", response_model=CreditResponse)
def read_credit_by_id(
credit_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific credit account by ID (requires authentication)."""
credit = session.get(Credit, credit_id)
if not credit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credit account not found"
)
return credit
# Update Credit
@router.put("/{credit_id}", response_model=CreditResponse)
def update_credit(
credit_id: int,
credit: CreditUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Update specific credit account (requires write access)."""
db_credit = session.get(Credit, credit_id)
if not db_credit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credit account not found"
)
update_data = credit.model_dump(exclude_unset=True)
# Validate partner if being updated
if "partner_id" in update_data:
partner = session.get(Partner, update_data["partner_id"])
if not partner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Partner not found"
)
# Check for duplicate partner (excluding current record)
existing_credit = session.exec(
select(Credit).where(
Credit.partner_id == update_data["partner_id"],
Credit.id != credit_id
)
).first()
if existing_credit:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Credit account already exists for this partner"
)
# Validate transaction if being updated
if "transaction_id" in update_data:
transaction = session.get(Transaction, update_data["transaction_id"])
if not transaction:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transaction not found"
)
# Track who updated
update_data["updated_by"] = current_user.id
# Update credit
for key, value in update_data.items():
setattr(db_credit, key, value)
session.add(db_credit)
session.commit()
session.refresh(db_credit)
return db_credit
# Delete Credit
@router.delete("/{credit_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_credit(
credit_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Delete specific credit account (admin only)."""
credit = session.get(Credit, credit_id)
if not credit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credit account not found"
)
session.delete(credit)
session.commit()
return None
+175
View File
@@ -0,0 +1,175 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, require_admin
from app.schemas.models import Inventory, Product
from app.schemas.schemas import (
InventoryCreate,
InventoryUpdate,
InventoryResponse,
UserResponse
)
from typing import List
router = APIRouter(prefix="/inventory", tags=["inventory"])
# Create Inventory
@router.post("/", response_model=InventoryResponse, status_code=status.HTTP_201_CREATED)
def create_inventory(
inventory: InventoryCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Create new inventory entry (requires write access)."""
# Validate product exists
product = session.get(Product, inventory.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product not found"
)
# Check if inventory already exists for this product
existing_inventory = session.exec(
select(Inventory).where(Inventory.product_id == inventory.product_id)
).first()
if existing_inventory:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Inventory entry already exists for this product"
)
# Create inventory
inventory_data = inventory.model_dump()
db_inventory = Inventory(**inventory_data)
session.add(db_inventory)
session.commit()
session.refresh(db_inventory)
return db_inventory
# Read all Inventory
@router.get("/", response_model=List[InventoryResponse])
def read_inventory(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get all inventory entries (requires authentication)."""
inventory = session.exec(
select(Inventory).offset(skip).limit(limit)
).all()
return inventory
# Read Inventory by product
@router.get("/product/{product_id}", response_model=InventoryResponse)
def read_inventory_by_product(
product_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get inventory for a specific product (requires authentication)."""
# Validate product exists
product = session.get(Product, product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
inventory = session.exec(
select(Inventory).where(Inventory.product_id == product_id)
).first()
if not inventory:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory not found for this product"
)
return inventory
# Read single Inventory by ID
@router.get("/{inventory_id}", response_model=InventoryResponse)
def read_inventory_by_id(
inventory_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific inventory entry by ID (requires authentication)."""
inventory = session.get(Inventory, inventory_id)
if not inventory:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory entry not found"
)
return inventory
# Update Inventory
@router.put("/{inventory_id}", response_model=InventoryResponse)
def update_inventory(
inventory_id: int,
inventory: InventoryUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Update specific inventory entry (requires write access)."""
db_inventory = session.get(Inventory, inventory_id)
if not db_inventory:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory entry not found"
)
update_data = inventory.model_dump(exclude_unset=True)
# Validate product if being updated
if "product_id" in update_data:
product = session.get(Product, update_data["product_id"])
if not product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product not found"
)
# Check for duplicate product (excluding current record)
existing_inventory = session.exec(
select(Inventory).where(
Inventory.product_id == update_data["product_id"],
Inventory.id != inventory_id
)
).first()
if existing_inventory:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Inventory entry already exists for this product"
)
# Update inventory
for key, value in update_data.items():
setattr(db_inventory, key, value)
session.add(db_inventory)
session.commit()
session.refresh(db_inventory)
return db_inventory
# Delete Inventory
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_inventory(
inventory_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Delete specific inventory entry (admin only)."""
inventory = session.get(Inventory, inventory_id)
if not inventory:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory entry not found"
)
session.delete(inventory)
session.commit()
return None
+126
View File
@@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, require_admin
from app.schemas.models import Partner
from app.schemas.schemas import (
PartnerCreate,
PartnerUpdate,
PartnerResponse,
UserResponse
)
from typing import List
router = APIRouter(prefix="/partners", tags=["partners"])
# Create Partner
@router.post("/", response_model=PartnerResponse, status_code=status.HTTP_201_CREATED)
def create_partner(
partner: PartnerCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Create a new partner (requires write access)."""
# Check if TIN number already exists
statement = select(Partner).where(Partner.tin_number == partner.tin_number)
existing_partner = session.exec(statement).first()
if existing_partner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Partner with this TIN number already exists"
)
# Create new partner
partner_data = partner.model_dump()
db_partner = Partner(**partner_data)
session.add(db_partner)
session.commit()
session.refresh(db_partner)
return db_partner
# Read all Partners
@router.get("/", response_model=List[PartnerResponse])
def read_partners(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get all partners (requires authentication)."""
partners = session.exec(select(Partner).offset(skip).limit(limit)).all()
return partners
# Read single Partner by ID
@router.get("/{partner_id}", response_model=PartnerResponse)
def read_partner(
partner_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific partner by ID (requires authentication)."""
partner = session.get(Partner, partner_id)
if not partner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Partner not found"
)
return partner
# Update Partner
@router.put("/{partner_id}", response_model=PartnerResponse)
def update_partner(
partner_id: int,
partner: PartnerUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Update specific partner (requires write access)."""
db_partner = session.get(Partner, partner_id)
if not db_partner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Partner not found"
)
# Check for TIN number conflicts if updating TIN
update_data = partner.model_dump(exclude_unset=True)
if "tin_number" in update_data:
statement = select(Partner).where(
Partner.tin_number == update_data["tin_number"],
Partner.id != partner_id
)
existing_partner = session.exec(statement).first()
if existing_partner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Partner with this TIN number already exists"
)
# Update partner
for key, value in update_data.items():
setattr(db_partner, key, value)
session.add(db_partner)
session.commit()
session.refresh(db_partner)
return db_partner
# Delete Partner
@router.delete("/{partner_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_partner(
partner_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Delete specific partner (admin only)."""
partner = session.get(Partner, partner_id)
if not partner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Partner not found"
)
session.delete(partner)
session.commit()
return None
+155
View File
@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, require_admin
from app.schemas.models import Payment, Transaction
from app.schemas.schemas import (
PaymentCreate,
PaymentUpdate,
PaymentResponse,
UserResponse
)
from typing import List
router = APIRouter(prefix="/payments", tags=["payments"])
# Create Payment
@router.post("/", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
def create_payment(
payment: PaymentCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Create new payment (requires write access)."""
# Validate transaction exists
transaction = session.get(Transaction, payment.transaction_id)
if not transaction:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transaction not found"
)
# Create payment with audit fields
payment_data = payment.model_dump()
payment_data["created_by"] = current_user.id
payment_data["updated_by"] = current_user.id
db_payment = Payment(**payment_data)
session.add(db_payment)
session.commit()
session.refresh(db_payment)
return db_payment
# Read all Payments
@router.get("/", response_model=List[PaymentResponse])
def read_payments(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get all payments (requires authentication)."""
payments = session.exec(
select(Payment).offset(skip).limit(limit)
).all()
return payments
# Read Payments by transaction
@router.get("/transaction/{transaction_id}", response_model=List[PaymentResponse])
def read_payments_by_transaction(
transaction_id: int,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get payments for a specific transaction (requires authentication)."""
# Validate transaction exists
transaction = session.get(Transaction, transaction_id)
if not transaction:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Transaction not found"
)
statement = select(Payment).where(
Payment.transaction_id == transaction_id
).offset(skip).limit(limit)
payments = session.exec(statement).all()
return payments
# Read single Payment by ID
@router.get("/{payment_id}", response_model=PaymentResponse)
def read_payment_by_id(
payment_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific payment by ID (requires authentication)."""
payment = session.get(Payment, payment_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
return payment
# Update Payment
@router.put("/{payment_id}", response_model=PaymentResponse)
def update_payment(
payment_id: int,
payment: PaymentUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Update specific payment (requires write access)."""
db_payment = session.get(Payment, payment_id)
if not db_payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
update_data = payment.model_dump(exclude_unset=True)
# Validate transaction if being updated
if "transaction_id" in update_data:
transaction = session.get(Transaction, update_data["transaction_id"])
if not transaction:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transaction not found"
)
# Track who updated
update_data["updated_by"] = current_user.id
# Update payment
for key, value in update_data.items():
setattr(db_payment, key, value)
session.add(db_payment)
session.commit()
session.refresh(db_payment)
return db_payment
# Delete Payment
@router.delete("/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_payment(
payment_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Delete specific payment (admin only)."""
payment = session.get(Payment, payment_id)
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
session.delete(payment)
session.commit()
return None
+166
View File
@@ -0,0 +1,166 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, require_admin
from app.schemas.models import Product
from app.schemas.schemas import (
ProductCreate,
ProductUpdate,
ProductResponse,
UserResponse
)
from typing import List
router = APIRouter(prefix="/products", tags=["products"])
# Create Product
@router.post("/", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
def create_product(
product: ProductCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Create a new product (requires write access)."""
# Check if product code already exists
statement = select(Product).where(Product.product_code == product.product_code)
existing_product = session.exec(statement).first()
if existing_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this code already exists"
)
# Check if product name already exists
statement = select(Product).where(Product.product_name == product.product_name)
existing_product = session.exec(statement).first()
if existing_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this name already exists"
)
# Create new product
product_data = product.model_dump()
db_product = Product(**product_data)
session.add(db_product)
session.commit()
session.refresh(db_product)
return db_product
# Read all Products
@router.get("/", response_model=List[ProductResponse])
def read_products(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get all products (requires authentication)."""
products = session.exec(select(Product).offset(skip).limit(limit)).all()
return products
# Read single Product by ID
@router.get("/{product_id}", response_model=ProductResponse)
def read_product(
product_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific product by ID (requires authentication)."""
product = session.get(Product, product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
return product
# Read Product by code
@router.get("/code/{product_code}", response_model=ProductResponse)
def read_product_by_code(
product_code: str,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific product by code (requires authentication)."""
statement = select(Product).where(Product.product_code == product_code)
product = session.exec(statement).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
return product
# Update Product
@router.put("/{product_id}", response_model=ProductResponse)
def update_product(
product_id: int,
product: ProductUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Update specific product (requires write access)."""
db_product = session.get(Product, product_id)
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
update_data = product.model_dump(exclude_unset=True)
# Check for product code conflicts if updating code
if "product_code" in update_data:
statement = select(Product).where(
Product.product_code == update_data["product_code"],
Product.id != product_id
)
existing_product = session.exec(statement).first()
if existing_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this code already exists"
)
# Check for product name conflicts if updating name
if "product_name" in update_data:
statement = select(Product).where(
Product.product_name == update_data["product_name"],
Product.id != product_id
)
existing_product = session.exec(statement).first()
if existing_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this name already exists"
)
# Update product
for key, value in update_data.items():
setattr(db_product, key, value)
session.add(db_product)
session.commit()
session.refresh(db_product)
return db_product
# Delete Product
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(
product_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Delete specific product (admin only)."""
product = session.get(Product, product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
session.delete(product)
session.commit()
return None
+197
View File
@@ -0,0 +1,197 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, require_admin
from app.schemas.models import Transaction_details, Partner, Product
from app.schemas.schemas import (
TransactionDetailsCreate,
TransactionDetailsUpdate,
TransactionDetailsResponse,
UserResponse
)
from typing import List
router = APIRouter(prefix="/transaction-details", tags=["transaction-details"])
# Create Transaction Details
@router.post("/", response_model=TransactionDetailsResponse, status_code=status.HTTP_201_CREATED)
def create_transaction_details(
transaction_details: TransactionDetailsCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Create new transaction details (requires write access)."""
# Validate partner exists
partner = session.get(Partner, transaction_details.partner_id)
if not partner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Partner not found"
)
# Validate product exists
product = session.get(Product, transaction_details.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product not found"
)
# Create transaction details with audit fields
transaction_details_data = transaction_details.model_dump()
transaction_details_data["created_by"] = current_user.id
transaction_details_data["updated_by"] = current_user.id
db_transaction_details = Transaction_details(**transaction_details_data)
session.add(db_transaction_details)
session.commit()
session.refresh(db_transaction_details)
return db_transaction_details
# Read all Transaction Details
@router.get("/", response_model=List[TransactionDetailsResponse])
def read_transaction_details(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get all transaction details (requires authentication)."""
transaction_details = session.exec(
select(Transaction_details).offset(skip).limit(limit)
).all()
return transaction_details
# Read Transaction Details by partner
@router.get("/partner/{partner_id}", response_model=List[TransactionDetailsResponse])
def read_transaction_details_by_partner(
partner_id: int,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get transaction details for a specific partner (requires authentication)."""
# Validate partner exists
partner = session.get(Partner, partner_id)
if not partner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Partner not found"
)
statement = select(Transaction_details).where(
Transaction_details.partner_id == partner_id
).offset(skip).limit(limit)
transaction_details = session.exec(statement).all()
return transaction_details
# Read Transaction Details by product
@router.get("/product/{product_id}", response_model=List[TransactionDetailsResponse])
def read_transaction_details_by_product(
product_id: int,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get transaction details for a specific product (requires authentication)."""
# Validate product exists
product = session.get(Product, product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
statement = select(Transaction_details).where(
Transaction_details.product_id == product_id
).offset(skip).limit(limit)
transaction_details = session.exec(statement).all()
return transaction_details
# Read single Transaction Details by ID
@router.get("/{transaction_details_id}", response_model=TransactionDetailsResponse)
def read_transaction_details_by_id(
transaction_details_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
"""Get specific transaction details by ID (requires authentication)."""
transaction_details = session.get(Transaction_details, transaction_details_id)
if not transaction_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Transaction details not found"
)
return transaction_details
# Update Transaction Details
@router.put("/{transaction_details_id}", response_model=TransactionDetailsResponse)
def update_transaction_details(
transaction_details_id: int,
transaction_details: TransactionDetailsUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
"""Update specific transaction details (requires write access)."""
db_transaction_details = session.get(Transaction_details, transaction_details_id)
if not db_transaction_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Transaction details not found"
)
update_data = transaction_details.model_dump(exclude_unset=True)
# Validate partner if being updated
if "partner_id" in update_data:
partner = session.get(Partner, update_data["partner_id"])
if not partner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Partner not found"
)
# Validate product if being updated
if "product_id" in update_data:
product = session.get(Product, update_data["product_id"])
if not product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product not found"
)
# Track who updated
update_data["updated_by"] = current_user.id
# Update transaction details
for key, value in update_data.items():
setattr(db_transaction_details, key, value)
session.add(db_transaction_details)
session.commit()
session.refresh(db_transaction_details)
return db_transaction_details
# Delete Transaction Details
@router.delete("/{transaction_details_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_transaction_details(
transaction_details_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Delete specific transaction details (admin only)."""
transaction_details = session.get(Transaction_details, transaction_details_id)
if not transaction_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Transaction details not found"
)
session.delete(transaction_details)
session.commit()
return None
+88
View File
@@ -0,0 +1,88 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import require_any_access, require_write_access, get_current_active_user
from app.schemas.models import Transaction
from app.schemas.schemas import TransactionCreate, TransactionUpdate, TransactionResponse, UserResponse
from typing import List, Optional
router = APIRouter(prefix="/transactions", tags=["transactions"])
# Create Transaction
@router.post("/", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED)
def create_transaction(
transaction: TransactionCreate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
# Set created_by and updated_by to current user
transaction_data = transaction.model_dump(exclude_unset=True)
transaction_data["created_by"] = current_user.id
transaction_data["updated_by"] = current_user.id
db_transaction = Transaction(**transaction_data)
session.add(db_transaction)
session.commit()
session.refresh(db_transaction)
return db_transaction
# Read all Transactions
@router.get("/", response_model=List[TransactionResponse])
def read_transactions(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
transactions = session.exec(select(Transaction).offset(skip).limit(limit)).all()
return transactions
# Read single Transaction by ID
@router.get("/{transaction_id}", response_model=TransactionResponse)
def read_transaction(
transaction_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_any_access)
):
transaction = session.get(Transaction, transaction_id)
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
return transaction
# Update Transaction
@router.put("/{transaction_id}", response_model=TransactionResponse)
def update_transaction(
transaction_id: int,
transaction: TransactionUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
db_transaction = session.get(Transaction, transaction_id)
if not db_transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
update_data = transaction.model_dump(exclude_unset=True)
update_data["updated_by"] = current_user.id # Track who updated
for key, value in update_data.items():
setattr(db_transaction, key, value)
session.add(db_transaction)
session.commit()
session.refresh(db_transaction)
return db_transaction
# Delete Transaction
@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_transaction(
transaction_id: int,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_write_access)
):
transaction = session.get(Transaction, transaction_id)
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
session.delete(transaction)
session.commit()
return None
+203
View File
@@ -0,0 +1,203 @@
# backend/app/api/v1/users.py
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from sqlmodel import Session, select
from app.core.db import get_session
from app.core.auth import (
authenticate_user,
create_access_token,
get_password_hash,
get_current_active_user,
get_token_expiration_minutes,
require_admin,
require_write_access,
require_any_access
)
from app.schemas.models import User
from app.schemas.schemas import (
UserCreate,
UserUpdate,
UserLogin,
Token,
UserResponse
)
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/login", response_model=Token)
def login(user_credentials: UserLogin, session: Session = Depends(get_session)):
"""Authenticate user and return JWT token with role-based expiration."""
user = authenticate_user(session, user_credentials.username, user_credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Get role-based expiration time
expire_minutes = get_token_expiration_minutes(user.role)
access_token_expires = timedelta(minutes=expire_minutes)
# Create token with user data
access_token = create_access_token(
data={
"sub": user.username,
"user_id": user.id,
"role": user.role.value
},
expires_delta=access_token_expires
)
return Token(
access_token=access_token,
token_type="bearer",
expires_in=expire_minutes * 60, # Convert to seconds
user=UserResponse(
id=user.id,
username=user.username,
role=user.role
)
)
@router.get("/me", response_model=UserResponse)
def get_current_user_info(current_user: User = Depends(get_current_active_user)):
"""Get current user information from token."""
return UserResponse(
id=current_user.id,
username=current_user.username,
role=current_user.role
)
@router.get("/", response_model=list[UserResponse])
def get_all_users(
session: Session = Depends(get_session),
current_user: User = Depends(require_any_access),
skip: int = 0,
limit: int = 100
):
"""Get all users (requires any authenticated role)."""
statement = select(User).offset(skip).limit(limit)
users = session.exec(statement).all()
return [
UserResponse(id=user.id, username=user.username, role=user.role)
for user in users
]
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
user: UserCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_admin)
):
"""Create a new user (admin only)."""
# Check if username already exists
statement = select(User).where(User.username == user.username)
existing_user = session.exec(statement).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Create new user with hashed password
hashed_password = get_password_hash(user.password)
db_user = User(
username=user.username,
password_hash=hashed_password,
role=user.role
)
session.add(db_user)
session.commit()
session.refresh(db_user)
return UserResponse(
id=db_user.id,
username=db_user.username,
role=db_user.role
)
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_any_access)
):
"""Get specific user by ID (requires authentication)."""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return UserResponse(
id=user.id,
username=user.username,
role=user.role
)
# Update to handle user self-updating password
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_update: UserUpdate,
session: Session = Depends(get_session),
current_user: UserResponse = Depends(require_admin)
):
"""Update specific user (admin only)."""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update only provided fields
update_data = user_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
session.add(user)
session.commit()
session.refresh(user)
return UserResponse(
id=user.id,
username=user.username,
role=user.role
)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_admin)
):
"""Delete specific user (admin only)."""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Prevent self-deletion
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
session.delete(user)
session.commit()
return None
+140
View File
@@ -0,0 +1,140 @@
"""
Authentication utilities for JWT-based session management with role-based expiration times.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Union
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session, select
from app.core.config import settings
from app.core.db import get_session
from app.schemas.models import User
from app.schemas.schemas import TokenData, UserResponse
from app.schemas.base import UserRole
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Security scheme
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Generate password hash."""
return pwd_context.hash(password)
def authenticate_user(
session: Session,
username: str,
password: str
) -> Optional[User]:
"""Authenticate user with username and password."""
statement = select(User).where(User.username == username)
user = session.exec(statement).first()
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
def get_token_expiration_minutes(role: UserRole) -> int:
"""Get token expiration time based on user role."""
role_expiration_map = {
UserRole.ADMIN: settings.admin_token_expire_minutes,
UserRole.WRITE: settings.write_token_expire_minutes,
UserRole.READ_ONLY: settings.read_only_token_expire_minutes,
}
return role_expiration_map.get(role, settings.read_only_token_expire_minutes)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> TokenData:
"""Verify JWT token and extract token data."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
username: Optional[str] = payload.get("sub")
user_id: Optional[int] = payload.get("user_id")
role: Optional[str] = payload.get("role")
if username is None or user_id is None or role is None:
raise credentials_exception
token_data = TokenData(
username=username,
user_id=user_id,
role=UserRole(role)
)
except JWTError:
raise credentials_exception
return token_data
def get_current_user(
token_data: TokenData = Depends(verify_token),
session: Session = Depends(get_session)
) -> User:
"""Get current user from token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = session.get(User, token_data.user_id)
if user is None:
raise credentials_exception
return user
def get_current_active_user(
current_user: UserResponse = Depends(get_current_user)
) -> UserResponse:
"""Get current active user (extend this if you add user activation status)."""
return current_user
def require_role(required_roles: list[UserRole]):
"""Dependency factory for role-based access control."""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
if current_user.role not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Operation not permitted for your role"
)
return current_user
return role_checker
# Common role dependencies
require_admin = require_role([UserRole.ADMIN])
require_write_access = require_role([UserRole.ADMIN, UserRole.WRITE])
require_any_access = require_role([UserRole.ADMIN, UserRole.WRITE, UserRole.READ_ONLY])
+10
View File
@@ -6,10 +6,20 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""
Application settings loaded from environment variables.
"""
database_uri: PostgresDsn
environment: str
project_name: str
# JWT settings
secret_key: str = "your-secret-key-change-this-in-production"
algorithm: str = "HS256"
# Role-based expiration times (in minutes)
admin_token_expire_minutes: int = 60 * 24 * 7 # 7 days (default)
write_token_expire_minutes: int = 60 * 24 * 3 # 3 days (default)
read_only_token_expire_minutes: int = 60 * 8 # 8 hours (default)
model_config = SettingsConfigDict(
# One level above ./backend
+28 -9
View File
@@ -5,11 +5,16 @@ NOTE:
-
"""
from app.core.config import settings
from typing import Union
from fastapi import FastAPI
from backend.app.api.endpoints.clients import router as clients_router
from backend.app.api.endpoints.suppliers import router as supplier_router
from backend.app.api.endpoints.products import router as product_router
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.users import router as users_router
from app.api.v1.transactions import router as transactions_router
from app.api.v1.partners import router as partners_router
from app.api.v1.products import router as products_router
from app.api.v1.transaction_details import router as transaction_details_router
from app.api.v1.payments import router as payments_router
from app.api.v1.credit import router as credit_router
from app.api.v1.inventory import router as inventory_router
app = FastAPI(
@@ -18,12 +23,26 @@ app = FastAPI(
)
# CORS for React frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
app.include_router(users_router, prefix=settings.api_v1_str)
app.include_router(transactions_router, prefix=settings.api_v1_str)
app.include_router(partners_router, prefix=settings.api_v1_str)
app.include_router(products_router, prefix=settings.api_v1_str)
app.include_router(transaction_details_router, prefix=settings.api_v1_str)
app.include_router(payments_router, prefix=settings.api_v1_str)
app.include_router(credit_router, prefix=settings.api_v1_str)
app.include_router(inventory_router, prefix=settings.api_v1_str)
@app.get("/")
def read_root():
"""
"""
return {"Hello": "World"}
app.include_router(clients_router, tags=["clients"])
app.include_router(supplier_router, tags=["suppliers"])
app.include_router(product_router, tags=["products"])
return {"message": "CMT API v1"}
+8 -8
View File
@@ -14,12 +14,11 @@ The models include:
- Inventory
"""
from sqlmodel import SQLModel, Field, UniqueConstraint
from sqlmodel import SQLModel, Field
from datetime import datetime, date
from sqlalchemy import Column, DateTime, func, Enum as SQLEnum
from enum import Enum
from sqlalchemy import Column, String, CheckConstraint, DateTime, func, Enum as SQLEnum
from typing import Optional
from base import UserRole, PartnerType, TransactionType, TransactionStatus, PaymentMethod
from .base import UserRole, PartnerType, TransactionType, TransactionStatus, PaymentMethod
class User(SQLModel, table=True):
@@ -157,7 +156,7 @@ class Transaction_details(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
partner_id: int = Field(nullable=False, foreign_key="partner.id")
product_id: str = Field(nullable=False, foreign_key="product.id")
product_id: int = Field(nullable=False, foreign_key="product.id")
qty: int = Field(nullable=False)
selling_price: int = Field(nullable=False)
@@ -193,11 +192,12 @@ class Payment(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
transaction_id: int = Field(nullable=False, foreign_key="transactions.id")
payment_method: PaymentMethod = Field(
payment_method: str = Field(
sa_column=Column(
SQLEnum(PaymentMethod),
String(10),
CheckConstraint("payment_method IN ('momo', 'bank', 'cash')"),
nullable=False,
default=PaymentMethod.CASH
default="cash"
)
)
paid_amount: int = Field(nullable=False)
+184 -15
View File
@@ -2,40 +2,209 @@
Custom validation schema
"""
from sqlmodel import SQLModel
from app.schemas.base import UserRole, PartnerType, PaymentMethod
from typing import Optional
from datetime import datetime, date
from .base import TransactionType, TransactionStatus
class ClientCreate(SQLModel):
######################################################
# Users
class UserCreate(SQLModel):
username: str
password: str
role: UserRole = UserRole.READ_ONLY
class UserUpdate(SQLModel):
username: Optional[str] = None
password: Optional[str] = None
role: Optional[UserRole] = None
class UserLogin(SQLModel):
username: str
password: str
class UserResponse(SQLModel):
id: Optional[int] = None
username: str
role: UserRole
class Token(SQLModel):
access_token: str
token_type: str
expires_in: int
user: UserResponse
class TokenData(SQLModel):
username: Optional[str] = None
user_id: Optional[int] = None
role: Optional[UserRole] = None
##################################################
# Transactions
class TransactionBase(SQLModel):
partner_id: int
transcation_type: TransactionType = TransactionType.SALE
transaction_status: TransactionStatus = TransactionStatus.UNPAID
total_amount: int
class TransactionCreate(TransactionBase):
pass
class TransactionUpdate(SQLModel):
partner_id: Optional[int] = None
transcation_type: Optional[TransactionType] = None
transaction_status: Optional[TransactionStatus] = None
total_amount: Optional[int] = None
class TransactionResponse(TransactionBase):
id: int
created_by: int
updated_by: int
created_on: datetime
updated_on: datetime
##################################################
# Partners
class PartnerBase(SQLModel):
tin_number: int
names: str
phone_number: str
class ClientUpdate(SQLModel):
tin_number: Optional[int] = None
names: Optional[str] = None
type: PartnerType = PartnerType.CLIENT
phone_number: Optional[str] = None
class PartnerCreate(PartnerBase):
pass
class SupplierCreate(SQLModel):
tin_number: int
names: str
phone_number: str
class SupplierUpdate(ClientUpdate):
class PartnerUpdate(SQLModel):
tin_number: Optional[int] = None
names: Optional[str] = None
type: Optional[PartnerType] = None
phone_number: Optional[str] = None
class PartnerResponse(PartnerBase):
id: int
class ProductCreate(SQLModel):
##################################################
# Products
class ProductBase(SQLModel):
product_code: str
product_name: str
purchase_price: int
selling_price: int
class ProductCreate(ProductBase):
pass
class ProductUpdate(SQLModel):
product_code: Optional[str] = None
product_name: Optional[str] = None
purchase_price: Optional[int] = None
selling_price: Optional[int] = None
class ProductResponse(ProductBase):
id: int
date_modified: datetime
##################################################
# Transaction Details
class TransactionDetailsBase(SQLModel):
partner_id: int
product_id: int
qty: int
selling_price: int
total_value: int
class TransactionDetailsCreate(TransactionDetailsBase):
pass
class TransactionDetailsUpdate(SQLModel):
partner_id: Optional[int] = None
product_id: Optional[int] = None
qty: Optional[int] = None
selling_price: Optional[int] = None
total_value: Optional[int] = None
class TransactionDetailsResponse(TransactionDetailsBase):
id: int
created_by: int
updated_by: int
created_at: datetime
updated_at: datetime
##################################################
# Payments
class PaymentBase(SQLModel):
transaction_id: int
payment_method: PaymentMethod = PaymentMethod.CASH
paid_amount: int
payment_date: date
class PaymentCreate(PaymentBase):
pass
class PaymentUpdate(SQLModel):
transaction_id: Optional[int] = None
payment_method: Optional[PaymentMethod] = None
paid_amount: Optional[int] = None
payment_date: Optional[date] = None
class PaymentResponse(PaymentBase):
id: int
created_by: int
updated_by: int
created_at: datetime
updated_at: datetime
##################################################
# Credit
class CreditBase(SQLModel):
partner_id: int
transaction_id: int
credit_amount: int
credit_limit: int
balance: int
class CreditCreate(CreditBase):
pass
class CreditUpdate(SQLModel):
partner_id: Optional[int] = None
transaction_id: Optional[int] = None
credit_amount: Optional[int] = None
credit_limit: Optional[int] = None
balance: Optional[int] = None
class CreditResponse(CreditBase):
id: int
created_by: int
updated_by: int
created_at: datetime
updated_at: datetime
##################################################
# Inventory
class InventoryBase(SQLModel):
product_id: int
total_qty: int
class InventoryCreate(InventoryBase):
pass
class InventoryUpdate(SQLModel):
product_id: Optional[int] = None
total_qty: Optional[int] = None
class InventoryResponse(InventoryBase):
id: int