Infrastructure: Implement master/dev/vX branching, save git path, and fix username casing
This commit is contained in:
58
backend/routers/categories.py
Normal file
58
backend/routers/categories.py
Normal 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"}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
118
backend/routers/users.py
Normal 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"}
|
||||
Reference in New Issue
Block a user