From b0f9685a0abdf93c1fcadb94717c345f4b929627 Mon Sep 17 00:00:00 2001 From: LinMihigo Date: Fri, 25 Jul 2025 21:27:16 +0200 Subject: [PATCH] Chore: Pushing changes to migrate from windows/wsl to fedora --- .gitignore | 1 + README.md | 15 +++ backend/README.md | 3 + ..._date_modified_columns_add_default_none.py | 32 ++++++ .../8aefa882e096_product_id_made_nullable.py | 38 +++++++ ...product_id_made_nullable_using_default_.py | 32 ++++++ ...e_making_product_date_modified_default_.py | 32 ++++++ backend/app/api/clients/endpoints.py | 70 +++++++++++-- backend/app/api/main.py | 11 --- backend/app/api/products/__init__.py | 0 backend/app/api/products/endpoints.py | 99 +++++++++++++++++++ backend/app/api/suppliers/__init__.py | 0 backend/app/api/suppliers/endpoints.py | 88 +++++++++++++++++ backend/app/main.py | 4 + backend/app/schemas/models.py | 5 +- backend/app/schemas/schemas.py | 31 ++++++ 16 files changed, 442 insertions(+), 19 deletions(-) create mode 100644 backend/app/alembic/versions/174e0494276d_date_modified_columns_add_default_none.py create mode 100644 backend/app/alembic/versions/8aefa882e096_product_id_made_nullable.py create mode 100644 backend/app/alembic/versions/b5ff3e70bd95_product_id_made_nullable_using_default_.py create mode 100644 backend/app/alembic/versions/d89dba0432de_making_product_date_modified_default_.py delete mode 100644 backend/app/api/main.py create mode 100644 backend/app/api/products/__init__.py create mode 100644 backend/app/api/products/endpoints.py create mode 100644 backend/app/api/suppliers/__init__.py create mode 100644 backend/app/api/suppliers/endpoints.py diff --git a/.gitignore b/.gitignore index eeb8a6e..417bbf0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ **/__pycache__ +**/.pytest_cache diff --git a/README.md b/README.md index 1dcda74..6faa159 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,18 @@ cat db_setup.sql | mysql -u root -p -- table setup cat db_table_setup.sql | mysql -u admin -p CMT ``` + +### Testing +``` +cd backend +pytest app/test.py + +# Curl POST command +curl -X POST "http://localhost:8000/clients/" -H "Content-Type: application/json" -d '{"tin_number": 100752121, "names": "Pax au Telemanus", "phone_number": "0788475021"}' + +# Trying updating client details +curl -X PATCH "http://localhost:8000/clients/1" -H "Content-Type: application/json" -d '{"names": "John Wick"}' + +# Deletion +curl -X DELETE http://localhost:8000/clients/2 +``` diff --git a/backend/README.md b/backend/README.md index 9a66902..c7aed3d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,4 +6,7 @@ cd backend alembic revision --autogenerate -m "Header message" alembic upgrade head + +# Making alembic DB is up-to-date without actually running the migration +alembic stamp head ``` diff --git a/backend/app/alembic/versions/174e0494276d_date_modified_columns_add_default_none.py b/backend/app/alembic/versions/174e0494276d_date_modified_columns_add_default_none.py new file mode 100644 index 0000000..04a46ed --- /dev/null +++ b/backend/app/alembic/versions/174e0494276d_date_modified_columns_add_default_none.py @@ -0,0 +1,32 @@ +"""Date_modified columns - add default=None + +Revision ID: 174e0494276d +Revises: d89dba0432de +Create Date: 2025-06-15 20:42:30.850962 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '174e0494276d' +down_revision: Union[str, None] = 'd89dba0432de' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/8aefa882e096_product_id_made_nullable.py b/backend/app/alembic/versions/8aefa882e096_product_id_made_nullable.py new file mode 100644 index 0000000..66ac8f5 --- /dev/null +++ b/backend/app/alembic/versions/8aefa882e096_product_id_made_nullable.py @@ -0,0 +1,38 @@ +"""Product id made nullable + +Revision ID: 8aefa882e096 +Revises: e8c4300db3cb +Create Date: 2025-06-15 19:33:39.299803 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '8aefa882e096' +down_revision: Union[str, None] = 'e8c4300db3cb' +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.alter_column('product', 'id', + existing_type=mysql.INTEGER(), + nullable=True, + autoincrement=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('product', 'id', + existing_type=mysql.INTEGER(), + nullable=False, + autoincrement=True) + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/b5ff3e70bd95_product_id_made_nullable_using_default_.py b/backend/app/alembic/versions/b5ff3e70bd95_product_id_made_nullable_using_default_.py new file mode 100644 index 0000000..b794380 --- /dev/null +++ b/backend/app/alembic/versions/b5ff3e70bd95_product_id_made_nullable_using_default_.py @@ -0,0 +1,32 @@ +"""Product id made nullable using 'default=None' + +Revision ID: b5ff3e70bd95 +Revises: 8aefa882e096 +Create Date: 2025-06-15 19:38:20.874456 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b5ff3e70bd95' +down_revision: Union[str, None] = '8aefa882e096' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/d89dba0432de_making_product_date_modified_default_.py b/backend/app/alembic/versions/d89dba0432de_making_product_date_modified_default_.py new file mode 100644 index 0000000..cae31d9 --- /dev/null +++ b/backend/app/alembic/versions/d89dba0432de_making_product_date_modified_default_.py @@ -0,0 +1,32 @@ +"""Making Product.date_modified default=None + +Revision ID: d89dba0432de +Revises: b5ff3e70bd95 +Create Date: 2025-06-15 20:06:11.734486 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd89dba0432de' +down_revision: Union[str, None] = 'b5ff3e70bd95' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/api/clients/endpoints.py b/backend/app/api/clients/endpoints.py index a8ebf2a..6b29bab 100644 --- a/backend/app/api/clients/endpoints.py +++ b/backend/app/api/clients/endpoints.py @@ -1,18 +1,19 @@ """ -The Client table +The Clients endpoint """ from fastapi import APIRouter, HTTPException -from sqlmodel import Session, select +from sqlmodel import select from app.api.deps import SessionDep, exists from app.schemas.models import Client -from app.schemas.schemas import ClientCreate -from typing import Sequence +from app.schemas.schemas import ClientCreate, ClientUpdate +from pydantic import ValidationError +from typing import Sequence, List, Optional router = APIRouter(prefix="/clients", tags=["clients"]) -@router.get("/", response_model=Client) -def fetch_clients(client: Client, session: SessionDep) -> Sequence[Client]: +@router.get("/", response_model=List[Client]) +def fetch_clients(session: SessionDep) -> Sequence[Client]: """Fetch client list """ clients = session.exec(select(Client)).all() @@ -21,11 +22,66 @@ def fetch_clients(client: Client, session: SessionDep) -> Sequence[Client]: @router.post("/", response_model=ClientCreate) def create_client(client_data: ClientCreate, session: SessionDep) -> Client: + """Create a client + """ existing = exists(session, Client, tin_number=client_data.tin_number) if existing: raise HTTPException(status_code=400, detail="Client with this tin number already exists") - client = Client.model_validate(client_data) + + try: + client = Client.model_validate(client_data) + except ValidationError as e: + raise HTTPException(status_code=400, detail=e.errors()) + session.add(client) session.commit() session.refresh(client) return client + + +@router.get("/{client_id}", response_model=Client) +def get_client(client_id: int, session: SessionDep) -> Optional[Client]: + """Returns a client by its ID + """ + stmt = select(Client).where(Client.id == client_id) + result: Optional[Client] = session.exec(stmt).first() + + if not result: + raise HTTPException(status_code=404, detail="Client not found") + + return result + + +@router.patch("/{client_id}", response_model=ClientCreate) +def update_client( + client_id: int, + client_data: ClientUpdate, + session: SessionDep) -> Optional[Client]: + """Updates a client using its ID + """ + client = session.get(Client, client_id) + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + update_fields = client_data.model_dump(exclude_unset=True) + + for key, value in update_fields.items(): + setattr(client, key, value) + + session.add(client) + session.commit() + session.refresh(client) + return client + + +@router.delete("/{client_id}", status_code=204) +def delete_client(client_id: int, session: SessionDep): + """Deletes a client + """ + client = session.get(Client, client_id) + + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + session.delete(client) + session.commit() diff --git a/backend/app/api/main.py b/backend/app/api/main.py deleted file mode 100644 index 579dcc4..0000000 --- a/backend/app/api/main.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -API Home -""" -from fastapi import APIRouter -from app.core.config import settings - - -api_router = APIRouter() - -#if settings.environment == "local": -# api_router.include_router() diff --git a/backend/app/api/products/__init__.py b/backend/app/api/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/products/endpoints.py b/backend/app/api/products/endpoints.py new file mode 100644 index 0000000..18ace6c --- /dev/null +++ b/backend/app/api/products/endpoints.py @@ -0,0 +1,99 @@ +""" +The products endpoint +""" +from fastapi import APIRouter, HTTPException +from sqlmodel import select +from app.api.deps import SessionDep, exists +from app.schemas.models import Product +from app.schemas.schemas import ProductCreate, ProductUpdate +from pydantic import ValidationError +from typing import Sequence, List, Optional + + +router = APIRouter(prefix="/products", tags=["products"]) + + +@router.get("/", response_model=List[Product]) +def fetch_products(session: SessionDep) -> Sequence[Product]: + """Fetch product list + """ + products = session.exec(select(Product)).all() + return products + + +@router.post("/", response_model=ProductCreate) +def create_product(product_data: ProductCreate, session: SessionDep) -> Product: + """Create a product + """ + name_exists = exists(session, Product, product_name=product_data.product_name) + if name_exists: + raise HTTPException( + status_code=400, + detail="Product with this product_name exists" + ) + + existing = exists(session, Product, product_code=product_data.product_code) + if existing: + raise HTTPException( + status_code=400, + detail="Product with this product_code exists" + ) + + try: + product = Product.model_validate(product_data) + except ValidationError as e: + raise HTTPException(status_code=400, detail=e.errors()) + + session.add(product) + session.commit() + session.refresh(product) + return product + + +@router.get("/{product_id}", response_model=Product) +def get_product(product_id: int, session: SessionDep) -> Optional[Product]: + """Returns a product by its id + """ + stmt = select(Product).where(Product.id == product_id) + result: Optional[Product] = session.exec(stmt).first() + + if not result: + raise HTTPException(status_code=404, detail="Product not found") + + return result + + +@router.patch("/{product_id}",response_model=ProductUpdate) +def update_product( + product_id: int, + product_data: ProductUpdate, + session: SessionDep +) -> Optional[Product]: + """Updates a product + """ + product = session.get(Product, product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + updated_fields = product_data.model_dump(exclude_unset=True) + + for key, value in updated_fields.items(): + setattr(product, key, value) + + session.add(product) + session.commit() + session.refresh(product) + return product + + +@router.delete("/{product_id}", status_code=204) +def delete_product(product_id: int, session: SessionDep): + """Deletes a product + """ + product = session.get(Product, product_id) + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + session.delete(product) + session.commit() diff --git a/backend/app/api/suppliers/__init__.py b/backend/app/api/suppliers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/suppliers/endpoints.py b/backend/app/api/suppliers/endpoints.py new file mode 100644 index 0000000..cef2aac --- /dev/null +++ b/backend/app/api/suppliers/endpoints.py @@ -0,0 +1,88 @@ +"""The suppliers endpoint +""" +from fastapi import APIRouter, HTTPException +from sqlmodel import select +from app.api.deps import SessionDep, exists +from app.schemas.models import Supplier +from app.schemas.schemas import SupplierCreate, SupplierUpdate +from pydantic import ValidationError +from typing import Sequence, List, Optional + + +router = APIRouter(prefix="/suppliers", tags=["suppliers"]) + + +@router.get("/", response_model=List[Supplier]) +def fetch_suppliers(session: SessionDep) -> Sequence[Supplier]: + """Fetch supplier list + """ + suppliers = session.exec(select(Supplier)).all() + return suppliers + + +@router.post("/", response_model=SupplierCreate) +def create_supplier(supplier_data: SupplierCreate, session: SessionDep) -> Supplier: + """Create a supplier + """ + existing = exists(session, Supplier, tin_number=supplier_data.tin_number) + if existing: + raise HTTPException(status_code=400, detail="Supplier with this tin_number already exists") + + try: + supplier = Supplier.model_validate(supplier_data) + except ValidationError as e: + raise HTTPException(status_code=400, detail=e.errors()) + + session.add(supplier) + session.commit() + session.refresh(supplier) + return supplier + + +@router.get("/{supplier_id}", response_model=Supplier) +def get_supplier(supplier_id: int, session: SessionDep) -> Optional[Supplier]: + """Returns a supplier by its ID + """ + stmt = select(Supplier).where(Supplier.id == supplier_id) + result: Optional[Supplier] = session.exec(stmt).first() + + if not result: + raise HTTPException(status_code=404, detail="Supplier not found") + + return result + + +@router.patch("/{supplier_id}", response_model=SupplierUpdate) +def update_supplier( + supplier_id: int, + supplier_data: SupplierUpdate, + session: SessionDep +) -> Optional[Supplier]: + """Updates a supplier's details + """ + supplier = session.get(Supplier, supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + updated_fields = supplier_data.model_dump(exclude_unset=True) + + for key, value in updated_fields.items(): + setattr(supplier, key, value) + + session.add(supplier) + session.commit() + session.refresh(supplier) + return supplier + + +@router.delete("/{supplier_id}", status_code=204) +def delete_supplier(supplier_id: int, session: SessionDep): + """Deletes a supplier + """ + supplier = session.get(Supplier, supplier_id) + + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + session.delete(supplier) + session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 7057a06..1d205d5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,8 @@ from app.core.config import settings from typing import Union from fastapi import FastAPI from app.api.clients.endpoints import router as clients_router +from app.api.suppliers.endpoints import router as supplier_router +from app.api.products.endpoints import router as product_router app = FastAPI( @@ -23,3 +25,5 @@ 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"]) diff --git a/backend/app/schemas/models.py b/backend/app/schemas/models.py index 252d2c7..d1f6c7a 100644 --- a/backend/app/schemas/models.py +++ b/backend/app/schemas/models.py @@ -49,11 +49,12 @@ class Product(SQLModel, table=True): """ __table_args__ = (UniqueConstraint("product_code"),) - id: Optional[int] = Field(nullable=False, primary_key=True) + id: Optional[int] = Field(default=None, primary_key=True) product_code: str = Field(max_length=10, nullable=False) product_name: str = Field(max_length=20, nullable=False, unique=True) purchase_price: int = Field(nullable=False) date_modified: datetime = Field( + default=None, sa_column=Column(DateTime, server_default=func.now(), server_onupdate=func.now()) @@ -77,6 +78,7 @@ class Payment(SQLModel, table=True): amount: int = Field(nullable=False) payment_method: str = Field(max_length=24, nullable=False) date: datetime = Field( + default=None, sa_column=Column(DateTime, server_default=func.now()) ) @@ -96,5 +98,6 @@ class Credit(SQLModel, table=True): qty: int = Field(nullable=False) amount: int = Field(nullable=False) date: datetime = Field( + default=None, sa_column=Column(DateTime, server_default=func.now()) ) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 8def913..4ac4d30 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,7 +1,38 @@ from sqlmodel import SQLModel +from typing import Optional class ClientCreate(SQLModel): tin_number: int names: str phone_number: str + + +class ClientUpdate(SQLModel): + tin_number: Optional[int] = None + names: Optional[str] = None + phone_number: Optional[str] = None + + +class SupplierCreate(SQLModel): + tin_number: int + names: str + phone_number: str + + +class SupplierUpdate(ClientUpdate): + tin_number: Optional[int] = None + names: Optional[str] = None + phone_number: Optional[str] = None + + +class ProductCreate(SQLModel): + product_code: str + product_name: str + purchase_price: int + + +class ProductUpdate(SQLModel): + product_code: Optional[str] = None + product_name: Optional[str] = None + purchase_price: Optional[int] = None