Initial commit: Ballet Production Suite ERP/CRM foundation

This commit is contained in:
2026-01-24 13:28:54 +01:00
commit 01ae4a09ff
10 changed files with 568 additions and 0 deletions

60
backend/auth.py Normal file
View File

@@ -0,0 +1,60 @@
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session, select
from .database import get_session
from .models import User
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.getenv("SECRET_KEY", "yoursecretkeyhere")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = session.exec(select(User).where(User.username == username)).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

21
backend/database.py Normal file
View File

@@ -0,0 +1,21 @@
import os
from sqlmodel import create_engine, SQLModel, Session
from dotenv import load_dotenv
load_dotenv()
# Default to SQLite for easy development if not specified
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/ballet_app.db")
# Use check_same_thread=False only for SQLite
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args, echo=True)
def create_db_and_tables():
from . import models # Ensure models are registered
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session

167
backend/main.py Normal file
View File

@@ -0,0 +1,167 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from .database import create_db_and_tables, get_session
from . import models, auth
from datetime import timedelta
app = FastAPI(title="Ballet Production Suite API")
@app.on_event("startup")
def on_startup():
create_db_and_tables()
@app.get("/")
def read_root():
return {"message": "Welcome to the Ballet Production Suite API"}
# --- Authentication ---
@app.post("/token")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)):
user = session.exec(select(models.User).where(models.User.username == form_data.username)).first()
if not user or not auth.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=auth.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.post("/users/", response_model=models.User)
def create_user(user: models.User, session: Session = Depends(get_session)):
user.hashed_password = auth.get_password_hash(user.hashed_password)
session.add(user)
session.commit()
session.refresh(user)
return user
# --- Materials ---
@app.post("/materials/", response_model=models.Material)
def create_material(material: models.Material, session: Session = Depends(get_session)):
session.add(material)
session.commit()
session.refresh(material)
return material
@app.get("/materials/", response_model=list[models.Material])
def read_materials(session: Session = Depends(get_session)):
materials = session.exec(select(models.Material)).all()
return materials
# --- Products ---
@app.post("/products/", response_model=models.Product)
def create_product(product: models.Product, session: Session = Depends(get_session)):
session.add(product)
session.commit()
session.refresh(product)
return product
@app.get("/products/", response_model=list[dict])
def read_products(session: Session = Depends(get_session)):
products = session.exec(select(models.Product)).all()
result = []
for p in products:
# Calculate cost based on materials
total_cost = p.base_price
for link in p.materials:
total_cost += link.quantity_required * link.material.cost_per_unit
p_dict = p.dict()
p_dict["total_cost"] = total_cost
result.append(p_dict)
return result
@app.post("/products/{product_id}/materials/")
def add_material_to_product(product_id: int, material_id: int, quantity: float, session: Session = Depends(get_session)):
link = models.ProductMaterialLink(product_id=product_id, material_id=material_id, quantity_required=quantity)
session.add(link)
session.commit()
return {"message": "Material added to product"}
# --- Clients ---
@app.post("/clients/", response_model=models.Client)
def create_client(client: models.Client, session: Session = Depends(get_session)):
session.add(client)
session.commit()
session.refresh(client)
return client
@app.get("/clients/", response_model=list[models.Client])
def read_clients(session: Session = Depends(get_session)):
clients = session.exec(select(models.Client)).all()
return clients
# --- Orders ---
@app.post("/orders/", response_model=models.Order)
def create_order(order_data: dict, session: Session = Depends(get_session)):
# This is a simplified version for demonstration.
# In a real app, you'd use a Pydantic schema for the request body.
client_id = order_data.get("client_id")
product_ids = order_data.get("product_ids", []) # List of strings or dicts
db_order = models.Order(client_id=client_id, status=models.OrderStatus.QUOTATION)
session.add(db_order)
session.commit()
session.refresh(db_order)
for p_id in product_ids:
link = models.OrderProductLink(order_id=db_order.id, product_id=p_id, quantity=1)
session.add(link)
session.commit()
session.refresh(db_order)
return db_order
@app.get("/orders/", response_model=list[models.Order])
def read_orders(session: Session = Depends(get_session)):
orders = session.exec(select(models.Order)).all()
return orders
@app.get("/orders/{order_id}", response_model=models.Order)
def read_order(order_id: int, session: Session = Depends(get_session)):
order = session.get(models.Order, order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
# --- Production ---
@app.get("/production/", response_model=list[models.ProductionStep])
def read_production_steps(session: Session = Depends(get_session)):
steps = session.exec(select(models.ProductionStep)).all()
return steps
@app.patch("/production/{step_id}", response_model=models.ProductionStep)
def update_production_step(step_id: int, status: str, session: Session = Depends(get_session)):
step = session.get(models.ProductionStep, step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
step.status = status
if status == "In Progress":
step.started_at = datetime.utcnow()
elif status == "Completed":
step.completed_at = datetime.utcnow()
session.add(step)
session.commit()
session.refresh(step)
return step
@app.get("/production/cutting-sheet")
def get_cutting_sheet(session: Session = Depends(get_session)):
# Group material requirements for all orders in 'Corte' status
orders_in_corte = session.exec(select(models.Order).where(models.Order.status == "Corte")).all()
cutting_sheet = {} # material_name -> total_quantity
for order in orders_in_corte:
for product_link in order.products:
product = product_link.product
for mat_link in product.materials:
mat_name = mat_link.material.name
qty = mat_link.quantity_required * product_link.quantity
cutting_sheet[mat_name] = cutting_sheet.get(mat_name, 0) + qty
return cutting_sheet

93
backend/models.py Normal file
View File

@@ -0,0 +1,93 @@
from datetime import datetime
from typing import List, Optional
from sqlmodel import Field, SQLModel, Relationship
# --- User & Auth ---
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
is_active: bool = Field(default=True)
is_admin: bool = Field(default=False)
# --- Inventory & Materials ---
class Material(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
description: Optional[str] = None
unit: str # e.g., "meters", "units", "grams"
stock_quantity: float = Field(default=0.0)
reorder_level: float = Field(default=0.0)
cost_per_unit: float = Field(default=0.0)
# --- Products ---
class Product(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
sku: str = Field(index=True, unique=True)
description: Optional[str] = None
base_price: float = Field(default=0.0)
estimated_time_hours: float = Field(default=0.0)
# Relationships
orders: List["OrderProductLink"] = Relationship(back_populates="product")
materials: List["ProductMaterialLink"] = Relationship(back_populates="product")
class ProductMaterialLink(SQLModel, table=True):
product_id: Optional[int] = Field(default=None, foreign_key="product.id", primary_key=True)
material_id: Optional[int] = Field(default=None, foreign_key="material.id", primary_key=True)
quantity_required: float
product: Product = Relationship(back_populates="materials")
material: Material = Relationship()
# --- CRM ---
class Client(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
full_name: str = Field(index=True)
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
# Measurements (as a JSON or separate fields, keeping it simple for now)
measurements: Optional[str] = None # Or use a JSON type if supported better
orders: List["Order"] = Relationship(back_populates="client")
# --- Orders & Production ---
class OrderStatus(str):
QUOTATION = "Quotation"
PENDING = "Pending"
IN_PRODUCTION = "In Production"
SHIPPED = "Shipped"
DELIVERED = "Delivered"
CANCELLED = "Cancelled"
class Order(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
client_id: int = Field(foreign_key="client.id")
order_date: datetime = Field(default_factory=datetime.utcnow)
status: str = Field(default=OrderStatus.QUOTATION)
total_amount: float = Field(default=0.0)
notes: Optional[str] = None
client: Client = Relationship(back_populates="orders")
products: List["OrderProductLink"] = Relationship(back_populates="order")
class OrderProductLink(SQLModel, table=True):
order_id: Optional[int] = Field(default=None, foreign_key="order.id", primary_key=True)
product_id: Optional[int] = Field(default=None, foreign_key="product.id", primary_key=True)
quantity: int = Field(default=1)
customizations: Optional[str] = None
order: Order = Relationship(back_populates="products")
product: Product = Relationship(back_populates="orders")
class ProductionStep(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="order.id")
step_name: str # e.g., "Corte", "Confección", "Terminado"
status: str = Field(default="Pending") # Pending, In Progress, Completed
assigned_to: Optional[int] = Field(default=None, foreign_key="user.id")
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None