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

5
backend/.env.example Normal file
View 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
View File

47
backend/ai/claude.py Normal file
View 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
View 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
View 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
View 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}")

View File

@@ -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
View 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
}

View File

@@ -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():

View File

@@ -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")

View File

@@ -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

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"}

View File

@@ -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