Compare commits

..

2 Commits

Author SHA1 Message Date
d277d5c07b Notifications and printing 2026-02-07 07:01:31 -05:00
3d1c007609 Label print API 2026-02-07 06:41:39 -05:00
7 changed files with 439 additions and 28 deletions

View File

@@ -1,11 +1,12 @@
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List, Optional
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
from .database import engine, get_db, Base
from .models import Drug, DrugVariant, Dispensing, User
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, ACCESS_TOKEN_EXPIRE_MINUTES
from .mqtt_service import publish_label_print
from pydantic import BaseModel
# Create tables
@@ -117,6 +118,21 @@ class DispensingResponse(BaseModel):
class Config:
from_attributes = True
class LabelVariables(BaseModel):
practice_name: str
animal_name: str
drug_name: str
dosage: str
quantity: str
expiry_date: str
class LabelPrintRequest(BaseModel):
variables: LabelVariables
class LabelPrintResponse(BaseModel):
success: bool
message: str
# Authentication Routes
@router.post("/auth/register", response_model=TokenResponse)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
@@ -475,5 +491,49 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
return db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).order_by(Dispensing.dispensed_at.desc()).all()
# Label printing endpoint
@router.post("/labels/print", response_model=LabelPrintResponse)
def print_label(label_request: LabelPrintRequest, current_user: User = Depends(get_current_user)):
"""
Print a drug label by publishing an MQTT message
This endpoint publishes a label print request to the MQTT broker,
which will be picked up by the label printing service.
"""
try:
# Get label configuration from environment
import os
template_id = os.getenv("LABEL_TEMPLATE_ID", "vet_label")
label_size = os.getenv("LABEL_SIZE", "29x90")
test_mode = os.getenv("LABEL_TEST", "false").lower() == "true"
# Convert the request to the MQTT message format
mqtt_message = {
"template_id": template_id,
"label_size": label_size,
"variables": label_request.variables.dict(),
"test": test_mode
}
# Publish to MQTT
success = publish_label_print(mqtt_message)
if success:
return LabelPrintResponse(
success=True,
message="Label print request sent successfully"
)
else:
raise HTTPException(
status_code=500,
detail="Failed to send label print request to MQTT broker"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error sending label print request: {str(e)}"
)
# Include router with /api prefix
app.include_router(router)

View File

@@ -0,0 +1,57 @@
import os
import json
import logging
from typing import Optional
import paho.mqtt.client as mqtt
logger = logging.getLogger(__name__)
# MQTT Configuration from environment
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME", "")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "")
MQTT_LABEL_TOPIC = os.getenv("MQTT_LABEL_TOPIC", "vet/labels/print")
def publish_label_print(label_data: dict) -> bool:
"""
Publish a label print request to MQTT broker
Args:
label_data: Dictionary containing label print information
Returns:
True if successful, False otherwise
"""
try:
# Create MQTT client
client = mqtt.Client()
# Set username and password if provided
if MQTT_USERNAME and MQTT_PASSWORD:
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# Connect to broker
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
# Start network loop to process connection
client.loop_start()
# Publish message with QoS 0 (fire and forget)
message = json.dumps(label_data)
result = client.publish(MQTT_LABEL_TOPIC, message, qos=0)
# Stop loop and disconnect
client.loop_stop()
client.disconnect()
if result.rc == mqtt.MQTT_ERR_SUCCESS:
logger.info(f"Successfully published label print request to {MQTT_LABEL_TOPIC}")
return True
else:
logger.error(f"Failed to publish label print request: {result.rc}")
return False
except Exception as e:
logger.error(f"Error publishing MQTT message: {str(e)}")
return False

View File

@@ -5,3 +5,4 @@ pydantic==2.5.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[argon2]==1.7.4
paho-mqtt==1.6.1

View File

@@ -14,6 +14,14 @@ services:
- DATABASE_URL=sqlite:///./data/drugs.db
- PUID=1001
- PGID=1001
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-localhost}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_LABEL_TOPIC=${MQTT_LABEL_TOPIC:-vet/labels/print}
- LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label}
- LABEL_SIZE=${LABEL_SIZE:-29x90}
- LABEL_TEST=${LABEL_TEST:-false}
frontend:
image: nginx:alpine

View File

