1969 lines
70 KiB
Python
1969 lines
70 KiB
Python
import uuid
|
|
import datetime
|
|
import datetime
|
|
import re
|
|
import os
|
|
import json
|
|
import serial
|
|
import time
|
|
import threading
|
|
from typing import Optional, Dict, Any
|
|
import smtplib
|
|
from email.mime.text import MIMEText
|
|
from app import db
|
|
from app.models.models import Machine, User, Product, VendingSlot, Transaction, Role, Branch, Brand, Category, SubCategory
|
|
from flask import current_app
|
|
import secrets
|
|
from dotenv import load_dotenv
|
|
|
|
# Load environment variables from .env file
|
|
load_dotenv()
|
|
|
|
class SerialCommunicationService:
|
|
def __init__(self):
|
|
self.serial_port: Optional[serial.Serial] = None
|
|
self.is_connected = False
|
|
self.response_buffer = ""
|
|
self.last_response = None
|
|
self.response_lock = threading.Lock()
|
|
|
|
def connect_to_machine(self, port: str = '/dev/ttyUSB0', baudrate: int = 9600) -> bool:
|
|
"""
|
|
Connect to vending machine via serial port
|
|
Common ports:
|
|
- Linux: /dev/ttyUSB0, /dev/ttyACM0
|
|
- Windows: COM3, COM4, etc.
|
|
- macOS: /dev/cu.usbserial-*
|
|
"""
|
|
try:
|
|
self.serial_port = serial.Serial(
|
|
port=port,
|
|
baudrate=baudrate,
|
|
bytesize=serial.EIGHTBITS,
|
|
parity=serial.PARITY_NONE,
|
|
stopbits=serial.STOPBITS_ONE,
|
|
timeout=1 # 1 second timeout for reads
|
|
)
|
|
|
|
# Test connection with a status command
|
|
time.sleep(2) # Allow time for Arduino to reset
|
|
self.serial_port.write(b'STATUS\n')
|
|
time.sleep(1)
|
|
|
|
if self.serial_port.in_waiting > 0:
|
|
response = self.serial_port.readline().decode('utf-8').strip()
|
|
print(f"Machine connected successfully. Status: {response}")
|
|
self.is_connected = True
|
|
return True
|
|
else:
|
|
print("No response from machine")
|
|
self.serial_port.close()
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"Failed to connect to machine: {e}")
|
|
self.is_connected = False
|
|
return False
|
|
|
|
def send_dispense_command(self, slot_id: str, timeout: int = 10) -> Dict[str, Any]:
|
|
"""
|
|
Send dispense command and wait for drop sensor response
|
|
"""
|
|
if not self.is_connected or not self.serial_port:
|
|
return {
|
|
'success': False,
|
|
'error': 'Not connected to machine',
|
|
'response': 'NO_CONNECTION'
|
|
}
|
|
|
|
try:
|
|
# Clear any pending data
|
|
self.serial_port.reset_input_buffer()
|
|
|
|
# Send dispense command
|
|
command = f"DISPENSE:{slot_id}\n"
|
|
self.serial_port.write(command.encode('utf-8'))
|
|
print(f"Sent command: {command.strip()}")
|
|
|
|
# Wait for response with timeout
|
|
start_time = time.time()
|
|
response = ""
|
|
|
|
while time.time() - start_time < timeout:
|
|
if self.serial_port.in_waiting > 0:
|
|
line = self.serial_port.readline().decode('utf-8').strip()
|
|
print(f"Received: {line}")
|
|
|
|
if line in ['PRODUCT_DISPENSED', 'DISPENSE_FAILED', 'SLOT_EMPTY', 'MOTOR_ERROR']:
|
|
response = line
|
|
break
|
|
|
|
time.sleep(0.1) # Small delay to prevent busy waiting
|
|
|
|
if not response:
|
|
return {
|
|
'success': False,
|
|
'error': 'Timeout waiting for drop sensor',
|
|
'response': 'TIMEOUT'
|
|
}
|
|
|
|
success = response == 'PRODUCT_DISPENSED'
|
|
return {
|
|
'success': success,
|
|
'response': response,
|
|
'slot_id': slot_id,
|
|
'message': self._get_response_message(response, slot_id)
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error during dispensing: {e}")
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'response': 'COMMUNICATION_ERROR'
|
|
}
|
|
|
|
def _get_response_message(self, response: str, slot_id: str) -> str:
|
|
"""Get user-friendly message for response codes"""
|
|
messages = {
|
|
'PRODUCT_DISPENSED': f'Product successfully dispensed from slot {slot_id}',
|
|
'DISPENSE_FAILED': f'Dispensing failed - no product detected from slot {slot_id}',
|
|
'SLOT_EMPTY': f'Slot {slot_id} is empty',
|
|
'MOTOR_ERROR': f'Motor malfunction in slot {slot_id}',
|
|
'TIMEOUT': f'Timeout - no response from slot {slot_id}',
|
|
'COMMUNICATION_ERROR': 'Communication error with machine'
|
|
}
|
|
return messages.get(response, f'Unknown response: {response}')
|
|
|
|
def check_machine_status(self) -> Dict[str, Any]:
|
|
"""Check overall machine status"""
|
|
if not self.is_connected or not self.serial_port:
|
|
return {'status': 'disconnected', 'message': 'Not connected to machine'}
|
|
|
|
try:
|
|
self.serial_port.reset_input_buffer()
|
|
self.serial_port.write(b'STATUS\n')
|
|
time.sleep(1)
|
|
|
|
if self.serial_port.in_waiting > 0:
|
|
response = self.serial_port.readline().decode('utf-8').strip()
|
|
return {
|
|
'status': 'connected',
|
|
'response': response,
|
|
'message': 'Machine is responding'
|
|
}
|
|
else:
|
|
return {
|
|
'status': 'no_response',
|
|
'message': 'Machine not responding'
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
'status': 'error',
|
|
'error': str(e),
|
|
'message': 'Communication error'
|
|
}
|
|
|
|
def reset_machine(self) -> Dict[str, Any]:
|
|
"""Send reset command to clear any error states"""
|
|
if not self.is_connected or not self.serial_port:
|
|
return {'success': False, 'error': 'Not connected to machine'}
|
|
|
|
try:
|
|
self.serial_port.write(b'RESET\n')
|
|
time.sleep(2)
|
|
|
|
response = self.serial_port.readline().decode('utf-8').strip()
|
|
return {
|
|
'success': True,
|
|
'response': response,
|
|
'message': 'Reset command sent'
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def disconnect(self):
|
|
"""Disconnect from serial port"""
|
|
if self.serial_port and self.serial_port.is_open:
|
|
self.serial_port.close()
|
|
self.is_connected = False
|
|
print("Disconnected from vending machine")
|
|
|
|
# Global instance
|
|
serial_service = SerialCommunicationService()
|
|
|
|
# Machine Service (UNCHANGED)
|
|
class MachineService:
|
|
@staticmethod
|
|
def generate_id(prefix):
|
|
return f"{prefix}{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
@staticmethod
|
|
def generate_unique_password():
|
|
while True:
|
|
password = secrets.token_hex(4).upper() # e.g., "A1B2C3D4"
|
|
if not Machine.query.filter_by(password=password).first():
|
|
return password
|
|
|
|
@staticmethod
|
|
def validate_machine_data(data):
|
|
required_fields = ['machine_model', 'machine_type', 'client_name', 'branch_name', 'operation_status', 'connection_status']
|
|
for field in required_fields:
|
|
if field not in data:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
|
|
status_mapping = {
|
|
'connected': 'connected',
|
|
'disconnected': 'disconnected',
|
|
'maintenance':'maintenance',
|
|
'active': 'active',
|
|
'inactive': 'inactive'
|
|
}
|
|
|
|
if data['operation_status'] not in status_mapping:
|
|
raise ValueError(f"Invalid operation status. Allowed statuses: {list(status_mapping.keys())}")
|
|
data['operation_status'] = status_mapping[data['operation_status']]
|
|
|
|
if data['connection_status'] not in status_mapping:
|
|
raise ValueError(f"Invalid connection status. Allowed statuses: {list(status_mapping.keys())}")
|
|
data['connection_status'] = status_mapping[data['connection_status']]
|
|
|
|
@staticmethod
|
|
def get_all_machines():
|
|
return Machine.query.all()
|
|
|
|
@staticmethod
|
|
def get_machine_slots(machine_id: str):
|
|
machine = Machine.query.filter_by(machine_id=machine_id).first()
|
|
if not machine:
|
|
return None
|
|
|
|
slots = VendingSlot.query.filter_by(machine_id=machine_id).all()
|
|
|
|
product_ids = [slot.product_id for slot in slots if slot.product_id]
|
|
products = {p.product_id: p for p in Product.query.filter(Product.product_id.in_(product_ids)).all()} if product_ids else {}
|
|
|
|
return [{
|
|
'slot_name': slot.row_id,
|
|
'column': int(slot.slot_name[1:]) if slot.slot_name[1:].isdigit() else 1,
|
|
'enabled': slot.enabled,
|
|
'product_id': slot.product_id,
|
|
'units': slot.units or 0,
|
|
'price': slot.price or '0.0',
|
|
'product_name': products.get(slot.product_id, {'product_name': 'N/A'}).product_name if slot.product_id else 'N/A',
|
|
'product_image': products.get(slot.product_id, {'product_image': None}).product_image if slot.product_id else None
|
|
} for slot in slots]
|
|
|
|
@staticmethod
|
|
def create_machine(data):
|
|
# Modified validation to accept client_id instead of client_name
|
|
required_fields = ['machine_model', 'machine_type', 'client_id', 'branch_name', 'operation_status', 'connection_status']
|
|
for field in required_fields:
|
|
if field not in data:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
|
|
status_mapping = {
|
|
'connected': 'connected',
|
|
'disconnected': 'disconnected',
|
|
'maintenance':'maintenance',
|
|
'active': 'active',
|
|
'inactive': 'inactive'
|
|
}
|
|
|
|
if data['operation_status'] not in status_mapping:
|
|
raise ValueError(f"Invalid operation status. Allowed statuses: {list(status_mapping.keys())}")
|
|
data['operation_status'] = status_mapping[data['operation_status']]
|
|
|
|
if data['connection_status'] not in status_mapping:
|
|
raise ValueError(f"Invalid connection status. Allowed statuses: {list(status_mapping.keys())}")
|
|
data['connection_status'] = status_mapping[data['connection_status']]
|
|
|
|
machine_id = MachineService.generate_id("M")
|
|
branch_id = MachineService.generate_id("B")
|
|
created_on = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
password = MachineService.generate_unique_password()
|
|
|
|
# Validate client_id exists in users
|
|
client_id = data.get('client_id')
|
|
if not client_id or not User.query.get(client_id):
|
|
raise ValueError("Invalid or missing client_id")
|
|
|
|
# Get client_name from user
|
|
user = User.query.get(client_id)
|
|
client_name = user.username
|
|
|
|
new_machine = Machine(
|
|
machine_id=machine_id,
|
|
machine_model=data['machine_model'],
|
|
machine_type=data['machine_type'],
|
|
client_id=client_id,
|
|
client_name=client_name,
|
|
branch_id=branch_id,
|
|
branch_name=data['branch_name'],
|
|
operation_status=data['operation_status'],
|
|
connection_status=data['connection_status'],
|
|
created_on=created_on,
|
|
password=password,
|
|
created_by=data.get('created_by') # NEW: Add created_by
|
|
)
|
|
|
|
db.session.add(new_machine)
|
|
db.session.commit()
|
|
return new_machine
|
|
@staticmethod
|
|
def update_machine(id, data):
|
|
machine = Machine.query.get_or_404(id)
|
|
|
|
if any(field in data for field in ['machine_model', 'machine_type', 'branch_name', 'operation_status', 'connection_status']):
|
|
MachineService.validate_machine_data({k: data.get(k, getattr(machine, k)) for k in ['machine_model', 'machine_type', 'branch_name', 'operation_status', 'connection_status']})
|
|
|
|
machine.machine_model = data.get('machine_model', machine.machine_model)
|
|
machine.machine_type = data.get('machine_type', machine.machine_type)
|
|
if 'client_id' in data:
|
|
client_id = data['client_id']
|
|
if not User.query.get(client_id):
|
|
raise ValueError("Invalid client_id")
|
|
machine.client_id = client_id
|
|
machine.branch_name = data.get('branch_name', machine.branch_name)
|
|
machine.operation_status = data.get('operation_status', machine.operation_status)
|
|
machine.connection_status = data.get('connection_status', machine.connection_status)
|
|
|
|
db.session.commit()
|
|
return machine
|
|
|
|
@staticmethod
|
|
def delete_machine(id):
|
|
machine = Machine.query.get_or_404(id)
|
|
db.session.delete(machine)
|
|
db.session.commit()
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_machine_by_id(id):
|
|
return Machine.query.get_or_404(id)
|
|
|
|
@staticmethod
|
|
def get_vending_state(machine_id):
|
|
machine = Machine.query.filter_by(machine_id=machine_id).first_or_404()
|
|
slots = VendingSlot.query.filter_by(machine_id=machine_id).all()
|
|
|
|
vending_rows = {}
|
|
for slot in slots:
|
|
if slot.row_id not in vending_rows:
|
|
vending_rows[slot.row_id] = []
|
|
vending_rows[slot.row_id].append(slot.to_dict())
|
|
|
|
return {
|
|
'vendingRows': [{'rowId': row_id, 'slots': slots} for row_id, slots in vending_rows.items()],
|
|
'lastUpdated': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
}
|
|
|
|
@staticmethod
|
|
def update_vending_state(machine_id, vending_rows):
|
|
machine = Machine.query.filter_by(machine_id=machine_id).first_or_404()
|
|
VendingSlot.query.filter_by(machine_id=machine_id).delete()
|
|
|
|
for row in vending_rows:
|
|
row_id = row['rowId']
|
|
for slot_data in row['slots']:
|
|
new_slot = VendingSlot(
|
|
machine_id=machine_id,
|
|
row_id=row_id,
|
|
slot_name=slot_data['name'],
|
|
enabled=slot_data['enabled'],
|
|
product_id=slot_data['productId'],
|
|
units=slot_data['units'],
|
|
price=slot_data['price']
|
|
)
|
|
db.session.add(new_slot)
|
|
|
|
db.session.commit()
|
|
return {'success': True, 'message': 'Vending state updated', 'lastUpdated': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
|
|
|
@staticmethod
|
|
def get_machine_refillers(machine_id):
|
|
"""
|
|
Get all Refillers assigned to a Machine
|
|
|
|
Args:
|
|
machine_id: machine_id string
|
|
|
|
Returns:
|
|
List of User objects (Refillers)
|
|
"""
|
|
from app.models.models import RefillerMachine, User
|
|
|
|
assignments = RefillerMachine.query.filter_by(machine_id=machine_id).all()
|
|
|
|
refillers = []
|
|
for assignment in assignments:
|
|
refiller = User.query.get(assignment.refiller_id)
|
|
if refiller and refiller.roles == 'Refiller':
|
|
refillers.append(refiller)
|
|
|
|
return refillers
|
|
|
|
@staticmethod
|
|
def assign_refillers_to_machine(machine_id, refiller_ids, assigned_by_id):
|
|
"""
|
|
Assign multiple Refillers to a Machine
|
|
|
|
Args:
|
|
machine_id: machine_id string
|
|
refiller_ids: List of Refiller user IDs
|
|
assigned_by_id: ID of the user making the assignment
|
|
|
|
Returns:
|
|
List of created assignments
|
|
"""
|
|
from app.models.models import RefillerMachine, User, Machine
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"ASSIGNING REFILLERS TO MACHINE")
|
|
print(f"{'='*60}")
|
|
|
|
# Validate machine exists
|
|
machine = Machine.query.filter_by(machine_id=machine_id).first()
|
|
if not machine:
|
|
raise ValueError(f"Machine {machine_id} not found")
|
|
|
|
print(f"Machine: {machine_id} ({machine.machine_model})")
|
|
print(f"Refillers to assign: {len(refiller_ids)}")
|
|
|
|
assignments = []
|
|
|
|
for refiller_id in refiller_ids:
|
|
# Validate refiller
|
|
refiller = User.query.get(refiller_id)
|
|
if not refiller:
|
|
print(f" ✗ Refiller {refiller_id} not found - skipping")
|
|
continue
|
|
if refiller.roles != 'Refiller':
|
|
print(f" ✗ User {refiller_id} is not a Refiller - skipping")
|
|
continue
|
|
|
|
# Check if already assigned
|
|
existing = RefillerMachine.query.filter_by(
|
|
refiller_id=refiller_id,
|
|
machine_id=machine_id
|
|
).first()
|
|
|
|
if existing:
|
|
print(f" ✓ Refiller {refiller.username} already assigned - skipping")
|
|
assignments.append(existing)
|
|
continue
|
|
|
|
# Create assignment
|
|
assignment = RefillerMachine(
|
|
refiller_id=refiller_id,
|
|
machine_id=machine_id,
|
|
assigned_by=assigned_by_id
|
|
)
|
|
|
|
db.session.add(assignment)
|
|
assignments.append(assignment)
|
|
print(f" ✓ Assigned refiller {refiller.username}")
|
|
|
|
db.session.commit()
|
|
|
|
print(f"\n✓ Successfully assigned {len(assignments)} refillers")
|
|
print(f"{'='*60}\n")
|
|
|
|
return assignments
|
|
|
|
|
|
# User Service (MODIFIED FOR BREVO SMTP WITH ENV VARIABLES)
|
|
# Updated UserService in services.py
|
|
class UserService:
|
|
@staticmethod
|
|
def generate_unique_password():
|
|
"""Generate a secure random password"""
|
|
return secrets.token_hex(4).upper() # 8 character hex password
|
|
|
|
@staticmethod
|
|
def save_file(file, folder='user_uploads'):
|
|
"""Save uploaded file and return path"""
|
|
if not file:
|
|
return None
|
|
|
|
filename = f"{uuid.uuid4().hex}_{file.filename}"
|
|
upload_folder = os.path.join(current_app.config['UPLOAD_FOLDER'], folder)
|
|
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
file_path = os.path.join(upload_folder, filename)
|
|
file.save(file_path)
|
|
|
|
return f"/uploads/{folder}/{filename}"
|
|
|
|
@staticmethod
|
|
def save_multiple_documents(files, metadata_json=None):
|
|
"""Save multiple document files with metadata"""
|
|
document_paths = []
|
|
|
|
metadata_list = []
|
|
if metadata_json:
|
|
try:
|
|
metadata_list = json.loads(metadata_json)
|
|
print(f"Parsed metadata: {metadata_list}")
|
|
except Exception as e:
|
|
print(f"Error parsing metadata: {e}")
|
|
metadata_list = []
|
|
|
|
if not isinstance(files, list):
|
|
files = [files]
|
|
|
|
for idx, file in enumerate(files):
|
|
if file and hasattr(file, 'filename'):
|
|
try:
|
|
path = UserService.save_file(file, 'user_documents')
|
|
if path:
|
|
metadata = metadata_list[idx] if idx < len(metadata_list) else {}
|
|
|
|
document_paths.append({
|
|
'filename': file.filename,
|
|
'path': path,
|
|
'uploaded_at': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
'document_type': metadata.get('document_type', 'other'),
|
|
'document_type_other': metadata.get('document_type_other', '')
|
|
})
|
|
print(f"Saved document: {file.filename}")
|
|
except Exception as e:
|
|
print(f"Error saving file {file.filename}: {e}")
|
|
continue
|
|
|
|
return document_paths
|
|
|
|
@staticmethod
|
|
def send_email_notification(username, email, contact, password, user_id):
|
|
"""Send email notification with user credentials"""
|
|
subject = "Your Account Credentials"
|
|
body = f"""
|
|
Dear {username},
|
|
|
|
Your account has been created successfully!
|
|
|
|
Login Credentials:
|
|
==================
|
|
User ID: {user_id}
|
|
Username: {username}
|
|
Email: {email}
|
|
Contact: {contact}
|
|
Password: {password}
|
|
|
|
Important Security Notice:
|
|
- Please change your password after first login
|
|
- Keep this information secure and confidential
|
|
- Do not share your credentials with anyone
|
|
|
|
You can login using either your email or username along with your password.
|
|
|
|
Best regards,
|
|
System Administration Team
|
|
"""
|
|
|
|
msg = MIMEText(body)
|
|
msg['Subject'] = subject
|
|
msg['From'] = os.getenv('BREVO_SENDER_EMAIL')
|
|
msg['To'] = email
|
|
|
|
smtp_email = os.getenv('BREVO_SMTP_EMAIL')
|
|
smtp_key = os.getenv('BREVO_SMTP_KEY')
|
|
|
|
if not all([smtp_email, smtp_key, msg['From']]):
|
|
print("Error: Brevo SMTP configuration missing in .env")
|
|
return False
|
|
|
|
try:
|
|
with smtplib.SMTP('smtp-relay.brevo.com', 587) as server:
|
|
server.starttls()
|
|
server.login(smtp_email, smtp_key)
|
|
server.send_message(msg)
|
|
print(f"✓ Email sent successfully to {email}")
|
|
return True
|
|
except Exception as e:
|
|
print(f"✗ Failed to send email to {email}: {str(e)}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def log_sms_notification(contact, username, email, password, user_id):
|
|
"""Log SMS notification (for future SMS integration)"""
|
|
notification = {
|
|
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"email": email,
|
|
"contact": contact,
|
|
"password": password,
|
|
"message": f"Your account credentials: User ID: {user_id}, Username: {username}, Email: {email}, Password: {password}"
|
|
}
|
|
|
|
notifications_file = os.path.join(current_app.root_path, "sms_notifications.json")
|
|
|
|
try:
|
|
notifications = []
|
|
if os.path.exists(notifications_file):
|
|
with open(notifications_file, "r") as f:
|
|
notifications = json.load(f)
|
|
|
|
notifications.append(notification)
|
|
|
|
with open(notifications_file, "w") as f:
|
|
json.dump(notifications, f, indent=4)
|
|
|
|
print(f"✓ SMS notification logged for {contact}")
|
|
return True
|
|
except Exception as e:
|
|
print(f"✗ Failed to log SMS notification: {str(e)}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def validate_user_data(data):
|
|
"""Validate user data before creating/updating"""
|
|
import re
|
|
|
|
required_fields = ['username', 'email', 'contact', 'roles', 'user_status']
|
|
for field in required_fields:
|
|
if field not in data:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
|
|
# Email validation - FIXED: Added closing quote
|
|
email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
if not re.match(email_regex, data['email']):
|
|
raise ValueError("Invalid email format")
|
|
|
|
@staticmethod
|
|
def get_all_users():
|
|
"""Get all users from database"""
|
|
return User.query.all()
|
|
|
|
@staticmethod
|
|
def create_user(data, photo_file=None, logo_file=None, document_files=None, documents_metadata=None):
|
|
"""
|
|
Create new user with proper password hashing
|
|
Password must be provided in data
|
|
"""
|
|
print(f"\n{'='*60}")
|
|
print(f"CREATING NEW USER")
|
|
print(f"{'='*60}")
|
|
|
|
# Validate user data
|
|
UserService.validate_user_data(data)
|
|
|
|
# CHECK: Password must be provided
|
|
if 'password' not in data or not data['password']:
|
|
raise ValueError("Password is required")
|
|
|
|
# Generate user ID only
|
|
user_id = User.generate_user_id(data['username'], data['email'])
|
|
password = data['password']
|
|
|
|
print(f"Generated User ID: {user_id}")
|
|
print(f"Password provided by admin")
|
|
|
|
# Handle file uploads
|
|
photo_path = UserService.save_file(photo_file, 'user_photos') if photo_file else None
|
|
logo_path = UserService.save_file(logo_file, 'company_logos') if logo_file else None
|
|
documents = UserService.save_multiple_documents(document_files, documents_metadata) if document_files else []
|
|
|
|
# ⭐ NEW: Validate assigned_to if provided
|
|
assigned_to = data.get('assigned_to')
|
|
if assigned_to:
|
|
# Validate that assigned_to user exists and is a Client
|
|
client = User.query.get(assigned_to)
|
|
if not client:
|
|
raise ValueError(f"Client with ID {assigned_to} not found")
|
|
if client.roles != 'Client':
|
|
raise ValueError(f"User {assigned_to} is not a Client. Can only assign Refillers to Clients.")
|
|
print(f"✓ Assigning Refiller to Client: {client.username} (ID: {assigned_to})")
|
|
|
|
# Create new user
|
|
new_user = User(
|
|
user_id=user_id,
|
|
username=data['username'],
|
|
email=data['email'],
|
|
contact=data['contact'],
|
|
roles=data['roles'],
|
|
user_status=data['user_status'],
|
|
photo=photo_path,
|
|
company_logo=logo_path,
|
|
documents=json.dumps(documents),
|
|
created_by=data.get('created_by'),
|
|
assigned_to=assigned_to # NEW: Set assigned_to
|
|
)
|
|
|
|
# Set password (this will hash it automatically)
|
|
new_user.set_password(password)
|
|
print(f"Password hashed and set for user")
|
|
|
|
# Save to database
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
print(f"✓ User created successfully with ID: {new_user.id}")
|
|
if assigned_to:
|
|
print(f"✓ Assigned to Client ID: {assigned_to}")
|
|
print(f"{'='*60}\n")
|
|
|
|
return new_user
|
|
|
|
@staticmethod
|
|
def update_user(id, data, photo_file=None, logo_file=None, document_files=None, documents_metadata=None):
|
|
"""
|
|
Update existing user
|
|
Password is only updated if explicitly provided in data
|
|
"""
|
|
user = User.query.get_or_404(id)
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"UPDATING USER: {user.username}")
|
|
print(f"{'='*60}")
|
|
|
|
# Validate only if fields are being updated
|
|
if any(field in data for field in ['username', 'email', 'contact', 'roles', 'user_status']):
|
|
UserService.validate_user_data({
|
|
'username': data.get('username', user.username),
|
|
'email': data.get('email', user.email),
|
|
'contact': data.get('contact', user.contact),
|
|
'roles': data.get('roles', user.roles),
|
|
'user_status': data.get('user_status', user.user_status)
|
|
})
|
|
|
|
# Update basic fields
|
|
user.username = data.get('username', user.username)
|
|
user.email = data.get('email', user.email)
|
|
user.contact = data.get('contact', user.contact)
|
|
user.roles = data.get('roles', user.roles)
|
|
user.user_status = data.get('user_status', user.user_status)
|
|
|
|
# ⭐ NEW: Update assigned_to if provided
|
|
if 'assigned_to' in data:
|
|
new_assigned_to = data['assigned_to']
|
|
|
|
if new_assigned_to:
|
|
# Validate that assigned_to user exists and is a Client
|
|
client = User.query.get(new_assigned_to)
|
|
if not client:
|
|
raise ValueError(f"Client with ID {new_assigned_to} not found")
|
|
if client.roles != 'Client':
|
|
raise ValueError(f"User {new_assigned_to} is not a Client. Can only assign Refillers to Clients.")
|
|
print(f"✓ Updating assignment to Client: {client.username} (ID: {new_assigned_to})")
|
|
else:
|
|
print(f"✓ Removing assignment (set to None)")
|
|
|
|
user.assigned_to = new_assigned_to
|
|
|
|
# Update password if provided (will be hashed automatically)
|
|
if 'password' in data and data['password']:
|
|
print(f"Updating password for user")
|
|
user.set_password(data['password'])
|
|
|
|
# Handle file updates (unchanged)
|
|
if photo_file:
|
|
if user.photo:
|
|
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
|
user.photo.replace('/uploads/', ''))
|
|
if os.path.exists(old_path):
|
|
os.remove(old_path)
|
|
user.photo = UserService.save_file(photo_file, 'user_photos')
|
|
|
|
if logo_file:
|
|
if user.company_logo:
|
|
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
|
user.company_logo.replace('/uploads/', ''))
|
|
if os.path.exists(old_path):
|
|
os.remove(old_path)
|
|
user.company_logo = UserService.save_file(logo_file, 'company_logos')
|
|
|
|
if document_files:
|
|
existing_docs = json.loads(user.documents) if user.documents else []
|
|
new_docs = UserService.save_multiple_documents(document_files, documents_metadata)
|
|
existing_docs.extend(new_docs)
|
|
user.documents = json.dumps(existing_docs)
|
|
|
|
user.updated_at = datetime.datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
print(f"✓ User updated successfully")
|
|
print(f"{'='*60}\n")
|
|
|
|
return user
|
|
|
|
@staticmethod
|
|
def delete_user_document(user_id, document_path):
|
|
"""Delete a specific document from a user"""
|
|
user = User.query.get_or_404(user_id)
|
|
|
|
if user.documents:
|
|
documents = json.loads(user.documents)
|
|
documents = [doc for doc in documents if doc['path'] != document_path]
|
|
user.documents = json.dumps(documents)
|
|
|
|
# Delete physical file
|
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
|
document_path.replace('/uploads/', ''))
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
db.session.commit()
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def delete_user(id):
|
|
"""Delete user and all associated files"""
|
|
user = User.query.get_or_404(id)
|
|
|
|
# Delete all associated files
|
|
if user.photo:
|
|
photo_path = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
|
user.photo.replace('/uploads/', ''))
|
|
if os.path.exists(photo_path):
|
|
os.remove(photo_path)
|
|
|
|
if user.company_logo:
|
|
logo_path = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
|
user.company_logo.replace('/uploads/', ''))
|
|
if os.path.exists(logo_path):
|
|
os.remove(logo_path)
|
|
|
|
if user.documents:
|
|
documents = json.loads(user.documents)
|
|
for doc in documents:
|
|
doc_path = os.path.join(current_app.config['UPLOAD_FOLDER'],
|
|
doc['path'].replace('/uploads/', ''))
|
|
if os.path.exists(doc_path):
|
|
os.remove(doc_path)
|
|
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
return True
|
|
|
|
@staticmethod
|
|
def validate_file(file, maxSizeMB=10):
|
|
"""Validate file size"""
|
|
max_size_bytes = maxSizeMB * 1024 * 1024
|
|
if file.size > max_size_bytes:
|
|
return f"File size exceeds {maxSizeMB}MB limit"
|
|
return None
|
|
|
|
@staticmethod
|
|
def is_valid_image_type(file):
|
|
"""Check if file is valid image type"""
|
|
valid_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
|
|
return valid_types.includes(file.type) if hasattr(file, 'type') else False
|
|
|
|
@staticmethod
|
|
def is_valid_document_type(file):
|
|
"""Check if file is valid document type"""
|
|
valid_types = [
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'image/jpeg',
|
|
'image/jpg',
|
|
'image/png'
|
|
]
|
|
return valid_types.includes(file.type) if hasattr(file, 'type') else False
|
|
|
|
@staticmethod
|
|
def assign_machines_to_refiller(refiller_id, machine_ids, assigned_by_id):
|
|
"""
|
|
Assign multiple machines to a Refiller
|
|
|
|
Args:
|
|
refiller_id: ID of the Refiller user
|
|
machine_ids: List of machine_id strings
|
|
assigned_by_id: ID of the user making the assignment (Admin)
|
|
|
|
Returns:
|
|
List of created RefillerMachine assignments
|
|
"""
|
|
from app.models.models import RefillerMachine, User, Machine
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"ASSIGNING MACHINES TO REFILLER")
|
|
print(f"{'='*60}")
|
|
|
|
# Validate refiller exists and is actually a Refiller
|
|
refiller = User.query.get(refiller_id)
|
|
if not refiller:
|
|
raise ValueError(f"Refiller with ID {refiller_id} not found")
|
|
if refiller.roles != 'Refiller':
|
|
raise ValueError(f"User {refiller_id} is not a Refiller")
|
|
|
|
print(f"Refiller: {refiller.username} (ID: {refiller_id})")
|
|
print(f"Machines to assign: {len(machine_ids)}")
|
|
|
|
# Optional: Validate machines belong to assigned client
|
|
if refiller.assigned_to:
|
|
client = User.query.get(refiller.assigned_to)
|
|
print(f"Assigned to Client: {client.username if client else 'None'}")
|
|
|
|
assignments = []
|
|
|
|
for machine_id in machine_ids:
|
|
# Validate machine exists
|
|
machine = Machine.query.filter_by(machine_id=machine_id).first()
|
|
if not machine:
|
|
print(f" ✗ Machine {machine_id} not found - skipping")
|
|
continue
|
|
|
|
# Optional: Check if machine belongs to assigned client
|
|
if refiller.assigned_to and machine.client_id != refiller.assigned_to:
|
|
print(f" ⚠ Warning: Machine {machine_id} doesn't belong to assigned client")
|
|
|
|
# Check if assignment already exists
|
|
existing = RefillerMachine.query.filter_by(
|
|
refiller_id=refiller_id,
|
|
machine_id=machine_id
|
|
).first()
|
|
|
|
if existing:
|
|
print(f" ✓ Machine {machine_id} already assigned - skipping")
|
|
assignments.append(existing)
|
|
continue
|
|
|
|
# Create new assignment
|
|
assignment = RefillerMachine(
|
|
refiller_id=refiller_id,
|
|
machine_id=machine_id,
|
|
assigned_by=assigned_by_id
|
|
)
|
|
|
|
db.session.add(assignment)
|
|
assignments.append(assignment)
|
|
print(f" ✓ Assigned machine {machine_id} ({machine.machine_model})")
|
|
|
|
db.session.commit()
|
|
|
|
print(f"\n✓ Successfully assigned {len(assignments)} machines")
|
|
print(f"{'='*60}\n")
|
|
|
|
return assignments
|
|
|
|
@staticmethod
|
|
def remove_machine_from_refiller(refiller_id, machine_id):
|
|
"""
|
|
Remove a machine assignment from a Refiller
|
|
|
|
Args:
|
|
refiller_id: ID of the Refiller user
|
|
machine_id: machine_id string to remove
|
|
|
|
Returns:
|
|
True if removed, False if not found
|
|
"""
|
|
from app.models.models import RefillerMachine
|
|
|
|
assignment = RefillerMachine.query.filter_by(
|
|
refiller_id=refiller_id,
|
|
machine_id=machine_id
|
|
).first()
|
|
|
|
if not assignment:
|
|
return False
|
|
|
|
db.session.delete(assignment)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Removed machine {machine_id} from refiller {refiller_id}")
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_refiller_machines(refiller_id):
|
|
"""
|
|
Get all machines assigned to a Refiller
|
|
|
|
Args:
|
|
refiller_id: ID of the Refiller user
|
|
|
|
Returns:
|
|
List of Machine objects
|
|
"""
|
|
from app.models.models import RefillerMachine, Machine
|
|
|
|
assignments = RefillerMachine.query.filter_by(refiller_id=refiller_id).all()
|
|
|
|
machines = []
|
|
for assignment in assignments:
|
|
machine = Machine.query.filter_by(machine_id=assignment.machine_id).first()
|
|
if machine:
|
|
machines.append(machine)
|
|
|
|
return machines
|
|
|
|
@staticmethod
|
|
def update_refiller_machines(refiller_id, machine_ids, assigned_by_id):
|
|
"""
|
|
Update machine assignments for a Refiller (replaces all existing)
|
|
|
|
Args:
|
|
refiller_id: ID of the Refiller user
|
|
machine_ids: List of machine_id strings (new complete list)
|
|
assigned_by_id: ID of the user making the assignment
|
|
|
|
Returns:
|
|
List of updated assignments
|
|
"""
|
|
from app.models.models import RefillerMachine
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"UPDATING REFILLER MACHINE ASSIGNMENTS")
|
|
print(f"{'='*60}")
|
|
print(f"Refiller ID: {refiller_id}")
|
|
print(f"New machine list: {machine_ids}")
|
|
|
|
# Remove all existing assignments
|
|
RefillerMachine.query.filter_by(refiller_id=refiller_id).delete()
|
|
db.session.commit()
|
|
print("✓ Cleared existing assignments")
|
|
|
|
# Add new assignments
|
|
if machine_ids and len(machine_ids) > 0:
|
|
assignments = UserService.assign_machines_to_refiller(
|
|
refiller_id,
|
|
machine_ids,
|
|
assigned_by_id
|
|
)
|
|
return assignments
|
|
else:
|
|
print("✓ No machines to assign")
|
|
return []
|
|
|
|
|
|
# Product Service (UNCHANGED)
|
|
class ProductService:
|
|
@staticmethod
|
|
def generate_id(prefix):
|
|
return f"{prefix}{uuid.uuid4().hex[:4].upper()}"
|
|
|
|
@staticmethod
|
|
def validate_product_data(data):
|
|
required_fields = ['product_name', 'price']
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
raise ValueError(f"Missing or empty required field: {field}")
|
|
|
|
try:
|
|
price = float(data['price'])
|
|
if price < 0:
|
|
raise ValueError("Price must be a positive number")
|
|
except (ValueError, TypeError):
|
|
raise ValueError("Price must be a valid number")
|
|
|
|
@staticmethod
|
|
def save_image(file):
|
|
if not file:
|
|
raise ValueError("Product image is required")
|
|
|
|
filename = f"{uuid.uuid4().hex}_{file.filename}"
|
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
|
|
file.save(file_path)
|
|
|
|
return f"/uploads/{filename}"
|
|
|
|
@staticmethod
|
|
def get_all_products():
|
|
return Product.query.all()
|
|
|
|
@staticmethod
|
|
def create_product(data, file):
|
|
ProductService.validate_product_data(data)
|
|
|
|
image_path = ProductService.save_image(file)
|
|
|
|
product_id = ProductService.generate_id("P")
|
|
created_date = data.get('created_date', datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
# Parse billing and expiration dates
|
|
billing_date = None
|
|
if data.get('billing_date'):
|
|
try:
|
|
billing_date = datetime.datetime.strptime(data['billing_date'], "%Y-%m-%d")
|
|
except ValueError:
|
|
pass
|
|
|
|
expiration_date = None
|
|
if data.get('expiration_date'):
|
|
try:
|
|
expiration_date = datetime.datetime.strptime(data['expiration_date'], "%Y-%m-%d")
|
|
except ValueError:
|
|
pass
|
|
|
|
new_product = Product(
|
|
product_id=product_id,
|
|
product_name=data['product_name'],
|
|
price=float(data['price']),
|
|
product_image=image_path,
|
|
created_date=created_date,
|
|
billing_date=billing_date,
|
|
expiration_date=expiration_date,
|
|
created_by=data.get('created_by')
|
|
)
|
|
|
|
db.session.add(new_product)
|
|
db.session.commit()
|
|
return new_product
|
|
|
|
@staticmethod
|
|
def update_product(id, data, file):
|
|
product = Product.query.get_or_404(id)
|
|
|
|
ProductService.validate_product_data(data)
|
|
|
|
product.product_name = data.get('product_name', product.product_name)
|
|
product.price = float(data.get('price', product.price))
|
|
product.created_date = data.get('created_date', product.created_date)
|
|
|
|
# Update billing date
|
|
if 'billing_date' in data and data['billing_date']:
|
|
try:
|
|
product.billing_date = datetime.datetime.strptime(data['billing_date'], "%Y-%m-%d")
|
|
except ValueError:
|
|
pass
|
|
|
|
# Update expiration date
|
|
if 'expiration_date' in data and data['expiration_date']:
|
|
try:
|
|
product.expiration_date = datetime.datetime.strptime(data['expiration_date'], "%Y-%m-%d")
|
|
except ValueError:
|
|
pass
|
|
|
|
if file:
|
|
if product.product_image:
|
|
old_image_path = os.path.join(current_app.config['UPLOAD_FOLDER'], product.product_image.split('/')[-1])
|
|
if os.path.exists(old_image_path):
|
|
os.remove(old_image_path)
|
|
product.product_image = ProductService.save_image(file)
|
|
|
|
db.session.commit()
|
|
return product
|
|
|
|
@staticmethod
|
|
def delete_product(id):
|
|
product = Product.query.get_or_404(id)
|
|
|
|
if product.product_image:
|
|
image_path = os.path.join(current_app.config['UPLOAD_FOLDER'], product.product_image.split('/')[-1])
|
|
if os.path.exists(image_path):
|
|
os.remove(image_path)
|
|
|
|
db.session.delete(product)
|
|
db.session.commit()
|
|
return True
|
|
|
|
class TransactionService:
|
|
@staticmethod
|
|
def create_transaction(machine_id, product_name, quantity, amount, payment_type, status='Success'):
|
|
"""Create a new transaction record"""
|
|
transaction_id = f"TXN-{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
new_transaction = Transaction(
|
|
transaction_id=transaction_id,
|
|
machine_id=machine_id,
|
|
product_name=product_name,
|
|
quantity=quantity,
|
|
amount=amount,
|
|
payment_type=payment_type,
|
|
status=status
|
|
)
|
|
|
|
db.session.add(new_transaction)
|
|
db.session.commit()
|
|
return new_transaction
|
|
|
|
@staticmethod
|
|
def update_transaction_status(transaction_id, status, amount_receiving_status=None, return_amount=None):
|
|
"""Update transaction status after dispensing"""
|
|
transaction = Transaction.query.filter_by(transaction_id=transaction_id).first()
|
|
if transaction:
|
|
transaction.status = status
|
|
if amount_receiving_status:
|
|
transaction.amount_receiving_status = amount_receiving_status
|
|
if return_amount:
|
|
transaction.return_amount = return_amount
|
|
db.session.commit()
|
|
return transaction
|
|
|
|
@staticmethod
|
|
def get_all_transactions(filters=None):
|
|
"""Get all transactions with optional filters"""
|
|
query = Transaction.query
|
|
|
|
if filters:
|
|
if 'machine_id' in filters:
|
|
query = query.filter_by(machine_id=filters['machine_id'])
|
|
if 'status' in filters:
|
|
query = query.filter_by(status=filters['status'])
|
|
if 'date_from' in filters:
|
|
query = query.filter(Transaction.created_at >= filters['date_from'])
|
|
if 'date_to' in filters:
|
|
query = query.filter(Transaction.created_at <= filters['date_to'])
|
|
|
|
return query.order_by(Transaction.created_at.desc()).all()
|
|
|
|
@staticmethod
|
|
def get_transaction_by_id(transaction_id):
|
|
"""Get single transaction by ID"""
|
|
return Transaction.query.filter_by(transaction_id=transaction_id).first()
|
|
|
|
class RoleService:
|
|
|
|
@staticmethod
|
|
def get_all_roles():
|
|
"""Get all roles"""
|
|
return Role.query.all()
|
|
|
|
@staticmethod
|
|
def get_role_by_id(role_id):
|
|
"""Get role by ID"""
|
|
return Role.query.get_or_404(role_id)
|
|
|
|
@staticmethod
|
|
def create_role(data):
|
|
"""Create new role"""
|
|
name = data.get('name')
|
|
description = data.get('description', '')
|
|
permissions = data.get('permissions', [])
|
|
|
|
# Check if role already exists
|
|
existing_role = Role.query.filter_by(name=name).first()
|
|
if existing_role:
|
|
raise ValueError(f"Role '{name}' already exists")
|
|
|
|
new_role = Role(
|
|
name=name,
|
|
description=description,
|
|
permissions=json.dumps(permissions),
|
|
created_by=data.get('created_by') # NEW: Add created_by
|
|
)
|
|
|
|
db.session.add(new_role)
|
|
db.session.commit()
|
|
|
|
return new_role
|
|
|
|
@staticmethod
|
|
def update_role(role_id, data):
|
|
"""Update existing role"""
|
|
role = Role.query.get_or_404(role_id)
|
|
|
|
if 'name' in data:
|
|
# Check if new name conflicts with existing role
|
|
existing = Role.query.filter(Role.name == data['name'], Role.id != role_id).first()
|
|
if existing:
|
|
raise ValueError(f"Role '{data['name']}' already exists")
|
|
role.name = data['name']
|
|
|
|
if 'description' in data:
|
|
role.description = data['description']
|
|
|
|
if 'permissions' in data:
|
|
role.permissions = json.dumps(data['permissions'])
|
|
|
|
role.updated_at = datetime.datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return role
|
|
|
|
@staticmethod
|
|
def delete_role(role_id):
|
|
"""Delete role"""
|
|
role = Role.query.get_or_404(role_id)
|
|
|
|
# Check if any users have this role
|
|
users_with_role = User.query.filter_by(roles=role.name).count()
|
|
if users_with_role > 0:
|
|
raise ValueError(f"Cannot delete role '{role.name}' - {users_with_role} user(s) still assigned to it")
|
|
|
|
db.session.delete(role)
|
|
db.session.commit()
|
|
|
|
return True
|
|
|
|
class BranchService:
|
|
"""Service for managing branches"""
|
|
|
|
@staticmethod
|
|
def validate_branch_data(data):
|
|
"""Validate branch data"""
|
|
import re
|
|
|
|
required_fields = ['code', 'name', 'location', 'address', 'contact']
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
|
|
# Validate code format (uppercase alphanumeric)
|
|
if not re.match(r'^[A-Z0-9]+$', data['code']):
|
|
raise ValueError("Branch code must contain only uppercase letters and numbers")
|
|
|
|
# Validate contact (10 digits)
|
|
if not re.match(r'^\d{10}$', data['contact']):
|
|
raise ValueError("Contact must be exactly 10 digits")
|
|
|
|
# Validate name length
|
|
if len(data['name']) < 3:
|
|
raise ValueError("Branch name must be at least 3 characters long")
|
|
|
|
# Validate address length
|
|
if len(data['address']) < 10:
|
|
raise ValueError("Address must be at least 10 characters long")
|
|
|
|
@staticmethod
|
|
def get_all_branches():
|
|
"""Get all branches"""
|
|
from app.models.models import Branch
|
|
return Branch.query.order_by(Branch.created_at.desc()).all()
|
|
|
|
@staticmethod
|
|
def get_branch_by_id(branch_id):
|
|
"""Get branch by ID"""
|
|
from app.models.models import Branch
|
|
return Branch.query.get_or_404(branch_id)
|
|
|
|
@staticmethod
|
|
def create_branch(data):
|
|
"""Create new branch"""
|
|
from app.models.models import Branch
|
|
from app import db
|
|
|
|
# Validate data
|
|
BranchService.validate_branch_data(data)
|
|
|
|
# Check if code already exists
|
|
existing = Branch.query.filter_by(code=data['code']).first()
|
|
if existing:
|
|
raise ValueError(f"Branch code '{data['code']}' already exists")
|
|
|
|
# Generate unique branch ID
|
|
branch_id = Branch.generate_branch_id()
|
|
|
|
# Create new branch
|
|
new_branch = Branch(
|
|
branch_id=branch_id,
|
|
code=data['code'].upper(),
|
|
name=data['name'],
|
|
location=data['location'],
|
|
address=data['address'],
|
|
contact=data['contact'],
|
|
created_by=data.get('created_by') # NEW: Add created_by
|
|
)
|
|
|
|
db.session.add(new_branch)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Branch created: {new_branch.name} ({new_branch.branch_id})")
|
|
|
|
return new_branch
|
|
|
|
@staticmethod
|
|
def update_branch(branch_id, data):
|
|
"""Update existing branch"""
|
|
from app.models.models import Branch
|
|
from app import db
|
|
|
|
branch = Branch.query.get_or_404(branch_id)
|
|
|
|
# Validate data
|
|
BranchService.validate_branch_data(data)
|
|
|
|
# Check if new code conflicts with existing branch
|
|
if data['code'] != branch.code:
|
|
existing = Branch.query.filter(
|
|
Branch.code == data['code'],
|
|
Branch.id != branch_id
|
|
).first()
|
|
if existing:
|
|
raise ValueError(f"Branch code '{data['code']}' already exists")
|
|
|
|
# Update fields
|
|
branch.code = data['code'].upper()
|
|
branch.name = data['name']
|
|
branch.location = data['location']
|
|
branch.address = data['address']
|
|
branch.contact = data['contact']
|
|
branch.updated_at = datetime.datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
print(f"✓ Branch updated: {branch.name} ({branch.branch_id})")
|
|
|
|
return branch
|
|
|
|
@staticmethod
|
|
def delete_branch(branch_id):
|
|
"""Delete branch"""
|
|
from app.models.models import Branch
|
|
from app import db
|
|
|
|
branch = Branch.query.get_or_404(branch_id)
|
|
|
|
# TODO: Check if branch is associated with any machines
|
|
# This prevents deleting branches that are in use
|
|
|
|
branch_name = branch.name
|
|
|
|
db.session.delete(branch)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Branch deleted: {branch_name}")
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def search_branches(query):
|
|
"""Search branches by name, code, or location"""
|
|
from app.models.models import Branch
|
|
|
|
search_term = f"%{query}%"
|
|
|
|
branches = Branch.query.filter(
|
|
db.or_(
|
|
Branch.name.ilike(search_term),
|
|
Branch.code.ilike(search_term),
|
|
Branch.location.ilike(search_term),
|
|
Branch.branch_id.ilike(search_term)
|
|
)
|
|
).order_by(Branch.created_at.desc()).all()
|
|
|
|
return branches
|
|
|
|
class BrandService:
|
|
"""Service for managing brands"""
|
|
|
|
@staticmethod
|
|
def validate_brand_data(data):
|
|
"""Validate brand data"""
|
|
required_fields = ['name', 'branch_id']
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
|
|
# Validate name length
|
|
if len(data['name']) < 2:
|
|
raise ValueError("Brand name must be at least 2 characters long")
|
|
|
|
@staticmethod
|
|
def save_image(file):
|
|
"""Save brand image and return path"""
|
|
if not file:
|
|
return None
|
|
|
|
filename = f"{uuid.uuid4().hex}_{file.filename}"
|
|
upload_folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'brand_images')
|
|
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
file_path = os.path.join(upload_folder, filename)
|
|
file.save(file_path)
|
|
|
|
return f"/uploads/brand_images/{filename}"
|
|
|
|
@staticmethod
|
|
def get_all_brands():
|
|
"""Get all brands with branch information"""
|
|
from app.models.models import Brand
|
|
return Brand.query.order_by(Brand.created_at.desc()).all()
|
|
|
|
@staticmethod
|
|
def get_brand_by_id(brand_id):
|
|
"""Get brand by ID"""
|
|
from app.models.models import Brand
|
|
return Brand.query.get_or_404(brand_id)
|
|
|
|
@staticmethod
|
|
def create_brand(data, image_file=None):
|
|
"""Create new brand"""
|
|
from app.models.models import Brand, Branch
|
|
from app import db
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"CREATING NEW BRAND")
|
|
print(f"{'='*60}")
|
|
|
|
# Validate data
|
|
BrandService.validate_brand_data(data)
|
|
|
|
# Get branch information
|
|
branch = Branch.query.filter_by(branch_id=data['branch_id']).first()
|
|
if not branch:
|
|
raise ValueError(f"Branch not found: {data['branch_id']}")
|
|
|
|
# Generate brand ID from name
|
|
brand_id = Brand.generate_brand_id(data['name'])
|
|
print(f"Generated Brand ID: {brand_id}")
|
|
|
|
# Handle image upload
|
|
image_path = BrandService.save_image(image_file) if image_file else None
|
|
|
|
# Create new brand
|
|
new_brand = Brand(
|
|
brand_id=brand_id,
|
|
name=data['name'],
|
|
branch_id=branch.branch_id,
|
|
branch_name=branch.name,
|
|
image=image_path,
|
|
created_by=data.get('created_by') # NEW: Add created_by
|
|
)
|
|
|
|
db.session.add(new_brand)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Brand created: {new_brand.name} ({new_brand.brand_id})")
|
|
print(f"✓ Linked to branch: {branch.name}")
|
|
print(f"{'='*60}\n")
|
|
|
|
return new_brand
|
|
|
|
@staticmethod
|
|
def update_brand(brand_id, data, image_file=None):
|
|
"""Update existing brand"""
|
|
from app.models.models import Brand, Branch
|
|
from app import db
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"UPDATING BRAND - ID: {brand_id}")
|
|
print(f"{'='*60}")
|
|
|
|
brand = Brand.query.get_or_404(brand_id)
|
|
|
|
# Validate data
|
|
BrandService.validate_brand_data(data)
|
|
|
|
# Get branch information if branch changed
|
|
if data['branch_id'] != brand.branch_id:
|
|
branch = Branch.query.filter_by(branch_id=data['branch_id']).first()
|
|
if not branch:
|
|
raise ValueError(f"Branch not found: {data['branch_id']}")
|
|
brand.branch_id = branch.branch_id
|
|
brand.branch_name = branch.name
|
|
|
|
# Update name and regenerate brand_id if name changed
|
|
if data['name'] != brand.name:
|
|
old_brand_id = brand.brand_id
|
|
brand.name = data['name']
|
|
brand.brand_id = Brand.generate_brand_id(data['name'])
|
|
print(f"Brand ID changed: {old_brand_id} -> {brand.brand_id}")
|
|
|
|
# Handle image update
|
|
if image_file:
|
|
# Delete old image if exists
|
|
if brand.image:
|
|
old_image_path = os.path.join(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
brand.image.replace('/uploads/', '')
|
|
)
|
|
if os.path.exists(old_image_path):
|
|
os.remove(old_image_path)
|
|
|
|
brand.image = BrandService.save_image(image_file)
|
|
|
|
brand.updated_at = datetime.datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
print(f"✓ Brand updated: {brand.name} ({brand.brand_id})")
|
|
print(f"{'='*60}\n")
|
|
|
|
return brand
|
|
|
|
@staticmethod
|
|
def delete_brand(brand_id):
|
|
"""Delete brand"""
|
|
from app.models.models import Brand
|
|
from app import db
|
|
|
|
brand = Brand.query.get_or_404(brand_id)
|
|
|
|
brand_name = brand.name
|
|
|
|
# Delete image if exists
|
|
if brand.image:
|
|
image_path = os.path.join(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
brand.image.replace('/uploads/', '')
|
|
)
|
|
if os.path.exists(image_path):
|
|
os.remove(image_path)
|
|
|
|
db.session.delete(brand)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Brand deleted: {brand_name}")
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def search_brands(query):
|
|
"""Search brands by name or brand_id"""
|
|
from app.models.models import Brand
|
|
|
|
search_term = f"%{query}%"
|
|
|
|
brands = Brand.query.filter(
|
|
db.or_(
|
|
Brand.name.ilike(search_term),
|
|
Brand.brand_id.ilike(search_term),
|
|
Brand.branch_name.ilike(search_term)
|
|
)
|
|
).order_by(Brand.created_at.desc()).all()
|
|
|
|
return brands
|
|
|
|
@staticmethod
|
|
def get_brands_by_branch(branch_id):
|
|
"""Get all brands for a specific branch"""
|
|
from app.models.models import Brand
|
|
|
|
brands = Brand.query.filter_by(branch_id=branch_id)\
|
|
.order_by(Brand.created_at.desc()).all()
|
|
|
|
return brands
|
|
|
|
class CategoryService:
|
|
"""Service for managing categories"""
|
|
|
|
@staticmethod
|
|
def validate_category_data(data):
|
|
"""Validate category data"""
|
|
required_fields = ['name', 'brand_id', 'branch_id']
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
|
|
# Validate name length
|
|
if len(data['name']) < 2:
|
|
raise ValueError("Category name must be at least 2 characters long")
|
|
|
|
@staticmethod
|
|
def save_image(file):
|
|
"""Save category image and return path"""
|
|
if not file:
|
|
return None
|
|
|
|
filename = f"{uuid.uuid4().hex}_{file.filename}"
|
|
upload_folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'category_images')
|
|
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
file_path = os.path.join(upload_folder, filename)
|
|
file.save(file_path)
|
|
|
|
return f"/uploads/category_images/{filename}"
|
|
|
|
@staticmethod
|
|
def get_all_categories():
|
|
"""Get all categories with related information"""
|
|
from app.models.models import Category
|
|
return Category.query.order_by(Category.created_at.desc()).all()
|
|
|
|
@staticmethod
|
|
def get_category_by_id(category_id):
|
|
"""Get category by ID"""
|
|
from app.models.models import Category
|
|
return Category.query.get_or_404(category_id)
|
|
|
|
@staticmethod
|
|
def create_category(data, image_file=None):
|
|
"""Create new category"""
|
|
from app.models.models import Category, Brand, Branch
|
|
from app import db
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"CREATING NEW CATEGORY")
|
|
print(f"{'='*60}")
|
|
|
|
# Validate data
|
|
CategoryService.validate_category_data(data)
|
|
|
|
# Get brand information
|
|
brand = Brand.query.filter_by(brand_id=data['brand_id']).first()
|
|
if not brand:
|
|
raise ValueError(f"Brand not found: {data['brand_id']}")
|
|
|
|
# Get branch information
|
|
branch = Branch.query.filter_by(branch_id=data['branch_id']).first()
|
|
if not branch:
|
|
raise ValueError(f"Branch not found: {data['branch_id']}")
|
|
|
|
# Generate category ID from name
|
|
category_id = Category.generate_category_id(data['name'])
|
|
print(f"Generated Category ID: {category_id}")
|
|
|
|
# Handle image upload
|
|
image_path = CategoryService.save_image(image_file) if image_file else None
|
|
|
|
# Create new category
|
|
new_category = Category(
|
|
category_id=category_id,
|
|
name=data['name'],
|
|
brand_id=brand.brand_id,
|
|
brand_name=brand.name,
|
|
branch_id=branch.branch_id,
|
|
branch_name=branch.name,
|
|
image=image_path,
|
|
created_by=data.get('created_by') # NEW: Add created_by
|
|
)
|
|
|
|
db.session.add(new_category)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Category created: {new_category.name} ({new_category.category_id})")
|
|
print(f"✓ Linked to brand: {brand.name}")
|
|
print(f"✓ Linked to branch: {branch.name}")
|
|
print(f"{'='*60}\n")
|
|
|
|
return new_category
|
|
|
|
@staticmethod
|
|
def update_category(category_id, data, image_file=None):
|
|
"""Update existing category"""
|
|
from app.models.models import Category, Brand, Branch
|
|
from app import db
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"UPDATING CATEGORY - ID: {category_id}")
|
|
print(f"{'='*60}")
|
|
|
|
category = Category.query.get_or_404(category_id)
|
|
|
|
# Validate data
|
|
CategoryService.validate_category_data(data)
|
|
|
|
# Get brand information if brand changed
|
|
if data['brand_id'] != category.brand_id:
|
|
brand = Brand.query.filter_by(brand_id=data['brand_id']).first()
|
|
if not brand:
|
|
raise ValueError(f"Brand not found: {data['brand_id']}")
|
|
category.brand_id = brand.brand_id
|
|
category.brand_name = brand.name
|
|
|
|
# Get branch information if branch changed
|
|
if data['branch_id'] != category.branch_id:
|
|
branch = Branch.query.filter_by(branch_id=data['branch_id']).first()
|
|
if not branch:
|
|
raise ValueError(f"Branch not found: {data['branch_id']}")
|
|
category.branch_id = branch.branch_id
|
|
category.branch_name = branch.name
|
|
|
|
# Update name and regenerate category_id if name changed
|
|
if data['name'] != category.name:
|
|
old_category_id = category.category_id
|
|
category.name = data['name']
|
|
category.category_id = Category.generate_category_id(data['name'])
|
|
print(f"Category ID changed: {old_category_id} -> {category.category_id}")
|
|
|
|
# Handle image update
|
|
if image_file:
|
|
# Delete old image if exists
|
|
if category.image:
|
|
old_image_path = os.path.join(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
category.image.replace('/uploads/', '')
|
|
)
|
|
if os.path.exists(old_image_path):
|
|
os.remove(old_image_path)
|
|
|
|
category.image = CategoryService.save_image(image_file)
|
|
|
|
category.updated_at = datetime.datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
print(f"✓ Category updated: {category.name} ({category.category_id})")
|
|
print(f"{'='*60}\n")
|
|
|
|
return category
|
|
|
|
@staticmethod
|
|
def delete_category(category_id):
|
|
"""Delete category"""
|
|
from app.models.models import Category
|
|
from app import db
|
|
|
|
category = Category.query.get_or_404(category_id)
|
|
|
|
category_name = category.name
|
|
|
|
# Delete image if exists
|
|
if category.image:
|
|
image_path = os.path.join(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
category.image.replace('/uploads/', '')
|
|
)
|
|
if os.path.exists(image_path):
|
|
os.remove(image_path)
|
|
|
|
db.session.delete(category)
|
|
db.session.commit()
|
|
|
|
print(f"✓ Category deleted: {category_name}")
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def search_categories(query):
|
|
"""Search categories by name or category_id"""
|
|
from app.models.models import Category
|
|
|
|
search_term = f"%{query}%"
|
|
|
|
categories = Category.query.filter(
|
|
db.or_(
|
|
Category.name.ilike(search_term),
|
|
Category.category_id.ilike(search_term),
|
|
Category.brand_name.ilike(search_term),
|
|
Category.branch_name.ilike(search_term)
|
|
)
|
|
).order_by(Category.created_at.desc()).all()
|
|
|
|
return categories
|
|
|
|
@staticmethod
|
|
def get_categories_by_brand(brand_id):
|
|
"""Get all categories for a specific brand"""
|
|
from app.models.models import Category
|
|
|
|
categories = Category.query.filter_by(brand_id=brand_id)\
|
|
.order_by(Category.created_at.desc()).all()
|
|
|
|
return categories
|
|
|
|
@staticmethod
|
|
def get_categories_by_branch(branch_id):
|
|
"""Get all categories for a specific branch"""
|
|
from app.models.models import Category
|
|
|
|
categories = Category.query.filter_by(branch_id=branch_id)\
|
|
.order_by(Category.created_at.desc()).all()
|
|
|
|
return categories
|
|
|
|
class SubCategoryService:
|
|
@staticmethod
|
|
def validate_data(data):
|
|
required = ['name', 'category_id', 'brand_id', 'branch_id']
|
|
for field in required:
|
|
if field not in data or not data[field]:
|
|
raise ValueError(f"Missing required field: {field}")
|
|
if len(data['name']) < 2:
|
|
raise ValueError("Name must be at least 2 characters")
|
|
|
|
@staticmethod
|
|
def save_image(file):
|
|
if not file:
|
|
return None
|
|
filename = f"{uuid.uuid4().hex}_{file.filename}"
|
|
folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'subcategory_images')
|
|
os.makedirs(folder, exist_ok=True)
|
|
file.save(os.path.join(folder, filename))
|
|
return f"/uploads/subcategory_images/{filename}"
|
|
|
|
@staticmethod
|
|
def get_all():
|
|
from app.models.models import SubCategory
|
|
return SubCategory.query.order_by(SubCategory.created_at.desc()).all()
|
|
|
|
@staticmethod
|
|
def get_by_id(id):
|
|
from app.models.models import SubCategory
|
|
return SubCategory.query.get_or_404(id)
|
|
|
|
@staticmethod
|
|
def create(data, image_file=None):
|
|
from app.models.models import SubCategory, Category, Brand, Branch
|
|
from app import db
|
|
|
|
SubCategoryService.validate_data(data)
|
|
|
|
category = Category.query.filter_by(category_id=data['category_id']).first()
|
|
if not category:
|
|
raise ValueError(f"Category not found")
|
|
|
|
brand = Brand.query.filter_by(brand_id=data['brand_id']).first()
|
|
if not brand:
|
|
raise ValueError(f"Brand not found")
|
|
|
|
branch = Branch.query.filter_by(branch_id=data['branch_id']).first()
|
|
if not branch:
|
|
raise ValueError(f"Branch not found")
|
|
|
|
sub_id = SubCategory.generate_sub_category_id(data['name'])
|
|
image = SubCategoryService.save_image(image_file) if image_file else None
|
|
|
|
new_sub = SubCategory(
|
|
sub_category_id=sub_id,
|
|
name=data['name'],
|
|
image=image,
|
|
category_id=category.category_id,
|
|
category_name=category.name,
|
|
brand_id=brand.brand_id,
|
|
brand_name=brand.name,
|
|
branch_id=branch.branch_id,
|
|
branch_name=branch.name,
|
|
created_by=data.get('created_by') # NEW: Add created_by
|
|
)
|
|
|
|
db.session.add(new_sub)
|
|
db.session.commit()
|
|
|
|
print(f"✓ SubCategory created: {new_sub.name} ({new_sub.sub_category_id})")
|
|
|
|
return new_sub
|
|
|
|
@staticmethod
|
|
def update(id, data, image_file=None):
|
|
from app.models.models import SubCategory, Category, Brand, Branch
|
|
from app import db
|
|
subcat = SubCategory.query.get_or_404(id)
|
|
SubCategoryService.validate_data(data)
|
|
if data['category_id'] != subcat.category_id:
|
|
category = Category.query.filter_by(category_id=data['category_id']).first()
|
|
if not category:
|
|
raise ValueError("Category not found")
|
|
subcat.category_id = category.category_id
|
|
subcat.category_name = category.name
|
|
if data['brand_id'] != subcat.brand_id:
|
|
brand = Brand.query.filter_by(brand_id=data['brand_id']).first()
|
|
if not brand:
|
|
raise ValueError("Brand not found")
|
|
subcat.brand_id = brand.brand_id
|
|
subcat.brand_name = brand.name
|
|
if data['branch_id'] != subcat.branch_id:
|
|
branch = Branch.query.filter_by(branch_id=data['branch_id']).first()
|
|
if not branch:
|
|
raise ValueError("Branch not found")
|
|
subcat.branch_id = branch.branch_id
|
|
subcat.branch_name = branch.name
|
|
if data['name'] != subcat.name:
|
|
subcat.name = data['name']
|
|
subcat.sub_category_id = SubCategory.generate_sub_category_id(data['name'])
|
|
if image_file:
|
|
if subcat.image:
|
|
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], subcat.image.replace('/uploads/', ''))
|
|
if os.path.exists(old_path):
|
|
os.remove(old_path)
|
|
subcat.image = SubCategoryService.save_image(image_file)
|
|
subcat.updated_at = datetime.datetime.utcnow()
|
|
db.session.commit()
|
|
return subcat
|
|
|
|
@staticmethod
|
|
def delete(id):
|
|
from app.models.models import SubCategory
|
|
from app import db
|
|
subcat = SubCategory.query.get_or_404(id)
|
|
if subcat.image:
|
|
path = os.path.join(current_app.config['UPLOAD_FOLDER'], subcat.image.replace('/uploads/', ''))
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
db.session.delete(subcat)
|
|
db.session.commit()
|
|
return True |