This commit is contained in:
2026-06-19 17:21:07 +01:00
parent 25c3f1fa64
commit b093e1a90c
5 changed files with 1184 additions and 42 deletions
+147 -1
View File
@@ -73,6 +73,9 @@ class UserCreate(BaseModel):
password: str
role: Optional[str] = "user" # admin, user, readonly
class UserRoleUpdate(BaseModel):
role: str
class PasswordChange(BaseModel):
current_password: str
new_password: str
@@ -297,6 +300,17 @@ class DispensingAllocationCreate(BaseModel):
quantity: float
class InventoryDisposeRequest(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
dispense_mode: str = "subunit"
dispense_source: str = "batch"
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = []
class DispensingCreate(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
@@ -876,6 +890,34 @@ def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User
db.commit()
return {"message": "User deleted successfully"}
@router.patch("/users/{user_id}/role", response_model=UserResponse)
def update_user_role(user_id: int, role_data: UserRoleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
"""Update a user's role (admin only)"""
if current_user.id == user_id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
valid_roles = ["admin", "user", "readonly"]
if role_data.role not in valid_roles:
raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
old_role = user.role
user.role = role_data.role
write_audit_log(
db,
action="user.role.update",
entity_type="user",
entity_id=user.id,
actor=current_user,
details={"username": user.username, "old_role": old_role, "new_role": user.role},
)
db.commit()
db.refresh(user)
return user
@router.post("/auth/change-password")
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Change current user's password"""
@@ -1620,6 +1662,110 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"allocations": allocation_payload,
}
@router.post("/dispose")
def dispose_inventory(payload: InventoryDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
"""Dispose selected inventory and reduce stock (admin only)"""
variant = db.query(DrugVariant).filter(DrugVariant.id == payload.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
dispose_mode = (payload.dispense_mode or "subunit").strip().lower()
if dispose_mode not in {"subunit", "pack"}:
raise HTTPException(status_code=400, detail="dispose_mode must be either 'subunit' or 'pack'")
if dispose_mode == "pack":
if payload.requested_pack_id is None or payload.requested_pack_count is None:
raise HTTPException(status_code=400, detail="Pack disposal requires requested_pack_id and requested_pack_count")
if payload.requested_pack_count <= 0:
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
if abs(payload.requested_pack_count - round(payload.requested_pack_count)) > 1e-6:
raise HTTPException(status_code=400, detail="Whole-pack disposal requires an integer pack count")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=None,
pack_id=payload.requested_pack_id,
pack_count=payload.requested_pack_count,
)
else:
if payload.quantity is None or payload.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity disposal requires quantity > 0")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=payload.quantity,
pack_id=None,
pack_count=None,
)
dispose_qty = resolved["quantity"]
if variant.quantity < dispose_qty:
raise HTTPException(
status_code=400,
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispose_qty}",
)
allocations = resolve_requested_allocations(
db,
variant_id=variant.id,
variant_quantity=variant.quantity,
requested_quantity=dispose_qty,
requested_allocations=payload.allocations,
dispense_mode=dispose_mode,
dispense_source=payload.dispense_source,
requested_pack_id=resolved["pack_id"],
)
selected_source = (payload.dispense_source or ("legacy" if not allocations else "batch")).strip().lower()
allocation_payload = []
disposed_at = datetime.utcnow()
for allocation in allocations:
batch = allocation["batch"]
qty = allocation["quantity"]
remaining_before = batch.quantity
batch.quantity -= qty
if batch.quantity <= 1e-6:
batch.quantity = 0
batch.disposed_at = disposed_at
batch.disposed_by_user_id = current_user.id
batch.disposed_quantity = remaining_before
batch.disposal_notes = payload.notes
recompute_batch_pack_state(batch)
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
variant.quantity = max(0, variant.quantity - dispose_qty)
write_audit_log(
db,
action="inventory.dispose",
entity_type="drug_variant",
entity_id=variant.id,
actor=current_user,
details={
"drug_variant_id": variant.id,
"disposed_quantity": dispose_qty,
"dispose_mode": dispose_mode,
"dispose_source": selected_source,
"requested_pack_id": resolved["pack_id"],
"requested_pack_count": resolved["pack_count"],
"allocations": allocation_payload,
"notes": payload.notes,
},
)
db.commit()
db.refresh(variant)
return {
"message": "Inventory disposed successfully",
"drug_variant_id": variant.id,
"disposed_quantity": dispose_qty,
"dispose_mode": dispose_mode,
"dispose_source": selected_source,
"allocations": allocation_payload,
}
@router.get("/dispense/history", response_model=List[DispensingResponse])
def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get dispensing records (audit log)"""
@@ -2012,7 +2158,7 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse)
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
batch = db.query(Batch).filter(Batch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="Batch not found")
+6 -6
View File
@@ -1,8 +1,8 @@
fastapi==0.104.1
uvicorn==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
fastapi==0.137.2
uvicorn==0.49.0
sqlalchemy==2.0.51
pydantic==2.13.4
python-multipart==0.0.32
python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4
paho-mqtt==1.6.1