Infrastructure: Implement master/dev/vX branching, save git path, and fix username casing
This commit is contained in:
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Google Gemini API Key
|
||||
GEMINI_API_KEY=your_gemini_key_here
|
||||
|
||||
# Anthropic Claude API Key
|
||||
CLAUDE_API_KEY=your_claude_key_here
|
||||
0
backend/ai/__init__.py
Normal file
0
backend/ai/__init__.py
Normal file
47
backend/ai/claude.py
Normal file
47
backend/ai/claude.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import anthropic
|
||||
import json
|
||||
import base64
|
||||
|
||||
def extract(image_bytes: bytes, prompt: str):
|
||||
api_key = os.environ.get("CLAUDE_API_KEY")
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
# 2026 Target models
|
||||
models_to_try = ["claude-3-5-haiku-latest", "claude-3-5-sonnet-latest"]
|
||||
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
print(f"Claude Attempt: {model_name}")
|
||||
message = client.messages.create(
|
||||
model=model_name,
|
||||
max_tokens=1024,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": base64_image,
|
||||
},
|
||||
},
|
||||
{"type": "text", "text": prompt}
|
||||
],
|
||||
}]
|
||||
)
|
||||
text = message.content[0].text.strip()
|
||||
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
|
||||
return json.loads(text)
|
||||
except Exception as e:
|
||||
print(f"Claude {model_name} failed: {e}")
|
||||
continue
|
||||
return None
|
||||
71
backend/ai/gemini.py
Normal file
71
backend/ai/gemini.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import json
|
||||
import io
|
||||
from PIL import Image
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
def get_best_models():
|
||||
# Using the exact models discovered via diagnostic
|
||||
return ["gemini-2.0-flash", "gemini-2.5-flash"]
|
||||
|
||||
def extract(image_bytes: bytes, prompt: str):
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
print("CRITICAL: GEMINI_API_KEY is MISSING in environment!")
|
||||
return None
|
||||
|
||||
# Log partial key for safety debug
|
||||
key_hint = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "too short"
|
||||
print(f"🔑 Using API Key: {key_hint}")
|
||||
|
||||
try:
|
||||
# Initialize the NEW SDK Client forcing stable v1 API
|
||||
client = genai.Client(api_key=api_key, http_options={'api_version': 'v1'})
|
||||
|
||||
# DEBUG: List allowed models for this key
|
||||
print("🔍 Checking available models for your key...")
|
||||
try:
|
||||
for m in client.models.list():
|
||||
print(f" - Found: {m.name}")
|
||||
except Exception as list_e:
|
||||
print(f" ⚠️ Could not list models: {list_e}")
|
||||
|
||||
models_to_try = get_best_models()
|
||||
|
||||
# Try models in order
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
print(f"🚀 AI Launching (v2 SDK): {model_name}...")
|
||||
|
||||
# In the new SDK, we pass a list of parts (text string and image bytes)
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=[
|
||||
prompt,
|
||||
types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
|
||||
]
|
||||
)
|
||||
|
||||
if not response or not response.text:
|
||||
continue
|
||||
|
||||
text = response.text.strip()
|
||||
print(f"✅ AI Response Received ({len(text)} bytes)")
|
||||
|
||||
# Extract JSON block
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].strip()
|
||||
|
||||
return json.loads(text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini {model_name} failed: {e}")
|
||||
continue
|
||||
|
||||
except Exception as outer_e:
|
||||
print(f"❌ Gemini Client Init failed: {outer_e}")
|
||||
|
||||
return None
|
||||
47
backend/ai_vision.py
Normal file
47
backend/ai_vision.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from .ai import gemini, claude
|
||||
|
||||
# Load environment variables from the directory where this file resides
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
dotenv_path = os.path.join(base_dir, ".env")
|
||||
load_dotenv(dotenv_path)
|
||||
|
||||
def extract_label_info(image_bytes: bytes):
|
||||
"""
|
||||
Orchestrates extraction across multiple AI providers.
|
||||
Order: Gemini (Flash/Pro) -> Claude (Haiku/Sonnet)
|
||||
"""
|
||||
prompt = """
|
||||
Extract technical inventory information from this label image.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Look at the most prominent text (usually top 1-2 rows). This is the product NAME and MODEL.
|
||||
2. Extract the PART NUMBER (P/N, Model No, Type). If no explicit Part Number is found, synthesize one from the most unique identifier in the header (e.g. 'OM4-MMF-DX').
|
||||
3. Separate the COLOR (e.g. Turquoise, Yellow, Black).
|
||||
4. Extract CATEGORY based on the item type (e.g. Patchcord, SFP, Connector).
|
||||
5. Extract technical SPECS (e.g. '2.0mm', '10G', '850nm').
|
||||
|
||||
Return ONLY a valid JSON object:
|
||||
{
|
||||
"name": "Full descriptive name from header",
|
||||
"part_number": "Unique identifier for fast scanning",
|
||||
"category": "Broad category",
|
||||
"color": "Color if present",
|
||||
"specs": "Brief tech specs list",
|
||||
"barcode": "Barcode value if visible",
|
||||
"quantity": 1
|
||||
}
|
||||
"""
|
||||
|
||||
# 1. Try Gemini
|
||||
result = gemini.extract(image_bytes, prompt)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 2. Try Claude (Fallback)
|
||||
result = claude.extract(image_bytes, prompt)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return {"error": "All AI providers failed or no API keys configured. Check your .env file."}
|
||||
20
backend/check_models.py
Normal file
20
backend/check_models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
import google.generativeai as genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
API_KEY = os.environ.get("GEMINI_API_KEY")
|
||||
|
||||
if not API_KEY:
|
||||
print("Error: GEMINI_API_KEY not found in .env")
|
||||
exit(1)
|
||||
|
||||
genai.configure(api_key=API_KEY)
|
||||
|
||||
print(f"Checking available models for your API key...")
|
||||
try:
|
||||
for m in genai.list_models():
|
||||
if 'generateContent' in m.supported_generation_methods:
|
||||
print(f"- {m.name}")
|
||||
except Exception as e:
|
||||
print(f"Error listing models: {e}")
|
||||
@@ -2,10 +2,16 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
import os
|
||||
|
||||
# Create data directory if it doesn't exist
|
||||
os.makedirs("data", exist_ok=True)
|
||||
# Get absolute path for the backend directory
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/inventory.db"
|
||||
# Create data directory if it doesn't exist in the backend folder
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# Handle absolute path for SQLite (needs 4 slashes on Unix)
|
||||
db_path = os.path.join(DATA_DIR, "inventory.db")
|
||||
SQLALCHEMY_DATABASE_URL = f"sqlite:////{db_path}"
|
||||
|
||||
# connect_args={"check_same_thread": False} is required for SQLite in FastAPI/Starlette
|
||||
engine = create_engine(
|
||||
|
||||
8
backend/ldap_config.json
Normal file
8
backend/ldap_config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ldap_enabled": false,
|
||||
"server_uri": "ldap://your-server.com:389",
|
||||
"base_dn": "dc=example,dc=com",
|
||||
"user_template": "uid={username},ou=users,dc=example,dc=com",
|
||||
"admin_group": "cn=inventory_admins,ou=groups,dc=example,dc=com",
|
||||
"use_tls": false
|
||||
}
|
||||
@@ -2,12 +2,12 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from . import models
|
||||
from .database import engine
|
||||
from .routers import items, operations
|
||||
from .routers import items, operations, users, categories
|
||||
|
||||
# Create the database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Inventory PWA API", version="0.1.0")
|
||||
app = FastAPI(title="TFM aInventory API", version="1.1.0")
|
||||
|
||||
# Setup Cross-Origin for Client interaction (PWA)
|
||||
app.add_middleware(
|
||||
@@ -20,6 +20,8 @@ app.add_middleware(
|
||||
|
||||
app.include_router(items.router)
|
||||
app.include_router(operations.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(categories.router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
||||
@@ -8,10 +8,20 @@ class User(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String, nullable=True) # Nullable for LDAP or legacy users
|
||||
role = Column(String, default="user") # 'admin' or 'user'
|
||||
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
items = relationship("Item", back_populates="category_rel")
|
||||
|
||||
class Item(Base):
|
||||
__tablename__ = "items"
|
||||
|
||||
@@ -19,11 +29,18 @@ class Item(Base):
|
||||
barcode = Column(String, unique=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
category = Column(String, index=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
type = Column(String, index=True, nullable=True)
|
||||
|
||||
category_rel = relationship("Category", back_populates="items")
|
||||
part_number = Column(String, index=True, nullable=True)
|
||||
color = Column(String, index=True, nullable=True)
|
||||
specs = Column(Text, nullable=True)
|
||||
quantity = Column(Float, default=0.0)
|
||||
min_quantity = Column(Float, default=1.0)
|
||||
image_url = Column(String, nullable=True)
|
||||
|
||||
# Store labels template extracted data simply as JSON text for now
|
||||
# Full AI metadata
|
||||
labels_data = Column(Text, nullable=True)
|
||||
|
||||
class AuditLog(Base):
|
||||
@@ -35,6 +52,8 @@ class AuditLog(Base):
|
||||
action = Column(String) # e.g., 'CHECK_IN', 'CHECK_OUT', 'CREATE_ITEM'
|
||||
target_item_id = Column(Integer, nullable=True)
|
||||
quantity_change = Column(Float, nullable=True)
|
||||
uuid = Column(String, unique=True, index=True, nullable=True)
|
||||
details = Column(Text, nullable=True) # For reasons, sync notes, etc.
|
||||
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
sqlalchemy>=2.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
google-genai>=0.1.0
|
||||
anthropic>=0.40.0
|
||||
python-dotenv>=1.0.0
|
||||
Pillow>=10.0.0
|
||||
python-multipart>=0.0.9
|
||||
ldap3>=2.9.1
|
||||
passlib[bcrypt]>=1.7.4
|
||||
|
||||
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"}
|
||||
@@ -2,11 +2,52 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# --- Users ---
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
role: str = "user"
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: Optional[str] = None
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserPasswordUpdate(BaseModel):
|
||||
old_password: Optional[str] = None
|
||||
new_password: str
|
||||
|
||||
# --- Categories ---
|
||||
class CategoryBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
class Category(CategoryBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# --- Items ---
|
||||
class ItemBase(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
category_id: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
barcode: str
|
||||
part_number: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
specs: Optional[str] = None
|
||||
quantity: float = 0.0
|
||||
min_quantity: float = 1.0
|
||||
image_url: Optional[str] = None
|
||||
@@ -37,14 +78,28 @@ class TrashOperationCreate(BaseModel):
|
||||
user_id: int
|
||||
reason: Optional[str] = "unspecified"
|
||||
|
||||
# --- Sync ---
|
||||
class SyncOperation(BaseModel):
|
||||
type: str # 'CHECK_IN', 'CHECK_OUT'
|
||||
barcode: str
|
||||
quantity: float
|
||||
uuid: Optional[str] = None
|
||||
timestamp: datetime
|
||||
|
||||
class SyncPayload(BaseModel):
|
||||
user_id: int
|
||||
operations: List[SyncOperation]
|
||||
|
||||
# --- Audit Logs ---
|
||||
class AuditLogResponse(BaseModel):
|
||||
id: int
|
||||
timestamp: datetime
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
action: str
|
||||
target_item_id: Optional[int]
|
||||
quantity_change: Optional[float]
|
||||
details: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
Reference in New Issue
Block a user