@@ -7,6 +7,37 @@ let expandedDrugs = new Set();
let currentUser = null;
let accessToken = null;
// Toast notification system
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: '✓',
error: '✕',
warning: '⚠',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
// Auto remove after duration
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => {
container.removeChild(toast);
}, 300);
}, duration);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
checkAuth();
@@ -139,11 +170,13 @@ function setupEventListeners() {
const variantForm = document.getElementById('variantForm');
const editVariantForm = document.getElementById('editVariantForm');
const dispenseForm = document.getElementById('dispenseForm');
const prescribeForm = document.getElementById('prescribeForm');
const editForm = document.getElementById('editForm');
const addModal = document.getElementById('addModal');
const addVariantModal = document.getElementById('addVariantModal');
const editVariantModal = document.getElementById('editVariantModal');
const dispenseModal = document.getElementById('dispenseModal');
const prescribeModal = document.getElementById('prescribeModal');
const editModal = document.getElementById('editModal');
const addDrugBtn = document.getElementById('addDrugBtn');
const dispenseBtn = document.getElementById('dispenseBtn');
@@ -151,6 +184,7 @@ function setupEventListeners() {
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn');
const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn');
@@ -166,6 +200,7 @@ function setupEventListeners() {
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
if (prescribeForm) prescribeForm.addEventListener('submit', handlePrescribeDrug);
if (editForm) editForm.addEventListener('submit', handleEditDrug);
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
@@ -178,6 +213,7 @@ function setupEventListeners() {
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
if (cancelPrescribeBtn) cancelPrescribeBtn.addEventListener('click', () => closeModal(prescribeModal));
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
@@ -324,6 +360,11 @@ function renderDrugs() {
);
}
// Sort alphabetically by drug name
drugsToShow = drugsToShow.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
if (drugsToShow.length === 0) {
drugsList.innerHTML = '<p class="empty">No drugs found matching your criteria</p>';
return;
@@ -353,6 +394,7 @@ function renderDrugs() {
</div>
</div>
<div class="variant-actions">
<button class="btn btn-primary btn-small" onclick="prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}')">🏷️ Prescribe</button>
<button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
<button class="btn btn-danger btn-small" onclick="deleteVariant(${variant.id})">Delete</button>
@@ -430,10 +472,10 @@ async function handleAddDrug(e) {
document.getElementById('initialVariantThreshold').value = '10';
closeModal(document.getElementById('addModal'));
await loadDrugs();
alert('Drug added successfully!');
showToast('Drug added successfully!', 'success');
} catch (error) {
console.error('Error adding drug:', error);
alert('Failed to add drug. Check the console for details.');
showToast('Failed to add drug. Check the console for details.', 'error');
}
}
@@ -448,7 +490,7 @@ async function handleDispenseDrug(e) {
const notes = document.getElementById('dispenseNotes').value;
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
alert('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)');
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
return;
}
@@ -474,10 +516,10 @@ async function handleDispenseDrug(e) {
document.getElementById('dispenseForm').reset();
closeModal(document.getElementById('dispenseModal'));
await loadDrugs();
alert('Drug dispensed successfully!');
showToast('Drug dispensed successfully!', 'success');
} catch (error) {
console.error('Error dispensing drug:', error);
alert('Failed to dispense drug: ' + error.message);
showToast('Failed to dispense drug: ' + error.message, 'error');
}
}
@@ -543,10 +585,10 @@ async function handleAddVariant(e) {
closeModal(document.getElementById('addVariantModal'));
await loadDrugs();
renderDrugs();
alert('Variant added successfully!');
showToast('Variant added successfully!', 'success');
} catch (error) {
console.error('Error adding variant:', error);
alert('Failed to add variant. Check the console for details.');
showToast('Failed to add variant. Check the console for details.', 'error');
}
}
@@ -593,10 +635,10 @@ async function handleEditVariant(e) {
closeModal(document.getElementById('editVariantModal'));
await loadDrugs();
renderDrugs();
alert('Variant updated successfully!');
showToast('Variant updated successfully!', 'success');
} catch (error) {
console.error('Error updating variant:', error);
alert('Failed to update variant. Check the console for details.');
showToast('Failed to update variant. Check the console for details.', 'error');
}
}
@@ -613,6 +655,105 @@ function dispenseVariant(variantId) {
openModal(document.getElementById('dispenseModal'));
}
// Prescribe variant and print label
function prescribeVariant(variantId, drugName, variantStrength, unit) {
// Set hidden fields
document.getElementById('prescribeVariantId').value = variantId;
document.getElementById('prescribeDrugName').value = drugName;
document.getElementById('prescribeVariantStrength').value = variantStrength;
document.getElementById('prescribeUnit').value = unit || 'units';
// Pre-fill user name if available
if (currentUser) {
document.getElementById('prescribeUser').value = currentUser.username;
}
// Set default expiry date to 1 month from now
const defaultExpiry = new Date();
defaultExpiry.setMonth(defaultExpiry.getMonth() + 1);
document.getElementById('prescribeExpiry').value = defaultExpiry.toISOString().split('T')[0];
// Open prescribe modal
openModal(document.getElementById('prescribeModal'));
}
// Handle prescribe drug form submission
async function handlePrescribeDrug(e) {
e.preventDefault();
const variantId = parseInt(document.getElementById('prescribeVariantId').value);
const drugName = document.getElementById('prescribeDrugName').value;
const variantStrength = document.getElementById('prescribeVariantStrength').value;
const unit = document.getElementById('prescribeUnit').value;
const quantity = parseFloat(document.getElementById('prescribeQuantity').value);
const animalName = document.getElementById('prescribeAnimal').value;
const dosage = document.getElementById('prescribeDosage').value;
const expiryDate = document.getElementById('prescribeExpiry').value;
const userName = document.getElementById('prescribeUser').value;
const notes = document.getElementById('prescribeNotes').value;
if (!variantId || isNaN(quantity) || quantity <= 0 || !animalName || !dosage || !expiryDate || !userName) {
showToast('Please fill in all required fields', 'warning');
return;
}
// Convert expiry date to DD/MM/YYYY format
const expiryParts = expiryDate.split('-');
const formattedExpiry = `${expiryParts[2]}/${expiryParts[1]}/${expiryParts[0]}`;
try {
// First, dispense the drug (decrement inventory)
const dispensingData = {
drug_variant_id: variantId,
quantity: quantity,
animal_name: animalName,
user_name: userName,
notes: notes || null
};
const dispenseResponse = await apiCall('/dispense', {
method: 'POST',
body: JSON.stringify(dispensingData)
});
if (!dispenseResponse.ok) {
const error = await dispenseResponse.json();
throw new Error(error.detail || 'Failed to dispense drug');
}
// Second, print the label
const labelData = {
variables: {
practice_name: "Many Tears Animal Rescue",
animal_name: animalName,
drug_name: `${drugName} ${variantStrength}`,
dosage: dosage,
quantity: `${quantity} ${unit}`,
expiry_date: formattedExpiry
}
};
const labelResponse = await apiCall('/labels/print', {
method: 'POST',
body: JSON.stringify(labelData)
});
if (!labelResponse.ok) {
console.error('Label printing failed, but drug was dispensed');
showToast('Drug prescribed successfully, but label printing failed', 'warning', 5000);
} else {
showToast('Drug prescribed and label sent to printer!', 'success');
}
document.getElementById('prescribeForm').reset();
closeModal(document.getElementById('prescribeModal'));
await loadDrugs();
} catch (error) {
console.error('Error prescribing drug:', error);
showToast('Failed to prescribe drug: ' + error.message, 'error');
}
}
// Delete variant
async function deleteVariant(variantId) {
if (!confirm('Are you sure you want to delete this variant?')) return;
@@ -626,10 +767,10 @@ async function deleteVariant(variantId) {
await loadDrugs();
renderDrugs();
alert('Variant deleted successfully!');
showToast('Variant deleted successfully!', 'success');
} catch (error) {
console.error('Error deleting variant:', error);
alert('Failed to delete variant. Check the console for details.');
showToast('Failed to delete variant. Check the console for details.', 'error');
}
}
@@ -725,10 +866,10 @@ async function handleEditDrug(e) {
closeEditModal();
await loadDrugs();
alert('Drug updated successfully!');
showToast('Drug updated successfully!', 'success');
} catch (error) {
console.error('Error updating drug:', error);
alert('Failed to update drug. Check the console for details.');
showToast('Failed to update drug. Check the console for details.', 'error');
}
}
@@ -744,10 +885,10 @@ async function deleteDrug(drugId) {
if (!response.ok) throw new Error('Failed to delete drug');
await loadDrugs();
alert('Drug deleted successfully!');
showToast('Drug deleted successfully!', 'success');
} catch (error) {
console.error('Error deleting drug:', error);
alert('Failed to delete drug. Check the console for details.');
showToast('Failed to delete drug. Check the console for details.', 'error');
}
}
@@ -771,12 +912,12 @@ async function handleChangePassword(e) {
const confirmPassword = document.getElementById('confirmNewPassword').value;
if (newPassword !== confirmPassword) {
alert('New passwords do not match!');
showToast('New passwords do not match!', 'warning');
return;
}
if (newPassword.length < 1) {
alert('New password cannot be empty!');
showToast('New password cannot be empty!', 'warning');
return;
}
@@ -794,11 +935,11 @@ async function handleChangePassword(e) {
throw new Error(error.detail || 'Failed to change password');
}
alert('Password changed successfully!');
showToast('Password changed successfully!', 'success');
closeModal(document.getElementById('changePasswordModal'));
} catch (error) {
console.error('Error changing password:', error);
alert('Failed to change password: ' + error.message);
showToast('Failed to change password: ' + error.message, 'error');
}
}
@@ -818,12 +959,12 @@ async function handleAdminChangePassword(e) {
const confirmPassword = document.getElementById('adminChangePasswordConfirm').value;
if (newPassword !== confirmPassword) {
alert('Passwords do not match!');
showToast('Passwords do not match!', 'warning');
return;
}
if (newPassword.length < 1) {
alert('Password cannot be empty!');
showToast('Password cannot be empty!', 'warning');
return;
}
@@ -840,12 +981,12 @@ async function handleAdminChangePassword(e) {
throw new Error(error.detail || 'Failed to change password');
}
alert('Password changed successfully!');
showToast('Password changed successfully!', 'success');
closeModal(document.getElementById('adminChangePasswordModal'));
openUserManagement();
} catch (error) {
console.error('Error changing password:', error);
alert('Failed to change password: ' + error.message);
showToast('Failed to change password: ' + error.message, 'error');
}
}
@@ -927,11 +1068,11 @@ async function createUser(e) {
document.getElementById('newUsername').value = '';
document.getElementById('newUserPassword').value = '';
alert('User created successfully!');
showToast('User created successfully!', 'success');
openUserManagement();
} catch (error) {
console.error('Error creating user:', error);
alert('Failed to create user: ' + error.message);
showToast('Failed to create user: ' + error.message, 'error');
}
}
@@ -944,10 +1085,10 @@ async function deleteUser(userId) {
if (!response.ok) throw new Error('Failed to delete user');
alert('User deleted successfully!');
showToast('User deleted successfully!', 'success');
openUserManagement();
} catch (error) {
console.error('Error deleting user:', error);
alert('Failed to delete user: ' + error.message);
showToast('Failed to delete user: ' + error.message, 'error');
}
}

