WIP: initial fastapi endpoint implementation
Client endpoint - GET & POST implemented
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# CMT Backend
|
||||
## Usage
|
||||
### API
|
||||
### Alembic
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "Header message"
|
||||
alembic upgrade head
|
||||
```
|
||||
@@ -7,7 +7,7 @@ from alembic import context
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from sqlmodel import SQLModel
|
||||
from app.models import Client, Supplier, Product, Payment, Credit
|
||||
from app.schemas.models import Client, Supplier, Product, Payment, Credit
|
||||
|
||||
load_dotenv()
|
||||
# this is the Alembic Config object, which provides
|
||||
@@ -24,7 +24,7 @@ if config.config_file_name is not None:
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = SQLModel.metadata
|
||||
url = os.getenv("DATABASE_URL")
|
||||
url = os.getenv("DATABASE_URI")
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Checking for updates
|
||||
|
||||
Revision ID: e8c4300db3cb
|
||||
Revises: bfb086d8d500
|
||||
Create Date: 2025-06-08 19:06:55.200977
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e8c4300db3cb'
|
||||
down_revision: Union[str, None] = 'bfb086d8d500'
|
||||
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 ###
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
API Home
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
The Client table
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from app.api.deps import SessionDep, exists
|
||||
from app.schemas.models import Client
|
||||
from app.schemas.schemas import ClientCreate
|
||||
from typing import Sequence
|
||||
|
||||
router = APIRouter(prefix="/clients", tags=["clients"])
|
||||
|
||||
|
||||
@router.get("/", response_model=Client)
|
||||
def fetch_clients(client: Client, session: SessionDep) -> Sequence[Client]:
|
||||
"""Fetch client list
|
||||
"""
|
||||
clients = session.exec(select(Client)).all()
|
||||
return clients
|
||||
|
||||
|
||||
@router.post("/", response_model=ClientCreate)
|
||||
def create_client(client_data: ClientCreate, session: SessionDep) -> 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)
|
||||
session.add(client)
|
||||
session.commit()
|
||||
session.refresh(client)
|
||||
return client
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
"""
|
||||
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,11 @@
|
||||
"""
|
||||
API Home
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
#if settings.environment == "local":
|
||||
# api_router.include_router()
|
||||
@@ -1,41 +0,0 @@
|
||||
import secrets
|
||||
import warnings
|
||||
from typing import Annotated, Any, Literal
|
||||
from pydantic import (
|
||||
MySQLDsn
|
||||
)
|
||||
from pydantic_core import MultiHostUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
"""
|
||||
model_config = SettingsConfigDict(
|
||||
# One level above ./backend
|
||||
env_file='../.env',
|
||||
env_ignore_empty=True,
|
||||
extra='ignore'
|
||||
)
|
||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
||||
|
||||
MYSQL_SERVER: str
|
||||
MYSQL_PORT: int = 3306
|
||||
MYSQL_USER: str
|
||||
MYSQL_PASSWORD: str = ""
|
||||
MYSQL_DB: str = ""
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> MySQLDsn:
|
||||
return MultiHostUrl.build(
|
||||
scheme="mysql+mysqldb",
|
||||
username=self.MYSQL_USER,
|
||||
password=self.MYSQL_PASSWORD,
|
||||
host=self.MYSQL_SERVER,
|
||||
port=self.MYSQL_PORT,
|
||||
path=self.MYSQL_DB
|
||||
) # type: ignore
|
||||
|
||||
settings = Settings() # type: ignore
|
||||
@@ -0,0 +1,22 @@
|
||||
from pydantic import (
|
||||
MySQLDsn
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
"""
|
||||
database_uri: MySQLDsn
|
||||
environment: str
|
||||
project_name: str
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
# One level above ./backend
|
||||
env_file='../.env',
|
||||
env_ignore_empty=True,
|
||||
extra='ignore'
|
||||
)
|
||||
api_v1_str: str = "/api/v1"
|
||||
|
||||
settings = Settings() # type: ignore
|
||||
@@ -0,0 +1,11 @@
|
||||
from sqlmodel import Session, create_engine
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(str(settings.database_uri))
|
||||
|
||||
|
||||
def get_session():
|
||||
"""main interface to interact with db
|
||||
"""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
The Client table
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import func, select
|
||||
from app.models import Client, Supplier, Product, Payment, Credit
|
||||
from typing import Any
|
||||
|
||||
router = APIRouter(prefix="/client", tags=["items"])
|
||||
|
||||
|
||||
@router.get("/", response_model=Client)
|
||||
def read_clients(
|
||||
session: SessionDep,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
TODO: when Credit.purchase_price is updated, update Product.purchase_price
|
||||
"""
|
||||
@@ -1,9 +0,0 @@
|
||||
from sqlmodel import Session, create_engine, select
|
||||
from app.config import settings
|
||||
from app.models import Client, Supplier
|
||||
|
||||
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
|
||||
|
||||
def init_db(session: Session) -> None:
|
||||
""""""
|
||||
+8
-3
@@ -4,17 +4,22 @@
|
||||
NOTE:
|
||||
-
|
||||
"""
|
||||
from app.config import settings
|
||||
from app.core.config import settings
|
||||
from typing import Union
|
||||
from fastapi import FastAPI
|
||||
from app.api.clients.endpoints import router as clients_router
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json"
|
||||
title=settings.project_name,
|
||||
openapi_url=f"{settings.api_v1_str}/openapi.json"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
"""
|
||||
"""
|
||||
return {"Hello": "World"}
|
||||
|
||||
app.include_router(clients_router, tags=["clients"])
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class ClientCreate(SQLModel):
|
||||
tin_number: int
|
||||
names: str
|
||||
phone_number: str
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.deps import SessionDep
|
||||
from app.schemas.schemas import ClientCreate
|
||||
from app.api.clients.endpoints import create_client
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
# Fixture for a temporary in-memory database session
|
||||
@pytest.fixture
|
||||
def session():
|
||||
engine = create_engine(str(settings.database_uri), echo=False)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def test_create_client_success(session: SessionDep):
|
||||
client_data = ClientCreate(tin_number=781410046, names="Lin", phone_number="0781410046")
|
||||
client = create_client(client_data, session)
|
||||
|
||||
assert client.id is not None
|
||||
assert client.names == "Lin"
|
||||
assert client.tin_number == 781410046
|
||||
assert client.phone_number == "0781410046"
|
||||
|
||||
|
||||
def test_create_client_duplicate_name(session):
|
||||
client_data = ClientCreate(tin_number=781410045, names="John Doe", phone_number="0781410045")
|
||||
|
||||
# Try to create another client with the same name
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_client(client_data, session)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "already exists" in exc_info.value.detail
|
||||
Reference in New Issue
Block a user