Files
MarineMaintenance/app.py
T
alro65 67a0e674ca Initial commit — MarineMaintenance v1.0
Marine maintenance management: work orders with photos, ISM/SWP procedures,
MSDS, inventory, RFQ/purchases, vessel history, bilingual PDF reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:54:20 -04:00

2342 lines
115 KiB
Python

from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file, session
import sqlite3, os, uuid
from datetime import datetime, date
from werkzeug.utils import secure_filename
from auth import (login_user, logout_user, current_user, is_logged_in,
is_superadmin, is_admin, login_required, admin_required,
superadmin_required, hash_password, verify_password,
create_initial_superadmin)
app = Flask(__name__)
app.secret_key = 'marine_maint_secret_2026_xK9p'
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'marine_maintenance.db')
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'photos')
LOGO_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'logos')
DOCS_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'docs')
SIG_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'signatures')
PDF_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'pdfs')
ALLOWED_IMG = {'png','jpg','jpeg','gif','webp'}
ALLOWED_DOCS = {'pdf','doc','docx','xls','xlsx','txt','png','jpg','jpeg','gif','webp'}
app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024
for folder in [UPLOAD_FOLDER, LOGO_FOLDER, DOCS_FOLDER, SIG_FOLDER, PDF_FOLDER]:
os.makedirs(folder, exist_ok=True)
# ── DB ────────────────────────────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db():
with open(os.path.join(BASE_DIR, 'schema.sql'), 'r', encoding='utf-8') as f:
conn = get_db()
conn.executescript(f.read())
conn.commit()
# Always run - safe, skips if column already exists
migrations = [
"ALTER TABLE vessels ADD COLUMN company_id INTEGER",
"ALTER TABLE vessels ADD COLUMN engine_type TEXT",
"ALTER TABLE vessels ADD COLUMN engine_hours REAL DEFAULT 0",
"ALTER TABLE vessels ADD COLUMN captain_name TEXT",
"ALTER TABLE vessels ADD COLUMN captain_phone TEXT",
"ALTER TABLE vessels ADD COLUMN captain_email TEXT",
"ALTER TABLE vessels ADD COLUMN flag TEXT",
"ALTER TABLE vessels ADD COLUMN port_of_registry TEXT",
"ALTER TABLE work_orders ADD COLUMN scope TEXT",
"ALTER TABLE work_orders ADD COLUMN root_cause TEXT",
"ALTER TABLE work_orders ADD COLUMN repairs_done TEXT",
"ALTER TABLE work_orders ADD COLUMN equipment_id INTEGER",
"ALTER TABLE work_orders ADD COLUMN system_id INTEGER",
"""CREATE TABLE IF NOT EXISTS rfq (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
vessel_id INTEGER REFERENCES vessels(id),
work_order_id INTEGER REFERENCES work_orders(id),
rfq_number TEXT UNIQUE, status TEXT DEFAULT 'draft',
subject TEXT, notes TEXT, created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS rfq_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfq_id INTEGER NOT NULL REFERENCES rfq(id) ON DELETE CASCADE,
description TEXT NOT NULL, quantity REAL DEFAULT 1,
unit TEXT DEFAULT 'pcs', notes TEXT)""",
"""CREATE TABLE IF NOT EXISTS rfq_quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfq_id INTEGER NOT NULL REFERENCES rfq(id) ON DELETE CASCADE,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
status TEXT DEFAULT 'pending', received_date DATE,
pdf_filename TEXT, notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS rfq_quote_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_id INTEGER NOT NULL REFERENCES rfq_quotes(id) ON DELETE CASCADE,
rfq_item_id INTEGER NOT NULL REFERENCES rfq_items(id),
unit_price REAL DEFAULT 0, delivery_days INTEGER,
brand TEXT, notes TEXT, selected INTEGER DEFAULT 0,
selection_reason TEXT)""",
"""CREATE TABLE IF NOT EXISTS purchase_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
vessel_id INTEGER REFERENCES vessels(id),
work_order_id INTEGER REFERENCES work_orders(id),
rfq_id INTEGER REFERENCES rfq(id),
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
po_number TEXT UNIQUE, status TEXT DEFAULT 'draft',
po_date DATE, expected_date DATE,
payment_terms TEXT DEFAULT 'Net 30',
shipping_address TEXT, notes TEXT,
total_amount REAL DEFAULT 0, invoice_filename TEXT,
created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS po_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
po_id INTEGER NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
rfq_item_id INTEGER REFERENCES rfq_items(id),
description TEXT NOT NULL, quantity REAL DEFAULT 1,
unit TEXT DEFAULT 'pcs', unit_cost REAL DEFAULT 0,
total_cost REAL GENERATED ALWAYS AS (quantity * unit_cost) STORED,
part_id INTEGER REFERENCES parts(id),
received_qty REAL DEFAULT 0, notes TEXT)""",
"""CREATE TABLE IF NOT EXISTS inventory_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER REFERENCES parts(id),
po_id INTEGER REFERENCES purchase_orders(id),
work_order_id INTEGER REFERENCES work_orders(id),
vessel_id INTEGER REFERENCES vessels(id),
movement_type TEXT NOT NULL,
quantity REAL NOT NULL, unit_cost REAL DEFAULT 0,
notes TEXT, created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS swp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
code TEXT NOT NULL, title TEXT NOT NULL,
category TEXT NOT NULL, status TEXT DEFAULT 'active',
current_version_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS swp_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
swp_id INTEGER NOT NULL REFERENCES swp(id) ON DELETE CASCADE,
version TEXT NOT NULL, purpose TEXT, scope TEXT,
hazards TEXT, ppe TEXT, steps TEXT, emergency TEXT,
ref_standards TEXT, status TEXT DEFAULT 'draft',
change_reason TEXT, diff_summary TEXT,
created_by TEXT, approved_by TEXT, approved_at TIMESTAMP,
effective_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS swp_acknowledgements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
swp_id INTEGER NOT NULL REFERENCES swp(id),
swp_version_id INTEGER NOT NULL REFERENCES swp_versions(id),
work_order_id INTEGER NOT NULL REFERENCES work_orders(id),
technician TEXT NOT NULL, signature TEXT,
acknowledged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notes TEXT)""",
"""CREATE TABLE IF NOT EXISTS msds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
part_id INTEGER REFERENCES parts(id),
product_name TEXT NOT NULL, manufacturer TEXT,
version TEXT DEFAULT 'v1.0', hazard_class TEXT,
hazards TEXT, first_aid TEXT, ppe_required TEXT,
handling TEXT, storage TEXT, spill_procedure TEXT,
disposal TEXT, ref_standards TEXT, pdf_filename TEXT,
created_by TEXT, updated_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
'ALTER TABLE work_orders ADD COLUMN assigned_to TEXT',
'ALTER TABLE work_orders ADD COLUMN assigned_by TEXT',
'ALTER TABLE work_orders ADD COLUMN assigned_at TIMESTAMP',
"""CREATE TABLE IF NOT EXISTS wo_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER NOT NULL REFERENCES work_orders(id),
sent_to TEXT NOT NULL,
sent_by TEXT,
notification_type TEXT DEFAULT 'assignment',
message TEXT,
pdf_filename TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'sent')""",
"ALTER TABLE swp_versions ADD COLUMN tools TEXT",
"ALTER TABLE work_orders ADD COLUMN billing_type TEXT DEFAULT 'labor_materials'",
"ALTER TABLE work_order_equipment ADD COLUMN labor_hours REAL DEFAULT 0",
"ALTER TABLE work_order_equipment ADD COLUMN labor_rate REAL DEFAULT 0",
"ALTER TABLE purchases ADD COLUMN company_id INTEGER",
"ALTER TABLE purchases ADD COLUMN vessel_id INTEGER",
"ALTER TABLE purchases ADD COLUMN work_order_id INTEGER",
"ALTER TABLE purchases ADD COLUMN purchase_number TEXT",
"ALTER TABLE purchases ADD COLUMN delivery_date DATE",
"ALTER TABLE purchases ADD COLUMN received_date DATE",
"ALTER TABLE purchases ADD COLUMN status TEXT DEFAULT 'requested'",
"ALTER TABLE purchases ADD COLUMN requested_by TEXT",
"ALTER TABLE purchases ADD COLUMN approved_by TEXT",
"ALTER TABLE purchases ADD COLUMN payment_method TEXT",
"ALTER TABLE purchases ADD COLUMN payment_reference TEXT",
"ALTER TABLE purchases ADD COLUMN invoice_photo TEXT",
"ALTER TABLE purchases ADD COLUMN updated_at TIMESTAMP",
"ALTER TABLE purchase_items ADD COLUMN part_number TEXT",
"ALTER TABLE purchase_items ADD COLUMN quantity_received REAL DEFAULT 0",
"ALTER TABLE purchase_items ADD COLUMN notes TEXT",
"""CREATE TABLE IF NOT EXISTS inventory_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL REFERENCES parts(id),
movement_type TEXT NOT NULL,
quantity REAL NOT NULL,
reference_type TEXT, reference_id INTEGER,
vessel_id INTEGER REFERENCES vessels(id),
notes TEXT, created_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER REFERENCES work_orders(id),
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
to_email TEXT NOT NULL, to_name TEXT, subject TEXT,
lang TEXT DEFAULT 'es', pdf_filename TEXT,
status TEXT DEFAULT 'sent', error_msg TEXT, sent_by TEXT)""",
"ALTER TABLE work_orders ADD COLUMN signature_tech TEXT",
"""CREATE TABLE IF NOT EXISTS systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies(id),
name TEXT NOT NULL, description TEXT,
is_default INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS work_order_equipment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
equipment_id INTEGER REFERENCES vessel_equipment(id),
description TEXT, notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (1,'Propulsión',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (2,'Generación',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (3,'Navegación y Comunicaciones',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (4,'Sistema Eléctrico',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (5,'Baterías y Carga',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (6,'Hidráulico',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (7,'HVAC / Climatización',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (8,'Plomería / Agua',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (9,'Seguridad',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (10,'Casco y Estructura',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (11,'Otro',1)",
"ALTER TABLE work_orders ADD COLUMN signature_client TEXT",
"ALTER TABLE work_orders ADD COLUMN saved_pdf TEXT",
]
for sql in migrations:
try:
conn.execute(sql)
conn.commit()
except Exception:
pass
conn.close()
def allowed_file(filename, allowed):
return '.' in filename and filename.rsplit('.',1)[1].lower() in allowed
def generate_order_number():
today = date.today()
conn = get_db()
count = conn.execute(
"SELECT COUNT(*) as c FROM work_orders WHERE date(created_at)=?",
(today.isoformat(),)).fetchone()['c']
conn.close()
return f"WO-{today.strftime('%Y%m%d')}-{count+1:03d}"
# ── context processor ─────────────────────────────────────────────────────────
@app.context_processor
def inject_user():
return dict(current_user=current_user())
# ── company filter helper ─────────────────────────────────────────────────────
def cid():
"""Current user's company_id, or None if superadmin."""
u = current_user()
return u['company_id'] if u and u['role'] != 'superadmin' else None
# ── AUTH ──────────────────────────────────────────────────────────────────────
@app.route('/login', methods=['GET','POST'])
def auth_login():
if is_logged_in():
return redirect(url_for('dashboard'))
error = None
username = ''
if request.method == 'POST':
username = request.form.get('username','').strip()
password = request.form.get('password','')
conn = get_db()
user = conn.execute("""
SELECT u.*, c.name as company_name FROM users u
LEFT JOIN companies c ON u.company_id = c.id
WHERE u.username=? AND u.is_active=1
""", (username,)).fetchone()
conn.close()
if user and verify_password(password, user['password_hash']):
login_user(user)
return redirect(request.args.get('next') or url_for('dashboard'))
error = 'Usuario o contraseña incorrectos.'
return render_template('login.html', error=error, username=username)
@app.route('/logout')
def auth_logout():
logout_user()
return redirect(url_for('auth_login'))
# ── DASHBOARD ─────────────────────────────────────────────────────────────────
@app.route('/')
@login_required
def dashboard():
conn = get_db()
company_id = cid()
if company_id:
stats = {
'vessels': conn.execute("SELECT COUNT(*) as c FROM vessels WHERE company_id=?", (company_id,)).fetchone()['c'],
'open_orders': conn.execute("""SELECT COUNT(*) as c FROM work_orders wo
JOIN vessels v ON wo.vessel_id=v.id
WHERE wo.status IN ('open','in_progress') AND v.company_id=?""", (company_id,)).fetchone()['c'],
'low_stock': conn.execute("SELECT COUNT(*) as c FROM parts WHERE quantity<=min_quantity AND min_quantity>0 AND (company_id=? OR company_id IS NULL)", (company_id,)).fetchone()['c'],
'completed_this_month': conn.execute("""SELECT COUNT(*) as c FROM work_orders wo
JOIN vessels v ON wo.vessel_id=v.id
WHERE wo.status='completed' AND strftime('%Y-%m',wo.updated_at)=strftime('%Y-%m','now') AND v.company_id=?""", (company_id,)).fetchone()['c'],
}
recent_orders = conn.execute("""
SELECT wo.*, v.name as vessel_name FROM work_orders wo
JOIN vessels v ON wo.vessel_id=v.id
WHERE v.company_id=? ORDER BY wo.created_at DESC LIMIT 8""", (company_id,)).fetchall()
low_stock_parts = conn.execute("""
SELECT * FROM parts WHERE quantity<=min_quantity AND min_quantity>0
AND (company_id=? OR company_id IS NULL) ORDER BY quantity ASC LIMIT 6""", (company_id,)).fetchall()
else:
stats = {
'vessels': conn.execute("SELECT COUNT(*) as c FROM vessels").fetchone()['c'],
'open_orders': conn.execute("SELECT COUNT(*) as c FROM work_orders WHERE status IN ('open','in_progress')").fetchone()['c'],
'low_stock': conn.execute("SELECT COUNT(*) as c FROM parts WHERE quantity<=min_quantity AND min_quantity>0").fetchone()['c'],
'completed_this_month': conn.execute("SELECT COUNT(*) as c FROM work_orders WHERE status='completed' AND strftime('%Y-%m',updated_at)=strftime('%Y-%m','now')").fetchone()['c'],
}
recent_orders = conn.execute("""
SELECT wo.*, v.name as vessel_name, c.name as company_name FROM work_orders wo
JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN companies c ON v.company_id=c.id
ORDER BY wo.created_at DESC LIMIT 8""").fetchall()
low_stock_parts = conn.execute("""
SELECT * FROM parts WHERE quantity<=min_quantity AND min_quantity>0
ORDER BY quantity ASC LIMIT 6""").fetchall()
conn.close()
return render_template('dashboard.html', stats=stats, recent_orders=recent_orders,
low_stock_parts=low_stock_parts)
# ── COMPANIES ─────────────────────────────────────────────────────────────────
@app.route('/companies')
@admin_required
def companies():
conn = get_db()
companies = conn.execute("""
SELECT c.*, COUNT(v.id) as vessel_count FROM companies c
LEFT JOIN vessels v ON v.company_id=c.id GROUP BY c.id ORDER BY c.name""").fetchall()
conn.close()
return render_template('companies.html', companies=companies)
@app.route('/companies/new', methods=['GET','POST'])
@admin_required
def company_new():
if request.method == 'POST':
data = request.form
logo_path = None
if 'logo' in request.files and request.files['logo'].filename:
f = request.files['logo']
if allowed_file(f.filename, ALLOWED_IMG):
fname = f"logo_{uuid.uuid4().hex[:8]}.{f.filename.rsplit('.',1)[1].lower()}"
f.save(os.path.join(LOGO_FOLDER, fname))
logo_path = fname
conn = get_db()
conn.execute("INSERT INTO companies (name,address,phone,email,website,logo_path,notes) VALUES (?,?,?,?,?,?,?)",
(data['name'],data.get('address'),data.get('phone'),data.get('email'),
data.get('website'),logo_path,data.get('notes')))
conn.commit(); conn.close()
return redirect(url_for('companies'))
return render_template('company_form.html', company=None)
@app.route('/companies/<int:coid>/edit', methods=['GET','POST'])
@admin_required
def company_edit(coid):
conn = get_db()
company = conn.execute("SELECT * FROM companies WHERE id=?", (coid,)).fetchone()
if request.method == 'POST':
data = request.form
logo_path = company['logo_path']
if 'logo' in request.files and request.files['logo'].filename:
f = request.files['logo']
if allowed_file(f.filename, ALLOWED_IMG):
fname = f"logo_{uuid.uuid4().hex[:8]}.{f.filename.rsplit('.',1)[1].lower()}"
f.save(os.path.join(LOGO_FOLDER, fname))
logo_path = fname
conn.execute("""UPDATE companies SET name=?,address=?,phone=?,email=?,website=?,logo_path=?,notes=? WHERE id=?""",
(data['name'],data.get('address'),data.get('phone'),data.get('email'),
data.get('website'),logo_path,data.get('notes'),coid))
conn.commit(); conn.close()
return redirect(url_for('companies'))
conn.close()
return render_template('company_form.html', company=company)
# ── USERS ─────────────────────────────────────────────────────────────────────
@app.route('/users')
@admin_required
def users():
conn = get_db()
company_id = cid()
if company_id:
user_list = conn.execute("""
SELECT u.*, c.name as company_name FROM users u
LEFT JOIN companies c ON u.company_id=c.id
WHERE u.company_id=? ORDER BY u.username""", (company_id,)).fetchall()
else:
user_list = conn.execute("""
SELECT u.*, c.name as company_name FROM users u
LEFT JOIN companies c ON u.company_id=c.id ORDER BY u.username""").fetchall()
conn.close()
return render_template('users.html', users=user_list)
@app.route('/users/new', methods=['GET','POST'])
@admin_required
def user_new():
conn = get_db()
if request.method == 'POST':
data = request.form
pw = data.get('password','')
role = data.get('role','technician')
if not is_superadmin() and role == 'superadmin':
role = 'admin'
company_id_val = data.get('company_id') or cid()
conn.execute("""INSERT INTO users (company_id,username,email,password_hash,full_name,role) VALUES (?,?,?,?,?,?)""",
(company_id_val or None, data['username'], data['email'],
hash_password(pw), data.get('full_name'), role))
conn.commit(); conn.close()
return redirect(url_for('users'))
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
conn.close()
return render_template('user_form.html', user=None, companies=companies_list)
@app.route('/users/<int:uid>/edit', methods=['GET','POST'])
@admin_required
def user_edit(uid):
conn = get_db()
user = conn.execute("""
SELECT u.*, c.name as company_name FROM users u
LEFT JOIN companies c ON u.company_id=c.id WHERE u.id=?""", (uid,)).fetchone()
if request.method == 'POST':
data = request.form
pw = data.get('password','').strip()
role = data.get('role','technician')
if not is_superadmin() and role == 'superadmin':
role = user['role']
company_id_val = data.get('company_id') or user['company_id']
if pw:
conn.execute("""UPDATE users SET username=?,email=?,password_hash=?,full_name=?,role=?,company_id=?,is_active=? WHERE id=?""",
(data['username'],data['email'],hash_password(pw),data.get('full_name'),role,company_id_val or None,data.get('is_active',1),uid))
else:
conn.execute("""UPDATE users SET username=?,email=?,full_name=?,role=?,company_id=?,is_active=? WHERE id=?""",
(data['username'],data['email'],data.get('full_name'),role,company_id_val or None,data.get('is_active',1),uid))
conn.commit(); conn.close()
return redirect(url_for('users'))
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
conn.close()
return render_template('user_form.html', user=user, companies=companies_list)
# ── VESSELS ───────────────────────────────────────────────────────────────────
@app.route('/vessels')
@login_required
def vessels():
conn = get_db()
company_id = cid()
if company_id:
vessel_list = conn.execute("""
SELECT v.*, c.name as company_name,
COUNT(DISTINCT wo.id) as total_orders,
SUM(CASE WHEN wo.status IN ('open','in_progress') THEN 1 ELSE 0 END) as open_orders
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id
LEFT JOIN work_orders wo ON wo.vessel_id=v.id
WHERE v.company_id=? GROUP BY v.id ORDER BY v.name""", (company_id,)).fetchall()
else:
vessel_list = conn.execute("""
SELECT v.*, c.name as company_name,
COUNT(DISTINCT wo.id) as total_orders,
SUM(CASE WHEN wo.status IN ('open','in_progress') THEN 1 ELSE 0 END) as open_orders
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id
LEFT JOIN work_orders wo ON wo.vessel_id=v.id
GROUP BY v.id ORDER BY c.name, v.name""").fetchall()
conn.close()
return render_template('vessels.html', vessels=vessel_list)
@app.route('/vessels/new', methods=['GET','POST'])
@login_required
def vessel_new():
conn = get_db()
if request.method == 'POST':
data = request.form
company_id_val = data.get('company_id') or cid()
conn.execute("""INSERT INTO vessels (company_id,name,registration,vessel_type,make,model,year,length_ft,
flag,port_of_registry,engine_hours,owner_name,owner_phone,owner_email,
captain_name,captain_phone,captain_email,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(company_id_val or None,data['name'],data.get('registration'),data.get('vessel_type'),
data.get('make'),data.get('model'),data.get('year') or None,
data.get('length_ft') or None,data.get('flag'),data.get('port_of_registry'),
data.get('engine_hours') or 0,data.get('owner_name'),data.get('owner_phone'),
data.get('owner_email'),data.get('captain_name'),data.get('captain_phone'),
data.get('captain_email'),data.get('notes')))
conn.commit(); conn.close()
return redirect(url_for('vessels'))
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
conn.close()
return render_template('vessel_form.html', vessel=None, companies=companies_list)
@app.route('/vessels/<int:vid>/edit', methods=['GET','POST'])
@login_required
def vessel_edit(vid):
conn = get_db()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone()
if request.method == 'POST':
data = request.form
company_id_val = data.get('company_id') or vessel['company_id']
conn.execute("""UPDATE vessels SET company_id=?,name=?,registration=?,vessel_type=?,make=?,model=?,year=?,
length_ft=?,flag=?,port_of_registry=?,owner_name=?,owner_phone=?,owner_email=?,
captain_name=?,captain_phone=?,captain_email=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(company_id_val or None,data['name'],data.get('registration'),data.get('vessel_type'),
data.get('make'),data.get('model'),data.get('year') or None,
data.get('length_ft') or None,data.get('flag'),data.get('port_of_registry'),
data.get('owner_name'),data.get('owner_phone'),data.get('owner_email'),
data.get('captain_name'),data.get('captain_phone'),data.get('captain_email'),
data.get('notes'),vid))
conn.commit(); conn.close()
return redirect(url_for('vessel_history', vid=vid))
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
conn.close()
return render_template('vessel_form.html', vessel=vessel, companies=companies_list)
@app.route('/vessels/<int:vid>')
@login_required
def vessel_detail(vid):
return redirect(url_for('vessel_history', vid=vid))
@app.route('/vessels/<int:vid>/history')
@login_required
def vessel_history(vid):
conn = get_db()
vessel = conn.execute("""
SELECT v.*, c.name as company_name, c.logo_path as company_logo
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id WHERE v.id=?""", (vid,)).fetchone()
orders = conn.execute("""
SELECT wo.*, ve.name as equipment_name, s.name as system_name,
COALESCE(wo.total_labor_cost, wo.labor_hours*wo.labor_rate, 0) as calc_labor_cost,
(SELECT COUNT(*) FROM work_order_photos WHERE work_order_id=wo.id) as photo_count
FROM work_orders wo
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id
LEFT JOIN systems s ON wo.system_id=s.id
WHERE wo.vessel_id=? ORDER BY wo.start_date DESC, wo.created_at DESC""", (vid,)).fetchall()
equipment = conn.execute("""
SELECT * FROM vessel_equipment WHERE vessel_id=? AND is_active=1
ORDER BY equipment_type, name""", (vid,)).fetchall()
documents = conn.execute("""
SELECT vd.*, ve.name as equipment_name FROM vessel_documents vd
LEFT JOIN vessel_equipment ve ON vd.equipment_id=ve.id
WHERE vd.vessel_id=? ORDER BY vd.created_at DESC""", (vid,)).fetchall()
total_cost = sum(float(o['calc_labor_cost'] or 0) + float(o['total_parts_cost'] or 0) for o in orders)
conn.close()
return render_template('vessel_history.html', vessel=vessel, orders=orders,
equipment=equipment, documents=documents, total_cost=total_cost)
# ── EQUIPMENT ─────────────────────────────────────────────────────────────────
@app.route('/vessels/<int:vid>/equipment/new', methods=['GET','POST'])
@login_required
def equipment_new(vid):
conn = get_db()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone()
if request.method == 'POST':
data = request.form
conn.execute("""INSERT INTO vessel_equipment
(vessel_id,name,equipment_type,make,model,serial_number,year,position,
engine_hours,last_service_date,last_service_hours,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(vid,data['name'],data.get('equipment_type'),data.get('make'),data.get('model'),
data.get('serial_number'),data.get('year') or None,data.get('position'),
data.get('engine_hours') or 0,data.get('last_service_date') or None,
data.get('last_service_hours') or None,data.get('notes')))
conn.commit(); conn.close()
return redirect(url_for('vessel_history', vid=vid))
conn.close()
return render_template('equipment_form.html', vessel=vessel, equip=None)
@app.route('/vessels/<int:vid>/equipment/<int:eid>/edit', methods=['GET','POST'])
@login_required
def equipment_edit(vid, eid):
conn = get_db()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone()
equip = conn.execute("SELECT * FROM vessel_equipment WHERE id=?", (eid,)).fetchone()
if request.method == 'POST':
data = request.form
conn.execute("""UPDATE vessel_equipment SET name=?,equipment_type=?,make=?,model=?,
serial_number=?,year=?,position=?,engine_hours=?,last_service_date=?,
last_service_hours=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(data['name'],data.get('equipment_type'),data.get('make'),data.get('model'),
data.get('serial_number'),data.get('year') or None,data.get('position'),
data.get('engine_hours') or 0,data.get('last_service_date') or None,
data.get('last_service_hours') or None,data.get('notes'),eid))
conn.commit(); conn.close()
return redirect(url_for('vessel_history', vid=vid))
conn.close()
return render_template('equipment_form.html', vessel=vessel, equip=equip)
# ── DOCUMENTS ────────────────────────────────────────────────────────────────
@app.route('/vessels/<int:vid>/documents/upload', methods=['POST'])
@login_required
def document_upload(vid):
if 'file' not in request.files or not request.files['file'].filename:
return jsonify({'error': 'No file'}), 400
f = request.files['file']
if not allowed_file(f.filename, ALLOWED_DOCS):
return jsonify({'error': 'Tipo de archivo no permitido'}), 400
ext = f.filename.rsplit('.',1)[1].lower()
fname = f"doc_{vid}_{uuid.uuid4().hex[:10]}.{ext}"
fpath = os.path.join(DOCS_FOLDER, fname)
f.save(fpath)
fsize = os.path.getsize(fpath)
u = current_user()
conn = get_db()
conn.execute("""INSERT INTO vessel_documents
(vessel_id,equipment_id,doc_type,title,description,filename,original_filename,file_size,uploaded_by)
VALUES (?,?,?,?,?,?,?,?,?)""",
(vid, request.form.get('equipment_id') or None,
request.form.get('doc_type','other'),
request.form.get('title') or f.filename,
request.form.get('description',''),
fname, f.filename, fsize, u['full_name'] if u else ''))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/documents/<int:doc_id>/download')
@login_required
def document_download(doc_id):
conn = get_db()
doc = conn.execute("SELECT * FROM vessel_documents WHERE id=?", (doc_id,)).fetchone()
conn.close()
if not doc:
return "Not found", 404
fpath = os.path.join(DOCS_FOLDER, doc['filename'])
return send_file(fpath, as_attachment=True, download_name=doc['original_filename'] or doc['filename'])
@app.route('/documents/<int:doc_id>/delete', methods=['DELETE'])
@login_required
def document_delete(doc_id):
conn = get_db()
doc = conn.execute("SELECT * FROM vessel_documents WHERE id=?", (doc_id,)).fetchone()
if doc:
fpath = os.path.join(DOCS_FOLDER, doc['filename'])
if os.path.exists(fpath): os.remove(fpath)
conn.execute("DELETE FROM vessel_documents WHERE id=?", (doc_id,))
conn.commit()
conn.close()
return jsonify({'ok': True})
# ── WORK ORDERS ───────────────────────────────────────────────────────────────
@app.route('/work-orders')
@login_required
def work_orders():
status = request.args.get('status','')
conn = get_db()
company_id = cid()
base = """SELECT wo.*, v.name as vessel_name, v.id as vessel_id, ve.name as equipment_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id"""
conditions = []
params = []
if company_id:
conditions.append("v.company_id=?"); params.append(company_id)
if status:
conditions.append("wo.status=?"); params.append(status)
if conditions:
base += " WHERE " + " AND ".join(conditions)
base += " ORDER BY wo.created_at DESC"
orders = conn.execute(base, params).fetchall()
conn.close()
return render_template('work_orders.html', orders=orders, status_filter=status)
@app.route('/work-orders/new', methods=['GET','POST'])
@login_required
def work_order_new():
conn = get_db()
company_id = cid()
if request.method == 'POST':
data = request.form
order_num = generate_order_number()
conn.execute("""INSERT INTO work_orders
(vessel_id,equipment_id,system_id,order_number,status,work_type,scope,description,
technician,start_date,engine_hours_start,labor_rate,billing_type,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(data['vessel_id'], data.get('equipment_id') or None,
data.get('system_id') or None,
order_num,'open',data.get('work_type'),
data.get('scope',''),data.get('description',''),
data.get('technician'),data.get('start_date'),
data.get('engine_hours_start') or None,
data.get('labor_rate') or 0,
data.get('billing_type','labor_materials'),
data.get('notes')))
conn.commit()
woid = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
conn.close()
return redirect(url_for('work_order_detail', woid=woid))
if company_id:
vessel_list = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall()
else:
vessel_list = conn.execute("""SELECT v.id, v.name || ' (' || COALESCE(c.name,'Sin compañía') || ')' as name
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id ORDER BY c.name,v.name""").fetchall()
preselect_vessel = request.args.get('vessel')
equipment_list = []
if preselect_vessel:
equipment_list = conn.execute(
"SELECT id,name,serial_number FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name",
(preselect_vessel,)).fetchall()
conn.close()
return render_template('work_order_form.html', order=None, vessels=vessel_list,
equipment_list=equipment_list, preselect_vessel=preselect_vessel)
@app.route('/work-orders/<int:woid>/delete', methods=['DELETE'])
@login_required
def work_order_delete(woid):
conn = get_db()
try:
order = conn.execute("SELECT status FROM work_orders WHERE id=?", (woid,)).fetchone()
if not order:
return jsonify({'ok': False, 'error': 'No encontrada'}), 404
if order['status'] == 'completed':
return jsonify({'ok': False, 'error': 'No se puede eliminar una orden completada'}), 403
# Delete photos from disk
photos = conn.execute("SELECT filename FROM work_order_photos WHERE work_order_id=?", (woid,)).fetchall()
for p in photos:
fp = os.path.join(UPLOAD_FOLDER, p['filename'])
if os.path.exists(fp): os.remove(fp)
# Delete signatures
sigs = conn.execute("SELECT signature_tech, signature_client FROM work_orders WHERE id=?", (woid,)).fetchone()
for sig in [sigs['signature_tech'], sigs['signature_client']]:
if sig:
fp = os.path.join(SIG_FOLDER, sig)
if os.path.exists(fp): os.remove(fp)
conn.execute("DELETE FROM work_orders WHERE id=?", (woid,))
conn.commit(); conn.close()
return jsonify({'ok': True})
except Exception as e:
conn.close()
return jsonify({'ok': False, 'error': str(e)}), 500
@app.route('/work-orders/<int:woid>/edit', methods=['GET','POST'])
@login_required
def work_order_edit(woid):
conn = get_db()
order = conn.execute("""
SELECT wo.*, v.name as vessel_name FROM work_orders wo
JOIN vessels v ON wo.vessel_id=v.id WHERE wo.id=?""", (woid,)).fetchone()
if order['status'] == 'completed':
conn.close()
return redirect(url_for('work_order_detail', woid=woid))
if request.method == 'POST':
data = request.form
conn.execute("""UPDATE work_orders SET
system_id=?, work_type=?, scope=?, description=?,
technician=?, start_date=?, engine_hours_start=?,
labor_rate=?, billing_type=?, notes=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(data.get('system_id') or None, data.get('work_type'),
data.get('scope',''), data.get('description',''),
data.get('technician'), data.get('start_date'),
data.get('engine_hours_start') or None,
data.get('labor_rate') or 0,
data.get('billing_type','labor_materials'),
data.get('notes'), woid))
conn.commit(); conn.close()
return redirect(url_for('work_order_detail', woid=woid))
company_id = cid()
if company_id:
vessels = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall()
else:
vessels = conn.execute("SELECT id,name FROM vessels ORDER BY name").fetchall()
systems = conn.execute("SELECT * FROM systems ORDER BY is_default DESC, name").fetchall()
conn.close()
return render_template('work_order_edit.html', order=order, vessels=vessels, systems=systems)
@app.route('/work-orders/<int:woid>')
@login_required
def work_order_detail(woid):
conn = get_db()
order = conn.execute("""
SELECT wo.*, v.name as vessel_name, ve.name as equipment_name,
ve.make as equip_make, ve.model as equip_model, ve.serial_number as equip_serial,
s.name as system_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id
LEFT JOIN systems s ON wo.system_id=s.id
WHERE wo.id=?""", (woid,)).fetchone()
vessel_row = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
conn2_data = dict(vessel_row) if vessel_row else {}
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type,taken_at", (woid,)).fetchall()
parts_used = conn.execute("""SELECT wop.*, p.name as part_name, p.part_number FROM work_order_parts wop
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
available_parts = conn.execute("SELECT id,name,part_number,quantity,unit,sale_price FROM parts ORDER BY name").fetchall()
wo_equipment = conn.execute("""
SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model,
(woe.labor_hours * woe.labor_rate) as labor_cost
FROM work_order_equipment woe
LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
WHERE woe.work_order_id=? ORDER BY woe.created_at
""", (woid,)).fetchall()
vessel_equipment = conn.execute(
"SELECT * FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name",
(order['vessel_id'],)).fetchall()
email_log = conn.execute("""
SELECT * FROM email_log WHERE work_order_id=? ORDER BY sent_at DESC
""", (woid,)).fetchall()
company_id = cid()
users = conn.execute("""SELECT username, full_name, email FROM users
WHERE company_id=? AND email IS NOT NULL AND email != '' AND is_active=1
ORDER BY full_name""", (company_id,)).fetchall() if company_id else []
conn.close()
return render_template('work_order_detail.html', order=order, photos=photos,
parts_used=parts_used, available_parts=available_parts,
wo_equipment=wo_equipment, vessel_equipment=vessel_equipment,
email_log=email_log, users=users,
vessel_captain_email=conn2_data.get('captain_email',''),
vessel_captain_name=conn2_data.get('captain_name',''),
vessel_owner_email=conn2_data.get('owner_email',''),
vessel_owner_name=conn2_data.get('owner_name',''))
@app.route('/work-orders/<int:woid>/update-status', methods=['POST'])
@login_required
def update_status(woid):
status = request.json.get('status')
conn = get_db()
extra = ", end_date=date('now')" if status=='completed' else ""
conn.execute(f"UPDATE work_orders SET status=?,updated_at=CURRENT_TIMESTAMP{extra} WHERE id=?", (status,woid))
conn.commit(); conn.close()
# Auto-save PDF when completed
if status == 'completed':
try:
from report_generator import generate_work_order_pdf
conn2 = get_db()
order = conn2.execute("""
SELECT wo.*, v.name as vessel_name, c.name as company_name,
c.logo_path, c.phone as company_phone,
c.email as company_email, c.address as company_address,
s.name as system_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN companies c ON v.company_id=c.id
LEFT JOIN systems s ON wo.system_id=s.id
WHERE wo.id=?""", (woid,)).fetchone()
vessel = conn2.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
photos = conn2.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall()
parts = conn2.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
wo_equip = conn2.execute("""SELECT woe.*,ve.name as equip_name,ve.serial_number,ve.make,ve.model
FROM work_order_equipment woe LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
WHERE woe.work_order_id=?""", (woid,)).fetchall()
conn2.close()
logo_path = None
if order['logo_path']:
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
if os.path.exists(lp): logo_path = lp
company_info = ' | '.join(filter(None, [
order['company_address'] or '', order['company_phone'] or '', order['company_email'] or ''
]))
pdf_buf = generate_work_order_pdf(
order=dict(order), vessel=dict(vessel),
photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts],
wo_equipment=[dict(e) for e in wo_equip],
upload_folder=UPLOAD_FOLDER, sig_folder=SIG_FOLDER,
company_name=order['company_name'] or 'Marine Maintenance Pro',
company_logo=logo_path, company_info=company_info, lang='es')
fname = f"{order['order_number'].replace('/','_')}_FINAL.pdf"
fpath = os.path.join(PDF_FOLDER, fname)
with open(fpath, 'wb') as f:
f.write(pdf_buf.read())
conn3 = get_db()
conn3.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid))
conn3.commit(); conn3.close()
except Exception as e:
print(f"[auto-pdf] Error: {e}")
return jsonify({'ok': True})
@app.route('/work-orders/<int:woid>/update-fields', methods=['POST'])
@login_required
def update_fields(woid):
data = request.json
conn = get_db()
conn.execute("""UPDATE work_orders SET root_cause=?,repairs_done=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(data.get('root_cause',''),data.get('repairs_done',''),woid))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/api/update-labor/<int:woid>', methods=['POST'])
@login_required
def update_labor(woid):
data = request.json
conn = get_db()
conn.execute("""UPDATE work_orders SET labor_hours=?, labor_rate=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(float(data.get('labor_hours', 0)),
float(data.get('labor_rate', 0)), woid))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/work-orders/<int:woid>/upload-photo', methods=['POST'])
@login_required
def upload_photo(woid):
if 'photo' not in request.files: return jsonify({'error':'No file'}),400
f = request.files['photo']
if f and allowed_file(f.filename, ALLOWED_IMG):
ext = f.filename.rsplit('.',1)[1].lower()
fname = f"{woid}_{request.form.get('photo_type','before')}_{uuid.uuid4().hex[:8]}.{ext}"
f.save(os.path.join(UPLOAD_FOLDER, fname))
conn = get_db()
conn.execute("INSERT INTO work_order_photos (work_order_id,photo_type,filename,caption) VALUES (?,?,?,?)",
(woid,request.form.get('photo_type','before'),fname,request.form.get('caption','')))
conn.commit(); conn.close()
return jsonify({'ok':True,'filename':fname})
return jsonify({'error':'Invalid file'}),400
@app.route('/work-orders/<int:woid>/add-part', methods=['POST'])
@login_required
def add_part_to_order(woid):
data = request.json
part_id = data.get('part_id')
qty = float(data.get('quantity',1))
cost = float(data.get('unit_cost',0))
conn = get_db()
conn.execute("INSERT INTO work_order_parts (work_order_id,part_id,description,quantity,unit_cost) VALUES (?,?,?,?,?)",
(woid,part_id or None,data.get('description',''),qty,cost))
if part_id:
conn.execute("UPDATE parts SET quantity=quantity-?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (qty, part_id))
# Log movement
u = current_user()
order_info = conn.execute("SELECT vessel_id, order_number FROM work_orders WHERE id=?", (woid,)).fetchone()
conn.execute("""INSERT INTO inventory_movements
(part_id,movement_type,quantity,reference_type,reference_id,vessel_id,notes,created_by)
VALUES (?,?,?,?,?,?,?,?)""",
(part_id, 'out', -qty, 'work_order', woid,
order_info['vessel_id'] if order_info else None,
f"Usado en {order_info['order_number'] if order_info else 'WO'}",
u['full_name'] if u else ''))
conn.execute("""UPDATE work_orders SET total_parts_cost=(
SELECT COALESCE(SUM(total_cost),0) FROM work_order_parts WHERE work_order_id=?
),updated_at=CURRENT_TIMESTAMP WHERE id=?""", (woid,woid))
conn.commit(); conn.close()
return jsonify({'ok':True})
@app.route('/api/delete-photo/<int:photo_id>', methods=['DELETE'])
@login_required
def delete_photo(photo_id):
conn = get_db()
photo = conn.execute("SELECT * FROM work_order_photos WHERE id=?", (photo_id,)).fetchone()
if photo:
fp = os.path.join(UPLOAD_FOLDER, photo['filename'])
if os.path.exists(fp): os.remove(fp)
conn.execute("DELETE FROM work_order_photos WHERE id=?", (photo_id,))
conn.commit()
conn.close()
return jsonify({'ok':True})
@app.route('/api/update-photo-caption/<int:photo_id>', methods=['POST'])
@login_required
def update_photo_caption(photo_id):
caption = request.json.get('caption', '')
conn = get_db()
conn.execute("UPDATE work_order_photos SET caption=? WHERE id=?", (caption, photo_id))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/work-orders/<int:woid>/pdf')
@login_required
def work_order_pdf(woid):
from report_generator import generate_work_order_pdf
lang = request.args.get('lang', 'es')
conn = get_db()
order = conn.execute("""
SELECT wo.*, v.name as vessel_name, c.name as company_name,
c.logo_path, c.phone as company_phone,
c.email as company_email, c.address as company_address,
ve.name as equip_name, ve.make as equip_make,
ve.model as equip_model, ve.serial_number as equip_serial,
s.name as system_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN companies c ON v.company_id=c.id
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id
LEFT JOIN systems s ON wo.system_id=s.id
WHERE wo.id=?""", (woid,)).fetchone()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type,taken_at", (woid,)).fetchall()
parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
wo_equip = conn.execute("""
SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model
FROM work_order_equipment woe
LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
WHERE woe.work_order_id=? ORDER BY woe.created_at""", (woid,)).fetchall()
conn.close()
logo_path = None
if order['logo_path']:
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
if os.path.exists(lp): logo_path = lp
company_info = ' | '.join(filter(None, [
order['company_address'] or '', order['company_phone'] or '', order['company_email'] or ''
]))
buf = generate_work_order_pdf(
order=dict(order), vessel=dict(vessel),
photos=[dict(p) for p in photos],
parts_used=[dict(p) for p in parts],
wo_equipment=[dict(e) for e in wo_equip],
upload_folder=UPLOAD_FOLDER,
sig_folder=SIG_FOLDER,
company_name=order['company_name'] or 'Marine Maintenance Pro',
company_logo=logo_path,
company_info=company_info,
lang=lang)
return send_file(buf, mimetype='application/pdf',
as_attachment=False, download_name=f"{order['order_number']}.pdf")
# ── INVENTORY ─────────────────────────────────────────────────────────────────
@app.route('/inventory')
@login_required
def inventory():
conn = get_db()
company_id = cid()
if company_id:
parts = conn.execute("""SELECT p.*,pc.name as category_name FROM parts p
LEFT JOIN parts_categories pc ON p.category_id=pc.id
WHERE p.company_id=? OR p.company_id IS NULL ORDER BY pc.name,p.name""", (company_id,)).fetchall()
else:
parts = conn.execute("""SELECT p.*,pc.name as category_name FROM parts p
LEFT JOIN parts_categories pc ON p.category_id=pc.id ORDER BY pc.name,p.name""").fetchall()
categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall()
conn.close()
return render_template('inventory.html', parts=parts, categories=categories)
@app.route('/inventory/new', methods=['GET','POST'])
@login_required
def part_new():
conn = get_db()
if request.method == 'POST':
data = request.form
company_id_val = cid()
conn.execute("""INSERT INTO parts (company_id,category_id,part_number,name,description,brand,
location,quantity,unit,min_quantity,cost_price,sale_price,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(company_id_val,data.get('category_id') or None,data.get('part_number'),
data['name'],data.get('description'),data.get('brand'),data.get('location'),
data.get('quantity') or 0,data.get('unit','pcs'),data.get('min_quantity') or 0,
data.get('cost_price') or 0,data.get('sale_price') or 0,data.get('notes')))
conn.commit(); conn.close()
return redirect(url_for('inventory'))
categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall()
conn.close()
return render_template('part_form.html', part=None, categories=categories)
@app.route('/inventory/<int:pid>/edit', methods=['GET','POST'])
@login_required
def part_edit(pid):
conn = get_db()
part = conn.execute("SELECT * FROM parts WHERE id=?", (pid,)).fetchone()
if request.method == 'POST':
data = request.form
conn.execute("""UPDATE parts SET category_id=?,part_number=?,name=?,description=?,brand=?,
location=?,quantity=?,unit=?,min_quantity=?,cost_price=?,sale_price=?,
notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(data.get('category_id') or None,data.get('part_number'),data['name'],
data.get('description'),data.get('brand'),data.get('location'),
data.get('quantity') or 0,data.get('unit','pcs'),data.get('min_quantity') or 0,
data.get('cost_price') or 0,data.get('sale_price') or 0,data.get('notes'),pid))
conn.commit(); conn.close()
return redirect(url_for('inventory'))
categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall()
conn.close()
return render_template('part_form.html', part=part, categories=categories)
# ── PURCHASES ─────────────────────────────────────────────────────────────────
PURCHASE_STATUSES = {
'requested': ('Solicitada', 'var(--gray)'),
'approved': ('Aprobada', 'var(--cyan)'),
'ordered': ('Ordenada', '#f4a261'),
'received': ('Recibida', '#2ec4b6'),
'paid': ('Pagada', '#2ec4b6'),
'cancelled': ('Cancelada', 'var(--danger)'),
}
@app.route('/purchases')
@login_required
def purchases():
conn = get_db()
company_id = cid()
base = """SELECT pu.*, s.name as supplier_name,
v.name as vessel_name, wo.order_number as wo_number
FROM purchases pu
LEFT JOIN suppliers s ON pu.supplier_id=s.id
LEFT JOIN vessels v ON pu.vessel_id=v.id
LEFT JOIN work_orders wo ON pu.work_order_id=wo.id"""
if company_id:
purchases_list = conn.execute(base + " WHERE pu.company_id=? ORDER BY pu.purchase_date DESC", (company_id,)).fetchall()
else:
purchases_list = conn.execute(base + " ORDER BY pu.purchase_date DESC").fetchall()
conn.close()
return render_template('purchases.html', purchases=purchases_list, statuses=PURCHASE_STATUSES)
@app.route('/purchases/new', methods=['GET','POST'])
@login_required
def purchase_new():
conn = get_db()
company_id = cid()
if request.method == 'POST':
data = request.form
# Handle invoice photo upload
invoice_photo = None
if 'invoice_photo' in request.files:
f = request.files['invoice_photo']
if f and f.filename:
ext = f.filename.rsplit('.',1)[-1].lower()
fname = f"invoice_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"
f.save(os.path.join(UPLOAD_FOLDER, fname))
invoice_photo = fname
u = current_user()
conn.execute("""INSERT INTO purchases
(company_id,supplier_id,vessel_id,work_order_id,purchase_number,invoice_number,
purchase_date,delivery_date,status,requested_by,payment_method,notes,invoice_photo)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(company_id, data.get('supplier_id') or None,
data.get('vessel_id') or None, data.get('work_order_id') or None,
data.get('purchase_number'), data.get('invoice_number'),
data['purchase_date'], data.get('delivery_date') or None,
data.get('status','requested'),
data.get('requested_by') or (u['full_name'] if u else ''),
data.get('payment_method'), data.get('notes'), invoice_photo))
conn.commit()
pid = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
conn.close()
return redirect(url_for('purchase_detail', pid=pid))
suppliers = conn.execute("SELECT * FROM suppliers ORDER BY name").fetchall()
if company_id:
vessels = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall()
work_orders = conn.execute("""SELECT wo.id, wo.order_number, v.name as vessel_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
WHERE v.company_id=? AND wo.status!='cancelled' ORDER BY wo.created_at DESC""", (company_id,)).fetchall()
else:
vessels = conn.execute("SELECT id,name FROM vessels ORDER BY name").fetchall()
work_orders = conn.execute("""SELECT wo.id, wo.order_number, v.name as vessel_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
WHERE wo.status!='cancelled' ORDER BY wo.created_at DESC""").fetchall()
u = current_user()
conn.close()
return render_template('purchase_form.html', suppliers=suppliers, vessels=vessels,
work_orders=work_orders, statuses=PURCHASE_STATUSES,
default_requester=u['full_name'] if u else '')
@app.route('/purchases/<int:pid>')
@login_required
def purchase_detail(pid):
conn = get_db()
purchase = conn.execute("""SELECT pu.*,s.name as supplier_name,
v.name as vessel_name, wo.order_number as wo_number
FROM purchases pu LEFT JOIN suppliers s ON pu.supplier_id=s.id
LEFT JOIN vessels v ON pu.vessel_id=v.id
LEFT JOIN work_orders wo ON pu.work_order_id=wo.id
WHERE pu.id=?""", (pid,)).fetchone()
items = conn.execute("""SELECT pi.*,p.name as part_name,p.part_number as catalog_number
FROM purchase_items pi LEFT JOIN parts p ON pi.part_id=p.id
WHERE pi.purchase_id=?""", (pid,)).fetchall()
parts = conn.execute("SELECT id,name,part_number FROM parts ORDER BY name").fetchall()
conn.close()
return render_template('purchase_detail.html', purchase=purchase, items=items,
parts=parts, statuses=PURCHASE_STATUSES)
@app.route('/purchases/<int:pid>/update-status', methods=['POST'])
@login_required
def purchase_update_status(pid):
data = request.json
new_status = data.get('status')
conn = get_db()
extra_fields = ""
extra_vals = []
u = current_user()
if new_status == 'approved':
extra_fields = ", approved_by=?"
extra_vals.append(u['full_name'] if u else '')
elif new_status == 'received':
extra_fields = ", received_date=date('now')"
conn.execute(f"UPDATE purchases SET status=?,updated_at=CURRENT_TIMESTAMP{extra_fields} WHERE id=?",
([new_status] + extra_vals + [pid]))
# If received — update inventory
if new_status == 'received':
items = conn.execute("SELECT * FROM purchase_items WHERE purchase_id=?", (pid,)).fetchall()
purchase = conn.execute("SELECT * FROM purchases WHERE id=?", (pid,)).fetchone()
for item in items:
if item['part_id']:
qty = float(item['quantity_received'] or item['quantity'])
conn.execute("UPDATE parts SET quantity=quantity+?,updated_at=CURRENT_TIMESTAMP WHERE id=?",
(qty, item['part_id']))
conn.execute("""INSERT INTO inventory_movements
(part_id,movement_type,quantity,reference_type,reference_id,vessel_id,notes,created_by)
VALUES (?,?,?,?,?,?,?,?)""",
(item['part_id'], 'in', qty, 'purchase', pid,
purchase['vessel_id'] if purchase else None,
f"Compra #{purchase['purchase_number'] or pid}",
u['full_name'] if u else ''))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/purchases/<int:pid>/upload-invoice', methods=['POST'])
@login_required
def upload_invoice(pid):
f = request.files.get('invoice')
if not f or not f.filename:
return jsonify({'ok': False, 'error': 'No file'})
ext = f.filename.rsplit('.',1)[-1].lower()
fname = f"invoice_{pid}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"
f.save(os.path.join(UPLOAD_FOLDER, fname))
conn = get_db()
conn.execute("UPDATE purchases SET invoice_photo=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (fname, pid))
conn.commit(); conn.close()
return jsonify({'ok': True, 'filename': fname})
@app.route('/purchases/<int:pid>/add-item', methods=['POST'])
@login_required
def add_purchase_item(pid):
data = request.json
part_id = data.get('part_id')
qty = float(data.get('quantity', 1))
cost = float(data.get('unit_cost', 0))
conn = get_db()
conn.execute("""INSERT INTO purchase_items
(purchase_id,part_id,description,part_number,quantity,unit_cost,quantity_received,notes)
VALUES (?,?,?,?,?,?,?,?)""",
(pid, part_id or None, data.get('description',''),
data.get('part_number',''), qty, cost, qty, data.get('notes','')))
conn.execute("""UPDATE purchases SET
total_amount=(SELECT COALESCE(SUM(total_cost),0) FROM purchase_items WHERE purchase_id=?),
updated_at=CURRENT_TIMESTAMP WHERE id=?""", (pid, pid))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/purchases/<int:pid>/delete-item/<int:iid>', methods=['DELETE'])
@login_required
def delete_purchase_item(pid, iid):
conn = get_db()
conn.execute("DELETE FROM purchase_items WHERE id=? AND purchase_id=?", (iid, pid))
conn.execute("""UPDATE purchases SET
total_amount=(SELECT COALESCE(SUM(total_cost),0) FROM purchase_items WHERE purchase_id=?),
updated_at=CURRENT_TIMESTAMP WHERE id=?""", (pid, pid))
conn.commit(); conn.close()
return jsonify({'ok': True})
# ── SUPPLIERS ─────────────────────────────────────────────────────────────────
@app.route('/suppliers')
@login_required
def suppliers():
conn = get_db()
suppliers = conn.execute("SELECT * FROM suppliers ORDER BY name").fetchall()
conn.close()
return render_template('suppliers.html', suppliers=suppliers)
@app.route('/suppliers/new', methods=['GET','POST'])
@login_required
def supplier_new():
if request.method == 'POST':
data = request.form
conn = get_db()
conn.execute("INSERT INTO suppliers (company_id,name,contact_name,phone,email,address,notes) VALUES (?,?,?,?,?,?,?)",
(cid(),data['name'],data.get('contact_name'),data.get('phone'),data.get('email'),data.get('address'),data.get('notes')))
conn.commit(); conn.close()
return redirect(url_for('suppliers'))
return render_template('supplier_form.html', supplier=None)
@app.route('/suppliers/<int:sid>/edit', methods=['GET','POST'])
@login_required
def supplier_edit(sid):
conn = get_db()
supplier = conn.execute("SELECT * FROM suppliers WHERE id=?", (sid,)).fetchone()
if request.method == 'POST':
data = request.form
conn.execute("""UPDATE suppliers SET name=?,contact_name=?,phone=?,email=?,
address=?,notes=? WHERE id=?""",
(data['name'],data.get('contact_name'),data.get('phone'),
data.get('email'),data.get('address'),data.get('notes'),sid))
conn.commit(); conn.close()
return redirect(url_for('suppliers'))
conn.close()
return render_template('supplier_form.html', supplier=supplier)
@app.route('/suppliers/<int:sid>/delete', methods=['DELETE'])
@login_required
def supplier_delete(sid):
conn = get_db()
try:
conn.execute("DELETE FROM suppliers WHERE id=?", (sid,))
conn.commit(); conn.close()
return jsonify({'ok': True})
except Exception as e:
conn.close()
return jsonify({'ok': False, 'error': str(e)}), 500
# ── API MISC ──────────────────────────────────────────────────────────────────
@app.route('/api/parts/<int:pid>')
@login_required
def api_part(pid):
conn = get_db()
part = conn.execute("SELECT * FROM parts WHERE id=?", (pid,)).fetchone()
conn.close()
return jsonify(dict(part)) if part else (jsonify({}), 404)
@app.route('/api/vessel-equipment/<int:vid>')
@login_required
def api_vessel_equipment(vid):
conn = get_db()
equip = conn.execute(
"SELECT id,name,serial_number,make,model FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name",
(vid,)).fetchall()
conn.close()
return jsonify([dict(e) for e in equip])
@app.route('/setup/migrate')
@login_required
def force_migrate():
if not is_admin():
return redirect(url_for('dashboard'))
conn = get_db()
results = []
sqls = [
"""CREATE TABLE IF NOT EXISTS systems (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id INTEGER REFERENCES companies(id), name TEXT NOT NULL, description TEXT, is_default INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS work_order_equipment (id INTEGER PRIMARY KEY AUTOINCREMENT, work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, equipment_id INTEGER REFERENCES vessel_equipment(id), description TEXT, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"ALTER TABLE work_orders ADD COLUMN system_id INTEGER",
"ALTER TABLE work_orders ADD COLUMN scope TEXT",
"ALTER TABLE work_orders ADD COLUMN root_cause TEXT",
"ALTER TABLE work_orders ADD COLUMN repairs_done TEXT",
"ALTER TABLE work_orders ADD COLUMN signature_tech TEXT",
"ALTER TABLE work_orders ADD COLUMN signature_client TEXT",
"ALTER TABLE work_orders ADD COLUMN saved_pdf TEXT",
"ALTER TABLE vessels ADD COLUMN company_id INTEGER",
"ALTER TABLE vessels ADD COLUMN engine_hours REAL DEFAULT 0",
"ALTER TABLE vessels ADD COLUMN captain_name TEXT",
"ALTER TABLE vessels ADD COLUMN captain_phone TEXT",
"ALTER TABLE vessels ADD COLUMN captain_email TEXT",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (1,'Propulsion',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (2,'Generation',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (3,'Navigation and Communications',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (4,'Electrical System',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (5,'Batteries and Charging',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (6,'Hydraulic',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (7,'HVAC',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (8,'Plumbing and Water',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (9,'Safety',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (10,'Hull and Structure',1)",
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (11,'Other',1)",
]
for sql in sqls:
try:
conn.execute(sql)
conn.commit()
results.append('OK: ' + sql[:60])
except Exception as e:
results.append('Skip: ' + str(e)[:60])
conn.close()
return '<br>'.join(results) + '<br><br><a href="/">Back to Dashboard</a>'
# ── SYSTEMS ───────────────────────────────────────────────────────────────────
@app.route('/systems')
@login_required
def systems():
conn = get_db()
company_id = cid()
sys_list = conn.execute("""
SELECT * FROM systems
WHERE is_default=1 OR company_id IS ? OR company_id=?
ORDER BY is_default DESC, name
""", (None if not company_id else company_id, company_id or -1)).fetchall()
conn.close()
return render_template('systems.html', systems=sys_list)
@app.route('/systems/new', methods=['GET','POST'])
@login_required
def system_new():
if request.method == 'POST':
data = request.form
conn = get_db()
conn.execute("INSERT INTO systems (company_id, name, description) VALUES (?,?,?)",
(cid(), data['name'], data.get('description','')))
conn.commit(); conn.close()
return redirect(url_for('systems'))
return render_template('system_form.html', system=None)
@app.route('/systems/<int:sid>/edit', methods=['GET','POST'])
@login_required
def system_edit(sid):
conn = get_db()
system = conn.execute("SELECT * FROM systems WHERE id=?", (sid,)).fetchone()
if request.method == 'POST':
data = request.form
conn.execute("UPDATE systems SET name=?, description=? WHERE id=? AND is_default=0",
(data['name'], data.get('description',''), sid))
conn.commit(); conn.close()
return redirect(url_for('systems'))
conn.close()
return render_template('system_form.html', system=system)
@app.route('/systems/<int:sid>/delete', methods=['DELETE'])
@login_required
def system_delete(sid):
conn = get_db()
conn.execute("DELETE FROM systems WHERE id=? AND is_default=0", (sid,))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/api/systems')
@login_required
def api_systems():
conn = get_db()
company_id = cid()
sys_list = conn.execute("""
SELECT * FROM systems
WHERE is_default=1 OR company_id IS NULL OR company_id=?
ORDER BY is_default DESC, name
""", (company_id or -1,)).fetchall()
conn.close()
return jsonify([dict(s) for s in sys_list])
# ── WORK ORDER EQUIPMENT ──────────────────────────────────────────────────────
@app.route('/work-orders/<int:woid>/update-equipment/<int:weid>', methods=['POST'])
@login_required
def update_wo_equipment(woid, weid):
data = request.json
conn = get_db()
try:
labor_hours = float(data.get('labor_hours') or 0)
labor_rate = float(data.get('labor_rate') or 0)
conn.execute("""UPDATE work_order_equipment
SET description=?, notes=?, labor_hours=?, labor_rate=?
WHERE id=? AND work_order_id=?""",
(data.get('description',''), data.get('notes',''),
labor_hours, labor_rate, weid, woid))
# Recalculate totals
conn.execute("""UPDATE work_orders SET
labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?),
labor_rate = (SELECT CASE WHEN SUM(labor_hours)>0 THEN SUM(labor_hours*labor_rate)/SUM(labor_hours) ELSE ? END FROM work_order_equipment WHERE work_order_id=?),
updated_at = CURRENT_TIMESTAMP WHERE id=?""",
(woid, labor_rate, woid, woid))
conn.commit(); conn.close()
return jsonify({'ok': True})
except Exception as e:
conn.close()
return jsonify({'ok': False, 'error': str(e)}), 500
@app.route('/work-orders/<int:woid>/add-equipment', methods=['POST'])
@login_required
def add_wo_equipment(woid):
data = request.json
conn = get_db()
try:
labor_hours = float(data.get('labor_hours') or 0)
labor_rate = float(data.get('labor_rate') or 0)
conn.execute("""INSERT INTO work_order_equipment
(work_order_id, equipment_id, description, notes, labor_hours, labor_rate)
VALUES (?,?,?,?,?,?)""",
(woid, data.get('equipment_id') or None,
data.get('description',''), data.get('notes',''),
labor_hours, labor_rate))
# Recalculate total labor on work order
conn.execute("""UPDATE work_orders SET
labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?),
labor_rate = (SELECT CASE WHEN SUM(labor_hours)>0 THEN SUM(labor_hours*labor_rate)/SUM(labor_hours) ELSE ? END FROM work_order_equipment WHERE work_order_id=?),
updated_at = CURRENT_TIMESTAMP
WHERE id=?""", (woid, labor_rate, woid, woid))
conn.commit()
conn.close()
return jsonify({'ok': True})
except Exception as e:
conn.close()
return jsonify({'ok': False, 'error': str(e)}), 500
@app.route('/work-orders/<int:woid>/remove-equipment/<int:weid>', methods=['DELETE'])
@login_required
def remove_wo_equipment(woid, weid):
conn = get_db()
conn.execute("DELETE FROM work_order_equipment WHERE id=?", (weid,))
# Recalculate totals
conn.execute("""UPDATE work_orders SET
labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?),
updated_at = CURRENT_TIMESTAMP WHERE id=?""", (woid, woid))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/api/wo-equipment/<int:woid>')
@login_required
def api_wo_equipment(woid):
conn = get_db()
items = conn.execute("""
SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model
FROM work_order_equipment woe
LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
WHERE woe.work_order_id=?
ORDER BY woe.created_at
""", (woid,)).fetchall()
conn.close()
return jsonify([dict(i) for i in items])
# ── SIGNATURES ───────────────────────────────────────────────────────────────
@app.route('/work-orders/<int:woid>/save-signature', methods=['POST'])
@login_required
def save_signature(woid):
import base64, re
data = request.json
who = data.get('who') # 'tech' or 'client'
dataurl = data.get('dataUrl', '')
if not who or not dataurl:
return jsonify({'error': 'Missing data'}), 400
# Strip header: data:image/png;base64,....
match = re.match(r'data:image/\w+;base64,(.*)', dataurl, re.DOTALL)
if not match:
return jsonify({'error': 'Invalid image'}), 400
img_bytes = base64.b64decode(match.group(1))
fname = f"sig_{woid}_{who}_{uuid.uuid4().hex[:6]}.png"
with open(os.path.join(SIG_FOLDER, fname), 'wb') as f:
f.write(img_bytes)
col = 'signature_tech' if who == 'tech' else 'signature_client'
conn = get_db()
# Delete old file if exists
old = conn.execute(f"SELECT {col} FROM work_orders WHERE id=?", (woid,)).fetchone()
if old and old[col]:
op = os.path.join(SIG_FOLDER, old[col])
if os.path.exists(op): os.remove(op)
conn.execute(f"UPDATE work_orders SET {col}=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (fname, woid))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/work-orders/<int:woid>/clear-signature', methods=['POST'])
@login_required
def clear_signature(woid):
who = request.json.get('who')
col = 'signature_tech' if who == 'tech' else 'signature_client'
conn = get_db()
old = conn.execute(f"SELECT {col} FROM work_orders WHERE id=?", (woid,)).fetchone()
if old and old[col]:
fp = os.path.join(SIG_FOLDER, old[col])
if os.path.exists(fp): os.remove(fp)
conn.execute(f"UPDATE work_orders SET {col}=NULL WHERE id=?", (woid,))
conn.commit(); conn.close()
return jsonify({'ok': True})
# ── EMAIL CONFIG ──────────────────────────────────────────────────────────────
@app.route('/settings/email', methods=['GET','POST'])
@admin_required
def email_settings():
conn = get_db()
company_id = cid() # None if superadmin
cfg = conn.execute("SELECT * FROM email_config WHERE company_id IS ?", (company_id,)).fetchone()
if request.method == 'POST':
data = request.form
if cfg:
conn.execute("""UPDATE email_config SET smtp_host=?,smtp_port=?,smtp_user=?,
smtp_password=?,from_name=?,from_email=?,use_tls=?,updated_at=CURRENT_TIMESTAMP
WHERE company_id IS ?""",
(data.get('smtp_host'),int(data.get('smtp_port',587)),
data.get('smtp_user'),data.get('smtp_password'),
data.get('from_name'),data.get('from_email'),
1 if data.get('use_tls') else 0, company_id))
else:
conn.execute("""INSERT INTO email_config
(company_id,smtp_host,smtp_port,smtp_user,smtp_password,from_name,from_email,use_tls)
VALUES (?,?,?,?,?,?,?,?)""",
(company_id,data.get('smtp_host'),int(data.get('smtp_port',587)),
data.get('smtp_user'),data.get('smtp_password'),
data.get('from_name'),data.get('from_email'),
1 if data.get('use_tls') else 0))
conn.commit(); conn.close()
return redirect(url_for('email_settings'))
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
conn.close()
return render_template('email_settings.html', cfg=cfg, companies=companies_list)
@app.route('/settings/email/test', methods=['POST'])
@admin_required
def email_test():
from mailer import send_report_email
to = request.json.get('to','')
company_id = cid()
ok, err = send_report_email(
to_email=to, to_name='Test',
subject='✅ Marine Maintenance — Email configurado correctamente',
body_html='<h2>¡Funciona!</h2><p>Tu configuración de email está correcta.</p>',
pdf_bytes=b'', pdf_filename='test.pdf',
company_id=company_id
)
return jsonify({'ok': ok, 'error': err})
@app.route('/settings/email/debug')
@login_required
def email_debug():
"""Debug: muestra qué config de email está activa."""
from mailer import get_email_config
company_id = cid()
cfg = get_email_config(company_id)
if not cfg:
return jsonify({'error': 'No hay config de email', 'company_id': company_id})
# Hide password
safe = {k: ('***' if 'password' in k.lower() else v) for k,v in cfg.items()}
return jsonify({'config': safe, 'company_id_used': company_id})
# ── SEND REPORT ───────────────────────────────────────────────────────────────
@app.route('/work-orders/<int:woid>/send', methods=['GET','POST'])
@login_required
def send_report(woid):
from mailer import send_report_email, build_email_body
from report_generator import generate_work_order_pdf
conn = get_db()
order = conn.execute("""
SELECT wo.*, v.name as vessel_name, c.name as company_name,
c.logo_path, c.id as company_id_val
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN companies c ON v.company_id=c.id WHERE wo.id=?""", (woid,)).fetchone()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall()
parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
wo_equip = conn.execute("""SELECT woe.*,ve.name as equip_name,ve.serial_number,ve.make,ve.model
FROM work_order_equipment woe LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
WHERE woe.work_order_id=?""", (woid,)).fetchall()
conn.close()
if request.method == 'POST':
data = request.form
to_email = data.get('to_email','').strip()
to_name = data.get('to_name','').strip()
subject = data.get('subject', f"Reporte de Mantenimiento — {order['order_number']}")
lang = data.get('lang', 'es')
company_id_val = order['company_id_val'] or cid() or 0
# ── Try to reuse existing saved PDF for this lang ─────────────────────
pdf_bytes = None
fname = None
fpath = None
# Look for existing PDF in email_log for this WO and lang
conn3 = get_db()
existing_log = conn3.execute("""SELECT pdf_filename FROM email_log
WHERE work_order_id=? AND lang=? AND status='sent' AND pdf_filename IS NOT NULL
ORDER BY sent_at DESC LIMIT 1""", (woid, lang)).fetchone()
conn3.close()
if existing_log and existing_log['pdf_filename']:
candidate = os.path.join(PDF_FOLDER, existing_log['pdf_filename'])
if os.path.exists(candidate):
with open(candidate, 'rb') as f:
pdf_bytes = f.read()
fname = existing_log['pdf_filename']
fpath = candidate
# Also check saved_pdf field
if not pdf_bytes and order.get('saved_pdf'):
candidate = os.path.join(PDF_FOLDER, order['saved_pdf'])
if os.path.exists(candidate):
with open(candidate, 'rb') as f:
pdf_bytes = f.read()
fname = order['saved_pdf']
fpath = candidate
# Regenerate only if no existing PDF found
if not pdf_bytes:
logo_path = None
if order['logo_path']:
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
if os.path.exists(lp): logo_path = lp
pdf_buf = generate_work_order_pdf(
order=dict(order), vessel=dict(vessel),
photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts],
wo_equipment=[dict(e) for e in wo_equip],
upload_folder=UPLOAD_FOLDER, sig_folder=SIG_FOLDER,
company_name=order['company_name'] or 'Marine Maintenance Pro',
company_logo=logo_path, company_info='', lang=lang)
pdf_bytes = pdf_buf.read()
import datetime
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
fname = f"{order['order_number'].replace('/','_')}_{lang}_{timestamp}.pdf"
fpath = os.path.join(PDF_FOLDER, fname)
with open(fpath, 'wb') as f:
f.write(pdf_bytes)
conn2 = get_db()
if not order.get('saved_pdf'):
conn2.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid))
conn2.commit()
conn2.close()
body_html = build_email_body(dict(order), dict(vessel),
order['company_name'] or 'Marine Maintenance Pro')
ok, err = send_report_email(
to_email=to_email, to_name=to_name,
subject=subject, body_html=body_html,
pdf_bytes=pdf_bytes,
pdf_filename=f"{order['order_number']}.pdf",
company_id=company_id_val)
# Log the email attempt
u = current_user()
log_conn = get_db()
log_conn.execute("""INSERT INTO email_log
(work_order_id, to_email, to_name, subject, lang, pdf_filename, status, error_msg, sent_by)
VALUES (?,?,?,?,?,?,?,?,?)""",
(woid, to_email, to_name, subject, lang, fname,
'sent' if ok else 'failed', err if not ok else None,
u['full_name'] if u else ''))
log_conn.commit(); log_conn.close()
return jsonify({'ok': ok, 'error': err})
# GET — render send modal data
captain_email = vessel['captain_email'] or ''
captain_name = vessel['captain_name'] or ''
owner_email = vessel['owner_email'] or ''
owner_name = vessel['owner_name'] or ''
conn.close()
return render_template('send_report.html', order=order, vessel=vessel,
captain_email=captain_email, captain_name=captain_name,
owner_email=owner_email, owner_name=owner_name)
# ── SAVE PDF & SHARE LINK ─────────────────────────────────────────────────────
@app.route('/work-orders/<int:woid>/save-pdf', methods=['POST'])
@login_required
def save_pdf(woid):
from report_generator import generate_work_order_pdf
conn = get_db()
order = conn.execute("""
SELECT wo.*, v.name as vessel_name, c.name as company_name, c.logo_path
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
LEFT JOIN companies c ON v.company_id=c.id WHERE wo.id=?""", (woid,)).fetchone()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall()
parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
logo_path = None
if order['logo_path']:
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
if os.path.exists(lp): logo_path = lp
pdf_buf = generate_work_order_pdf(
order=dict(order), vessel=dict(vessel),
photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts],
upload_folder=UPLOAD_FOLDER,
company_name=order['company_name'] or 'Marine Maintenance Pro',
company_logo=logo_path, company_info='')
fname = f"{order['order_number'].replace('/','_')}.pdf"
fpath = os.path.join(PDF_FOLDER, fname)
with open(fpath, 'wb') as f:
f.write(pdf_buf.read())
conn.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid))
conn.commit(); conn.close()
return jsonify({'ok': True, 'filename': fname,
'url': f"/static/uploads/pdfs/{fname}"})
@app.route('/work-orders/<int:woid>/share')
@login_required
def share_report(woid):
"""Returns share info: PDF URL, WhatsApp link, SMS link."""
conn = get_db()
order = conn.execute("SELECT * FROM work_orders WHERE id=?", (woid,)).fetchone()
conn.close()
host = request.host_url.rstrip('/')
pdf_url = f"{host}/work-orders/{woid}/pdf"
# If saved PDF exists, use static link (better for sharing)
if order['saved_pdf']:
pdf_url = f"{host}/static/uploads/pdfs/{order['saved_pdf']}"
scope = order['scope'] or order['description'][:60] if order['description'] else ''
msg = f"Reporte de mantenimiento {order['order_number']}{scope}\n{pdf_url}"
import urllib.parse
wa_link = f"https://wa.me/?text={urllib.parse.quote(msg)}"
sms_link = f"sms:?body={urllib.parse.quote(msg)}"
return jsonify({
'ok': True,
'pdf_url': pdf_url,
'wa_link': wa_link,
'sms_link': sms_link,
'message': msg
})
# ══════════════════════════════════════════════════════════════════════════════
# ISM — PROCEDIMIENTOS DE TRABAJO SEGURO (SWP)
# ══════════════════════════════════════════════════════════════════════════════
SWP_CATEGORIES = [
('electrical', '⚡ Trabajo Eléctrico'),
('mechanical', '⚙️ Mecánico / Motor'),
('chemical', '🧪 Químicos / Pinturas'),
('confined', '🔒 Espacio Confinado'),
('height', '⚓ Trabajos en Altura'),
('welding', '🔥 Soldadura / Calor'),
('hull', '🚢 Casco / Buceo'),
('other', '📋 Otro'),
]
def next_swp_code(company_id):
conn = get_db()
row = conn.execute("""SELECT code FROM swp WHERE company_id=?
ORDER BY code DESC LIMIT 1""", (company_id,)).fetchone()
conn.close()
if not row:
return "SWP-001"
try:
last_num = int(row['code'].split('-')[-1])
return f"SWP-{(last_num+1):03d}"
except:
# Count as fallback
conn2 = get_db()
n = conn2.execute("SELECT COUNT(*) as n FROM swp WHERE company_id=?", (company_id,)).fetchone()['n']
conn2.close()
return f"SWP-{(n+1):03d}"
def next_version(swp_id):
conn = get_db()
row = conn.execute("""SELECT version FROM swp_versions WHERE swp_id=?
ORDER BY created_at DESC LIMIT 1""", (swp_id,)).fetchone()
conn.close()
if not row: return 'v1.0'
try:
parts = row['version'].lstrip('v').split('.')
return f"v{parts[0]}.{int(parts[1])+1}"
except:
return 'v1.1'
@app.route('/ism')
@login_required
def ism_index():
from auth import is_superadmin
conn = get_db()
company_id = cid()
# Superadmin can filter by company
filter_cid = request.args.get('company_id', type=int)
companies = []
if is_superadmin():
companies = conn.execute("SELECT id, name FROM companies ORDER BY name").fetchall()
if filter_cid:
company_id = filter_cid
else:
company_id = None # show all for superadmin by default
if company_id:
swps = conn.execute("""
SELECT s.*, sv.version, sv.effective_date, sv.approved_by,
sv.status as ver_status, sv.id as ver_id,
c.name as company_name
FROM swp s
LEFT JOIN swp_versions sv ON s.current_version_id=sv.id
LEFT JOIN companies c ON s.company_id=c.id
WHERE s.company_id=? ORDER BY s.category, s.code""", (company_id,)).fetchall()
else:
swps = conn.execute("""
SELECT s.*, sv.version, sv.effective_date, sv.approved_by,
sv.status as ver_status, sv.id as ver_id,
c.name as company_name
FROM swp s
LEFT JOIN swp_versions sv ON s.current_version_id=sv.id
LEFT JOIN companies c ON s.company_id=c.id
ORDER BY c.name, s.category, s.code""").fetchall()
conn.close()
return render_template('ism_index.html', swps=swps, categories=SWP_CATEGORIES,
companies=companies, current_company=company_id,
is_superadmin=is_superadmin())
@app.route('/ism/new', methods=['GET','POST'])
@login_required
def swp_new():
from auth import is_superadmin
conn = get_db()
company_id = cid()
companies = []
if is_superadmin():
companies = conn.execute("SELECT id,name FROM companies ORDER BY name").fetchall()
if request.method == 'POST':
data = request.form
# Superadmin can pick company from form
if is_superadmin() and data.get('company_id'):
company_id = int(data['company_id'])
code = next_swp_code(company_id)
# Double-check uniqueness
while conn.execute("SELECT id FROM swp WHERE code=? AND company_id=?",
(code, company_id)).fetchone():
code = next_swp_code(company_id)
conn.execute("""INSERT INTO swp (company_id,code,title,category,status)
VALUES (?,?,?,?,?)""",
(company_id, code, data['title'], data['category'], 'active'))
conn.commit()
swp_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
import json
steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()]
hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()]
ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()]
refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()]
u = current_user()
tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()]
conn.execute("""INSERT INTO swp_versions
(swp_id,version,purpose,scope,hazards,ppe,tools,steps,emergency,
ref_standards,status,change_reason,created_by,approved_by,
approved_at,effective_date)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,?)""",
(swp_id,'v1.0',data.get('purpose'),data.get('scope'),
json.dumps(hazards),json.dumps(ppe),json.dumps(tools),json.dumps(steps),
data.get('emergency'),json.dumps(refs),
'draft','Versión inicial',
u['full_name'] if u else '',
u['full_name'] if u else '',
data.get('effective_date') or None))
conn.commit()
ver_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (ver_id, swp_id))
conn.commit(); conn.close()
return redirect(url_for('swp_detail', sid=swp_id))
conn.close()
return render_template('swp_form.html', swp=None, categories=SWP_CATEGORIES,
code=next_swp_code(company_id or 0),
companies=companies, is_superadmin=is_superadmin())
@app.route('/ism/<int:sid>')
@login_required
def swp_detail(sid):
conn = get_db()
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
versions = conn.execute("""SELECT * FROM swp_versions WHERE swp_id=?
ORDER BY created_at DESC""", (sid,)).fetchall()
current = conn.execute("""SELECT * FROM swp_versions WHERE id=?""",
(swp['current_version_id'],)).fetchone() if swp['current_version_id'] else None
conn.close()
import json
return render_template('swp_detail.html', swp=swp, versions=versions,
current=current, categories=dict(SWP_CATEGORIES), json=json)
@app.route('/ism/<int:sid>/versions/<int:vid>/edit', methods=['GET','POST'])
@login_required
def swp_edit_version(sid, vid):
"""Edit a DRAFT version — not allowed once approved/active."""
conn = get_db()
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
version = conn.execute("SELECT * FROM swp_versions WHERE id=? AND swp_id=?", (vid, sid)).fetchone()
if not version or version['status'] not in ('draft', 'active'):
conn.close()
return redirect(url_for('swp_detail', sid=sid))
if request.method == 'POST':
import json
data = request.form
u = current_user()
steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()]
hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()]
ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()]
refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()]
tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()]
conn.execute("""UPDATE swp_versions SET
purpose=?, scope=?, hazards=?, ppe=?, tools=?, steps=?, emergency=?,
ref_standards=?, change_reason=?, diff_summary=?, effective_date=?,
created_by=?
WHERE id=?""",
(data.get('purpose'), data.get('scope'),
json.dumps(hazards), json.dumps(ppe), json.dumps(tools), json.dumps(steps),
data.get('emergency'), json.dumps(refs),
data.get('change_reason'), data.get('diff_summary'),
data.get('effective_date') or None,
u['full_name'] if u else version['created_by'],
vid))
conn.commit(); conn.close()
return redirect(url_for('swp_detail', sid=sid))
conn.close()
import json
return render_template('swp_version_edit.html', swp=swp, version=version,
categories=SWP_CATEGORIES, json=json)
@app.route('/ism/<int:sid>/edit', methods=['GET','POST'])
@login_required
def swp_edit(sid):
from auth import is_superadmin
conn = get_db()
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
companies = conn.execute("SELECT id,name FROM companies ORDER BY name").fetchall()
if request.method == 'POST':
data = request.form
new_cid = data.get('company_id') or cid()
conn.execute("""UPDATE swp SET code=?, title=?, category=?, company_id=?
WHERE id=?""", (data['code'], data['title'], data['category'], new_cid, sid))
conn.commit(); conn.close()
return redirect(url_for('swp_detail', sid=sid))
conn.close()
return render_template('swp_edit.html', swp=swp, categories=SWP_CATEGORIES,
companies=companies, is_superadmin=is_superadmin())
@app.route('/ism/<int:sid>/new-version', methods=['GET','POST'])
@login_required
def swp_new_version(sid):
conn = get_db()
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
current = conn.execute("""SELECT * FROM swp_versions WHERE id=?""",
(swp['current_version_id'],)).fetchone() if swp['current_version_id'] else None
if request.method == 'POST':
data = request.form
import json
u = current_user()
steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()]
hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()]
ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()]
refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()]
new_ver = next_version(sid)
tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()]
conn.execute("""INSERT INTO swp_versions
(swp_id,version,purpose,scope,hazards,ppe,tools,steps,emergency,
ref_standards,status,change_reason,diff_summary,created_by,
effective_date)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(sid, new_ver, data.get('purpose'), data.get('scope'),
json.dumps(hazards), json.dumps(ppe), json.dumps(tools), json.dumps(steps),
data.get('emergency'), json.dumps(refs),
'draft', data.get('change_reason'), data.get('diff_summary'),
u['full_name'] if u else '',
data.get('effective_date') or None))
conn.commit()
# Update current_version_id to point to new draft
new_ver_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (new_ver_id, sid))
conn.commit()
conn.close()
return redirect(url_for('swp_detail', sid=sid))
conn.close()
import json
return render_template('swp_version_form.html', swp=swp, current=current,
categories=dict(SWP_CATEGORIES), json=json,
new_version=next_version(sid))
@app.route('/ism/<int:sid>/versions/<int:vid>/approve', methods=['POST'])
@login_required
def swp_approve_version(sid, vid):
if not is_admin():
return jsonify({'ok': False, 'error': 'Sin permiso'}), 403
u = current_user()
conn = get_db()
# Supersede current active version
conn.execute("""UPDATE swp_versions SET status='superseded'
WHERE swp_id=? AND status='active'""", (sid,))
# Approve new version
conn.execute("""UPDATE swp_versions SET status='active',
approved_by=?, approved_at=CURRENT_TIMESTAMP
WHERE id=?""", (u['full_name'] if u else '', vid))
# Update swp current_version_id
conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (vid, sid))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/ism/<int:sid>/pdf')
@login_required
def swp_pdf(sid):
from swp_generator import generate_swp_pdf
lang = request.args.get('lang', 'es')
conn = get_db()
swp = conn.execute("""SELECT s.*, c.name as company_name, c.logo_path,
c.address as company_address, c.phone as company_phone,
c.email as company_email
FROM swp s LEFT JOIN companies c ON s.company_id=c.id
WHERE s.id=?""", (sid,)).fetchone()
version = conn.execute("SELECT * FROM swp_versions WHERE id=?",
(swp['current_version_id'],)).fetchone()
conn.close()
if not version:
return "Sin versión activa", 404
logo_path = None
company_name = swp['company_name']
company_address = swp['company_address'] or ''
company_phone = swp['company_phone'] or ''
company_email = swp['company_email'] or ''
# Fallback: if SWP has no company, use current user's company
if not swp['company_id'] or not swp['company_name']:
fallback_cid = cid()
if fallback_cid:
fc = conn2.execute("""SELECT name,logo_path,address,phone,email
FROM companies WHERE id=?""", (fallback_cid,)).fetchone() if False else None
fb_conn = get_db()
fc = fb_conn.execute("SELECT name,logo_path,address,phone,email FROM companies WHERE id=?",
(fallback_cid,)).fetchone()
fb_conn.close()
if fc:
company_name = fc['name']
company_address = fc['address'] or ''
company_phone = fc['phone'] or ''
company_email = fc['email'] or ''
if fc['logo_path']:
lp = os.path.join(LOGO_FOLDER, fc['logo_path'])
if os.path.exists(lp): logo_path = lp
if swp['logo_path'] and not logo_path:
lp = os.path.join(LOGO_FOLDER, swp['logo_path'])
if os.path.exists(lp): logo_path = lp
company_info = ' | '.join(filter(None, [company_address, company_phone, company_email]))
import json as json_module
swp_dict = dict(swp)
swp_dict['company_info'] = company_info
swp_dict['company_name'] = company_name or swp['company_name'] or ''
buf = generate_swp_pdf(swp_dict, dict(version), logo_path, json_module, lang=lang)
return send_file(buf, mimetype='application/pdf', as_attachment=False,
download_name=f"{swp['code']}_{version['version']}_{lang}.pdf")
# ── MSDS ──────────────────────────────────────────────────────────────────────
@app.route('/ism/msds')
@login_required
def msds_index():
conn = get_db()
company_id = cid()
if company_id:
msds_list = conn.execute("""SELECT m.*, p.name as part_name
FROM msds m LEFT JOIN parts p ON m.part_id=p.id
WHERE m.company_id=? ORDER BY m.product_name""", (company_id,)).fetchall()
else:
msds_list = conn.execute("""SELECT m.*, p.name as part_name
FROM msds m LEFT JOIN parts p ON m.part_id=p.id
ORDER BY m.product_name""").fetchall()
parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall()
conn.close()
return render_template('msds_index.html', msds_list=msds_list, parts=parts)
@app.route('/ism/msds/new', methods=['GET','POST'])
@login_required
def msds_new():
conn = get_db()
if request.method == 'POST':
data = request.form
u = current_user()
f = request.files.get('pdf_file')
pdf_fname = None
if f and f.filename:
import uuid
pdf_fname = f"msds_{uuid.uuid4().hex[:8]}.pdf"
f.save(os.path.join(DOCS_FOLDER, pdf_fname))
conn.execute("""INSERT INTO msds
(company_id,part_id,product_name,manufacturer,version,hazard_class,
hazards,first_aid,ppe_required,handling,storage,spill_procedure,
disposal,ref_standards,pdf_filename,created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(cid(), data.get('part_id') or None, data['product_name'],
data.get('manufacturer'), data.get('version','v1.0'),
data.get('hazard_class'), data.get('hazards'),
data.get('first_aid'), data.get('ppe_required'),
data.get('handling'), data.get('storage'),
data.get('spill_procedure'), data.get('disposal'),
data.get('ref_standards'), pdf_fname,
u['full_name'] if u else ''))
conn.commit(); conn.close()
return redirect(url_for('msds_index'))
parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall()
conn.close()
return render_template('msds_form.html', msds=None, parts=parts)
@app.route('/ism/msds/<int:mid>/edit', methods=['GET','POST'])
@login_required
def msds_edit(mid):
conn = get_db()
msds = conn.execute("SELECT * FROM msds WHERE id=?", (mid,)).fetchone()
if request.method == 'POST':
data = request.form
u = current_user()
f = request.files.get('pdf_file')
pdf_fname = msds['pdf_filename']
if f and f.filename:
import uuid
pdf_fname = f"msds_{uuid.uuid4().hex[:8]}.pdf"
f.save(os.path.join(DOCS_FOLDER, pdf_fname))
conn.execute("""UPDATE msds SET product_name=?,manufacturer=?,version=?,
hazard_class=?,hazards=?,first_aid=?,ppe_required=?,handling=?,
storage=?,spill_procedure=?,disposal=?,ref_standards=?,
pdf_filename=?,updated_by=?,updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(data['product_name'],data.get('manufacturer'),data.get('version','v1.0'),
data.get('hazard_class'),data.get('hazards'),data.get('first_aid'),
data.get('ppe_required'),data.get('handling'),data.get('storage'),
data.get('spill_procedure'),data.get('disposal'),data.get('ref_standards'),
pdf_fname, u['full_name'] if u else '', mid))
conn.commit(); conn.close()
return redirect(url_for('msds_index'))
parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall()
conn.close()
return render_template('msds_form.html', msds=msds, parts=parts)
# ══════════════════════════════════════════════════════════════════════════════
# WO ASIGNACIÓN Y NOTIFICACIONES INTERNAS
# ══════════════════════════════════════════════════════════════════════════════
@app.route('/work-orders/<int:woid>/assign', methods=['POST'])
@login_required
def wo_assign(woid):
"""Assign WO to technician(s) and send email with PDF."""
from mailer import send_wo_notification
from report_generator import generate_work_order_pdf
data = request.get_json() or request.form
to_emails = data.get('to_emails','')
message = data.get('message','')
u = current_user()
sender_name = u['full_name'] if u else 'Sistema'
company_id = cid()
conn = get_db()
wo = conn.execute("""SELECT wo.*, v.name as vessel_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
WHERE wo.id=?""", (woid,)).fetchone()
if not wo:
conn.close()
return jsonify({'ok': False, 'error': 'WO no encontrada'}), 404
# Update assigned_to
to_list = [e.strip() for e in to_emails.replace(',',';').split(';') if e.strip()]
conn.execute("""UPDATE work_orders SET assigned_to=?, assigned_by=?,
assigned_at=CURRENT_TIMESTAMP WHERE id=?""",
(', '.join(to_list), sender_name, woid))
conn.commit()
# Generate PDF
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=?", (woid,)).fetchall()
parts = conn.execute("""SELECT wop.*, p.name as part_name FROM work_order_parts wop
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
wo_equip = conn.execute("SELECT * FROM work_order_equipment WHERE work_order_id=?", (woid,)).fetchall()
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (wo['vessel_id'],)).fetchone()
company = conn.execute("SELECT * FROM companies WHERE id=?", (company_id,)).fetchone()
conn.close()
logo_path = None
if company and company['logo_path']:
lp = os.path.join(LOGO_FOLDER, company['logo_path'])
if os.path.exists(lp): logo_path = lp
try:
company_info_str = ' | '.join(filter(None, [
(company.get('address') or '') if company else '',
(company.get('phone') or '') if company else '',
(company.get('email') or '') if company else '',
]))
buf = generate_work_order_pdf(
order=dict(wo),
vessel=dict(vessel) if vessel else {},
photos=[dict(p) for p in photos],
parts_used=[dict(p) for p in parts],
wo_equipment=[dict(e) for e in wo_equip],
upload_folder=UPLOAD_FOLDER,
sig_folder=SIG_FOLDER,
company_name=(company['name'] if company else 'Marine Maintenance Pro'),
company_logo=logo_path,
company_info=company_info_str,
lang='es')
pdf_fname = f"WO_{wo['order_number']}_assign.pdf"
pdf_path = os.path.join(PDF_FOLDER, pdf_fname)
with open(pdf_path, 'wb') as f:
f.write(buf.read())
except Exception as e:
pdf_path = None
pdf_fname = None
# Build email HTML
msg_extra = f"<p><b>Mensaje:</b> {message}</p>" if message else ""
app_url = request.host_url.rstrip('/')
body = f"""
<div style="font-family:Arial,sans-serif;max-width:600px">
<div style="background:#0a1628;padding:20px;border-radius:8px 8px 0 0">
<h2 style="color:#00b4d8;margin:0">Nueva Orden de Trabajo Asignada</h2>
</div>
<div style="background:#f8f9fa;padding:20px;border:1px solid #dee2e6">
<p><b>Orden:</b> {wo['order_number']}</p>
<p><b>Embarcación:</b> {wo['vessel_name']}</p>
<p><b>Scope:</b> {wo.get('scope','—')}</p>
<p><b>Asignado por:</b> {sender_name}</p>
{msg_extra}
<hr>
<p style="color:#666;font-size:13px">Se adjunta el PDF de la orden de trabajo.</p>
<a href="{app_url}/work-orders/{woid}"
style="background:#00b4d8;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:10px">
Ver Orden en el Sistema
</a>
</div>
</div>"""
ok, err = send_wo_notification(
company_id, to_list,
f"WO Asignada: {wo['order_number']}{wo['vessel_name']}",
body, pdf_path, pdf_fname)
# Log notification
db2 = get_db()
db2.execute("""INSERT INTO wo_notifications
(work_order_id,sent_to,sent_by,notification_type,message,pdf_filename,status)
VALUES (?,?,?,?,?,?,?)""",
(woid, ', '.join(to_list), sender_name, 'assignment',
message, pdf_fname, 'sent' if ok else f'error: {err}'))
db2.commit(); db2.close()
return jsonify({'ok': ok, 'error': err if not ok else None})
@app.route('/work-orders/<int:woid>/notify-status', methods=['POST'])
@login_required
def wo_notify_status(woid):
"""Notify assigned person of status change."""
from mailer import send_wo_notification
conn = get_db()
wo = conn.execute("""SELECT wo.*, v.name as vessel_name
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
WHERE wo.id=?""", (woid,)).fetchone()
conn.close()
if not wo or not wo['assigned_to']:
return jsonify({'ok': False, 'error': 'Sin asignado'})
u = current_user()
status_labels = {'open':'Abierta','in_progress':'En Progreso',
'completed':'Completada','cancelled':'Cancelada'}
status_label = status_labels.get(wo['status'], wo['status'])
app_url = request.host_url.rstrip('/')
body = f"""
<div style="font-family:Arial,sans-serif;max-width:600px">
<div style="background:#0a1628;padding:20px;border-radius:8px 8px 0 0">
<h2 style="color:#00b4d8;margin:0">Actualización de Orden de Trabajo</h2>
</div>
<div style="background:#f8f9fa;padding:20px;border:1px solid #dee2e6">
<p><b>Orden:</b> {wo['order_number']}</p>
<p><b>Embarcación:</b> {wo['vessel_name']}</p>
<p><b>Nuevo estado:</b> <span style="color:#0077b6;font-weight:bold">{status_label}</span></p>
<p><b>Actualizado por:</b> {u['full_name'] if u else '—'}</p>
<a href="{app_url}/work-orders/{woid}"
style="background:#00b4d8;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:10px">
Ver Orden
</a>
</div>
</div>"""
to_list = [e.strip() for e in wo['assigned_to'].replace(',',';').split(';') if e.strip()]
ok, err = send_wo_notification(cid(), to_list,
f"Estado actualizado: {wo['order_number']}{status_label}", body)
return jsonify({'ok': ok, 'error': err if not ok else None})
if __name__ == '__main__':
if not os.path.exists(DB_PATH):
init_db()
else:
init_db() # safe: uses CREATE IF NOT EXISTS
create_initial_superadmin()
app.run(host='0.0.0.0', port=5500, debug=True)