From e60489715a5c35a5882ad8950a2f549cbd36f816 Mon Sep 17 00:00:00 2001 From: LinMihigo Date: Sun, 8 Jun 2025 23:17:58 +0200 Subject: [PATCH] WIP: initial fastapi endpoint implementation Client endpoint - GET & POST implemented --- .env | 15 ++++--- backend/README.md | 9 ++++ backend/app/alembic/env.py | 4 +- .../e8c4300db3cb_checking_for_updates.py | 32 +++++++++++++++ backend/app/api/api.py | 7 ---- backend/app/{crud => api/clients}/__init__.py | 0 backend/app/api/clients/endpoints.py | 31 ++++++++++++++ backend/app/api/deps.py | 30 ++++++++++++++ backend/app/api/main.py | 11 +++++ backend/app/config.py | 41 ------------------- backend/app/{auth.py => core/__init__.py} | 0 backend/app/core/config.py | 22 ++++++++++ backend/app/core/db.py | 11 +++++ backend/app/crud/client.py | 15 ------- backend/app/crud/credit.py | 0 backend/app/crud/login.py | 0 backend/app/crud/payment.py | 0 backend/app/crud/product.py | 3 -- backend/app/crud/supplier.py | 0 backend/app/db.py | 9 ---- backend/app/main.py | 11 +++-- .../app/{crud/auth.py => schemas/__init__.py} | 0 backend/app/{ => schemas}/models.py | 0 backend/app/schemas/schemas.py | 7 ++++ backend/app/test.py | 37 +++++++++++++++++ 25 files changed, 207 insertions(+), 88 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/app/alembic/versions/e8c4300db3cb_checking_for_updates.py delete mode 100644 backend/app/api/api.py rename backend/app/{crud => api/clients}/__init__.py (100%) create mode 100644 backend/app/api/clients/endpoints.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/main.py delete mode 100644 backend/app/config.py rename backend/app/{auth.py => core/__init__.py} (100%) create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/db.py delete mode 100644 backend/app/crud/client.py delete mode 100644 backend/app/crud/credit.py delete mode 100644 backend/app/crud/login.py delete mode 100644 backend/app/crud/payment.py delete mode 100644 backend/app/crud/product.py delete mode 100644 backend/app/crud/supplier.py delete mode 100644 backend/app/db.py rename backend/app/{crud/auth.py => schemas/__init__.py} (100%) rename backend/app/{ => schemas}/models.py (100%) create mode 100644 backend/app/schemas/schemas.py create mode 100644 backend/app/test.py diff --git a/.env b/.env index 12e9435..3366f86 100644 --- a/.env +++ b/.env @@ -1,9 +1,8 @@ -DATABASE_URL=mysql+mysqldb://admin:Avatarme1@localhost:3306/CMT +# Domain +# Will be set to the prod domain with an env var on deployment used by Traefik +# to transmit traffic and acquire TLS certs +ENVIRONMENT=local +PROJECT_NAME="CMT" -# mysql -MYSQL_ROOT_PASSWORD=%40Avatarme1 -MYSQL_SERVER=localhost -MYSQL_PORT=3306 -MYSQL_DB=CMT -MYSQL_USER=admin -MYSQL_PASSWORD=Avatarme1 +# DB URI +DATABASE_URI=mysql+mysqldb://admin:Avatarme1@localhost:3306/CMT diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..9a66902 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,9 @@ +# CMT Backend +## Usage +### API +### Alembic +```bash +cd backend +alembic revision --autogenerate -m "Header message" +alembic upgrade head +``` diff --git a/backend/app/alembic/env.py b/backend/app/alembic/env.py index 94c6671..84e37e3 100644 --- a/backend/app/alembic/env.py +++ b/backend/app/alembic/env.py @@ -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: diff --git a/backend/app/alembic/versions/e8c4300db3cb_checking_for_updates.py b/backend/app/alembic/versions/e8c4300db3cb_checking_for_updates.py new file mode 100644 index 0000000..f13c400 --- /dev/null +++ b/backend/app/alembic/versions/e8c4300db3cb_checking_for_updates.py @@ -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 ### diff --git a/backend/app/api/api.py b/backend/app/api/api.py deleted file mode 100644 index 0fb3589..0000000 --- a/backend/app/api/api.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -API Home -""" -from fastapi import APIRouter - - -api_router = APIRouter() diff --git a/backend/app/crud/__init__.py b/backend/app/api/clients/__init__.py similarity index 100% rename from backend/app/crud/__init__.py rename to backend/app/api/clients/__init__.py diff --git a/backend/app/api/clients/endpoints.py b/backend/app/api/clients/endpoints.py new file mode 100644 index 0000000..a8ebf2a --- /dev/null +++ b/backend/app/api/clients/endpoints.py @@ -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 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..1c860bd --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/main.py b/backend/app/api/main.py new file mode 100644 index 0000000..579dcc4 --- /dev/null +++ b/backend/app/api/main.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index 4271663..0000000 --- a/backend/app/config.py +++ /dev/null @@ -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 diff --git a/backend/app/auth.py b/backend/app/core/__init__.py similarity index 100% rename from backend/app/auth.py rename to backend/app/core/__init__.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..54d4eec --- /dev/null +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..a47c47f --- /dev/null +++ b/backend/app/core/db.py @@ -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 diff --git a/backend/app/crud/client.py b/backend/app/crud/client.py deleted file mode 100644 index db0d4e5..0000000 --- a/backend/app/crud/client.py +++ /dev/null @@ -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, -) diff --git a/backend/app/crud/credit.py b/backend/app/crud/credit.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/crud/login.py b/backend/app/crud/login.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/crud/payment.py b/backend/app/crud/payment.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/crud/product.py b/backend/app/crud/product.py deleted file mode 100644 index e7c4a0d..0000000 --- a/backend/app/crud/product.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -TODO: when Credit.purchase_price is updated, update Product.purchase_price -""" diff --git a/backend/app/crud/supplier.py b/backend/app/crud/supplier.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/db.py b/backend/app/db.py deleted file mode 100644 index 9c74fa3..0000000 --- a/backend/app/db.py +++ /dev/null @@ -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: - """""" diff --git a/backend/app/main.py b/backend/app/main.py index 2e48503..7057a06 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/crud/auth.py b/backend/app/schemas/__init__.py similarity index 100% rename from backend/app/crud/auth.py rename to backend/app/schemas/__init__.py diff --git a/backend/app/models.py b/backend/app/schemas/models.py similarity index 100% rename from backend/app/models.py rename to backend/app/schemas/models.py diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..8def913 --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -0,0 +1,7 @@ +from sqlmodel import SQLModel + + +class ClientCreate(SQLModel): + tin_number: int + names: str + phone_number: str diff --git a/backend/app/test.py b/backend/app/test.py new file mode 100644 index 0000000..7dc6798 --- /dev/null +++ b/backend/app/test.py @@ -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