Infrastructure: Implement master/dev/vX branching, save git path, and fix username casing

This commit is contained in:
Daniel Bedeleanu
2026-04-10 21:51:22 +03:00
parent 93edcc261b
commit 8a3783c7e9
3397 changed files with 57402 additions and 98 deletions

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from .. import models, schemas, database
router = APIRouter(prefix="/categories", tags=["categories"])
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/", response_model=List[schemas.Category])
def get_categories(db: Session = Depends(get_db)):
categories = db.query(models.Category).all()
# Auto-seed if empty with defaults mentioned by user
if not categories:
defaults = [
{"name": "Connectors", "description": "Conectica: cables, adapters, plugs"},
{"name": "Spare Parts", "description": "Piese de schimb: specific components"},
{"name": "Tools", "description": "Hand and power tools"},
{"name": "Consumables", "description": "One-time use items"}
]
for d in defaults:
cat = models.Category(**d)
db.add(cat)
db.commit()
return db.query(models.Category).all()
return categories
@router.post("/", response_model=schemas.Category)
def create_category(category: schemas.CategoryCreate, db: Session = Depends(get_db)):
existing = db.query(models.Category).filter(models.Category.name == category.name).first()
if existing:
raise HTTPException(status_code=400, detail="Category already exists")
new_cat = models.Category(**category.model_dump())
db.add(new_cat)
db.commit()
db.refresh(new_cat)
return new_cat
@router.delete("/{cat_id}")
def delete_category(cat_id: int, db: Session = Depends(get_db)):
cat = db.query(models.Category).filter(models.Category.id == cat_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
# Check if items are linked
linkedItems = db.query(models.Item).filter(models.Item.category_id == cat_id).count()
if linkedItems > 0:
raise HTTPException(status_code=400, detail="Cannot delete category with linked items")
db.delete(cat)
db.commit()
return {"message": "Category deleted"}

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
from .. import models, schemas
@@ -21,6 +21,18 @@ def read_item(item_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Item not found")
return item
@router.post("/extract-label")
async def extract_label(file: UploadFile = File(...)):
from ..ai_vision import extract_label_info
# Read image content
contents = await file.read()
# Process with Gemini
result = extract_label_info(contents)
return result
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)
def create_item(item: schemas.ItemCreate, user_id: int, db: Session = Depends(get_db)):
# Check if barcode exists
@@ -44,3 +56,27 @@ def create_item(item: schemas.ItemCreate, user_id: int, db: Session = Depends(ge
db.commit()
return db_item
@router.put("/{item_id}", response_model=schemas.Item)
def update_item(item_id: int, item: schemas.ItemCreate, db: Session = Depends(get_db)):
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
update_data = item.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@router.delete("/{item_id}")
def delete_item(item_id: int, db: Session = Depends(get_db)):
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
db.delete(db_item)
db.commit()
return {"message": "Item deleted successfully"}

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
@@ -76,12 +77,13 @@ def trash_item(op: schemas.TrashOperationCreate, db: Session = Depends(get_db)):
# Update quantity
item.quantity -= op.quantity
# Create Mandatory Audit Log with TRASH action and reason
# Create Mandatory Audit Log with TRASH action and reason in details
audit = models.AuditLog(
user_id=op.user_id,
action=f"TRASH: {op.reason}",
action="TRASH",
target_item_id=item.id,
quantity_change=-op.quantity
quantity_change=-op.quantity,
details=op.reason
)
db.add(audit)
@@ -122,3 +124,81 @@ def bulk_check_out(bulk_op: schemas.BulkOperationCreate, db: Session = Depends(g
db.commit()
return results
@router.post("/bulk-sync")
def bulk_sync(payload: schemas.SyncPayload, db: Session = Depends(get_db)):
results = {"success": [], "errors": []}
for op in payload.operations:
try:
# DEDUPLICATION CHECK: If this UUID already exists, skip it
if op.uuid:
existing = db.query(models.AuditLog).filter(models.AuditLog.uuid == op.uuid).first()
if existing:
results["success"].append({"barcode": op.barcode, "type": op.type, "note": "Already synced"})
continue
item = db.query(models.Item).filter(models.Item.barcode == op.barcode).first()
if not item:
results["errors"].append({"barcode": op.barcode, "error": "Item not found"})
continue
if op.type == "CHECK_IN":
item.quantity += op.quantity
change = op.quantity
elif op.type == "CHECK_OUT" or op.type == "TRASH":
if item.quantity < op.quantity:
results["errors"].append({"barcode": op.barcode, "error": f"Insufficient stock (Available: {item.quantity})"})
continue
item.quantity -= op.quantity
change = -op.quantity
else:
results["errors"].append({"barcode": op.barcode, "error": f"Invalid operation type: {op.type}"})
continue
# Log audit with original offline timestamp and UUID
audit = models.AuditLog(
user_id=payload.user_id,
action=op.type,
target_item_id=item.id,
quantity_change=change,
timestamp=op.timestamp,
uuid=op.uuid,
details="Offline Synchronization"
)
db.add(audit)
results["success"].append({"barcode": op.barcode, "type": op.type})
except Exception as e:
results["errors"].append({"barcode": op.barcode, "error": str(e)})
db.commit()
return results
@router.get("/logs", response_model=List[schemas.AuditLogResponse])
def get_logs(limit: int = 50, db: Session = Depends(get_db)):
# Join with User to get the username directly
logs_with_users = db.query(
models.AuditLog.id,
models.AuditLog.timestamp,
models.AuditLog.user_id,
models.User.username,
models.AuditLog.action,
models.AuditLog.target_item_id,
models.AuditLog.quantity_change,
models.AuditLog.details
).join(models.User, models.AuditLog.user_id == models.User.id).order_by(models.AuditLog.timestamp.desc()).limit(limit).all()
# Mapper to dictionary for Pydantic
return [
{
"id": l.id,
"timestamp": l.timestamp,
"user_id": l.user_id,
"username": l.username,
"action": l.action,
"target_item_id": l.target_item_id,
"quantity_change": l.quantity_change,
"details": l.details
} for l in logs_with_users
]

118
backend/routers/users.py Normal file
View File

@@ -0,0 +1,118 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from passlib.context import CryptContext
import ldap3
import json
import os
from .. import models, schemas, database
router = APIRouter(prefix="/users", tags=["users"])
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def get_ldap_config():
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ldap_config.json")
if os.path.exists(config_path):
with open(config_path, "r") as f:
return json.load(f)
return {"ldap_enabled": False}
def authenticate_ldap(username, password):
config = get_ldap_config()
if not config.get("ldap_enabled"):
return False
try:
server = ldap3.Server(config["server_uri"], use_ssl=config.get("use_tls", False), get_info=ldap3.ALL)
user_dn = config["user_template"].format(username=username)
conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
# If bound, user is authenticated
return True
except Exception as e:
print(f"LDAP Auth Error: {e}")
return False
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
def get_password_hash(password):
return pwd_context.hash(password)
def verify_password(plain_password, hashed_password):
if not hashed_password: return False
return pwd_context.verify(plain_password, hashed_password)
@router.get("/", response_model=List[schemas.User])
def get_users(db: Session = Depends(get_db)):
users = db.query(models.User).all()
# Auto-seed if empty
if not users:
new_user = models.User(
username="Admin",
role="admin",
hashed_password=get_password_hash("admin") # Default password
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return [new_user]
return users
@router.post("/", response_model=schemas.User)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
existing = db.query(models.User).filter(models.User.username == user.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
hashed = get_password_hash(user.password) if user.password else None
new_user = models.User(username=user.username, role=user.role, hashed_password=hashed)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/login")
def login(form_data: schemas.UserLogin, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.username == form_data.username).first()
# Try local authentication
authenticated = False
if user and user.hashed_password:
if verify_password(form_data.password, user.hashed_password):
authenticated = True
elif user and not user.hashed_password:
# Legacy user without password - allow skip for now or force set
authenticated = True
# If local failed, try LDAP
if not authenticated:
if authenticate_ldap(form_data.username, form_data.password):
authenticated = True
# If user doesn't exist locally, create a stub for role management
if not user:
user = models.User(username=form_data.username, role="user")
db.add(user)
db.commit()
db.refresh(user)
else:
raise HTTPException(status_code=400, detail="Invalid username or password")
return user
@router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.username == "Admin":
raise HTTPException(status_code=400, detail="Cannot delete default Admin")
db.delete(user)
db.commit()
return {"message": "User deleted"}