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:
@@ -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 ###
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user