View File

@@ -7,6 +7,9 @@
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Toast Notification Container -->
<div id="toastContainer" class="toast-container"></div>
<!-- Login Page -->
<div id="loginPage" class="login-page">
<div class="login-container">
@@ -196,6 +199,55 @@
</div>
</div>
<!-- Prescribe Drug Modal -->
<div id="prescribeModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Prescribe Drug & Print Label</h2>
<form id="prescribeForm" novalidate>
<input type="hidden" id="prescribeVariantId">
<input type="hidden" id="prescribeDrugName">
<input type="hidden" id="prescribeVariantStrength">
<input type="hidden" id="prescribeUnit">
<div class="form-group">
<label for="prescribeQuantity">Quantity *</label>
<input type="number" id="prescribeQuantity" step="0.1" required>
</div>
<div class="form-group">
<label for="prescribeAnimal">Animal Name/ID *</label>
<input type="text" id="prescribeAnimal" required>
</div>
<div class="form-group">
<label for="prescribeDosage">Dosage Instructions *</label>
<input type="text" id="prescribeDosage" placeholder="e.g., 1 tablet twice daily with food" required>
</div>
<div class="form-group">
<label for="prescribeExpiry">Expiry Date *</label>
<input type="date" id="prescribeExpiry" required>
</div>
<div class="form-group">
<label for="prescribeUser">Prescribed by *</label>
<input type="text" id="prescribeUser" required>
</div>
<div class="form-group">
<label for="prescribeNotes">Notes</label>
<input type="text" id="prescribeNotes" placeholder="Optional">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Prescribe & Print Label</button>
<button type="button" class="btn btn-secondary" id="cancelPrescribeBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Add Variant Modal -->
<div id="addVariantModal" class="modal">
<div class="modal-content">

View File

@@ -947,3 +947,95 @@ footer {
flex-wrap: wrap;
}
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: var(--white);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 12px;
min-width: 300px;
max-width: 400px;
pointer-events: auto;
animation: slideIn 0.3s ease-out;
border-left: 4px solid var(--primary-color);
}
.toast.success {
border-left-color: var(--success-color);
}
.toast.error {
border-left-color: var(--danger-color);
}
.toast.warning {
border-left-color: var(--warning-color);
}
.toast.info {
border-left-color: var(--secondary-color);
}
.toast-icon {
font-size: 1.5em;
flex-shrink: 0;
}
.toast-message {
flex: 1;
color: var(--text-dark);
font-size: 0.95em;
}
.toast.fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
@media (max-width: 768px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
}
.toast {
min-width: auto;
width: 100%;
}
}