from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file, session import sqlite3, os, uuid, time from datetime import datetime, date from urllib.parse import urlparse 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__) # ── Security: SECRET_KEY desde variable de entorno ─────────────────────────── _secret_key = os.environ.get('SECRET_KEY') if not _secret_key: _secret_key = 'marine_maint_secret_2026_xK9p' print('⚠️ WARNING: SECRET_KEY no configurado en variables de entorno. ' 'Crea un archivo .env con SECRET_KEY= antes de producción.') app.secret_key = _secret_key # ── Rate-limiting simple para login (sin dependencias externas) ────────────── _login_attempts: dict = {} # ip -> [timestamps] _LOGIN_MAX = 10 _LOGIN_WINDOW = 900 # 15 minutos def _is_rate_limited(ip: str) -> bool: now = time.time() attempts = [t for t in _login_attempts.get(ip, []) if now - t < _LOGIN_WINDOW] _login_attempts[ip] = attempts return len(attempts) >= _LOGIN_MAX def _record_failed_login(ip: str): _login_attempts.setdefault(ip, []).append(time.time()) 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': ip = request.remote_addr or '0.0.0.0' if _is_rate_limited(ip): error = 'Demasiados intentos fallidos. Espera 15 minutos.' return render_template('login.html', error=error, username='') 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) # Validar next para prevenir open redirect next_url = request.args.get('next') or url_for('dashboard') parsed = urlparse(next_url) if parsed.netloc and parsed.netloc != request.host: next_url = url_for('dashboard') return redirect(next_url) _record_failed_login(ip) 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//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//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//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/') @login_required def vessel_detail(vid): return redirect(url_for('vessel_history', vid=vid)) @app.route('/vessels//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//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//equipment//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//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//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//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//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//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/') @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','')) _VALID_WO_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled', 'on_hold'} def _check_wo_access(conn, woid): """Verifica que el WO pertenece a la empresa del usuario actual.""" company_id = cid() if company_id is None: # superadmin ve todo return True row = conn.execute(""" SELECT wo.id FROM work_orders wo JOIN vessels v ON wo.vessel_id = v.id WHERE wo.id=? AND v.company_id=? """, (woid, company_id)).fetchone() return row is not None @app.route('/work-orders//update-status', methods=['POST']) @login_required def update_status(woid): status = request.json.get('status') if status not in _VALID_WO_STATUSES: return jsonify({'error': 'Estado inválido'}), 400 conn = get_db() if not _check_wo_access(conn, woid): conn.close() return jsonify({'error': 'No autorizado'}), 403 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//update-fields', methods=['POST']) @login_required def update_fields(woid): data = request.json conn = get_db() if not _check_wo_access(conn, woid): conn.close(); return jsonify({'error': 'No autorizado'}), 403 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/', methods=['POST']) @login_required def update_labor(woid): data = request.json conn = get_db() if not _check_wo_access(conn, woid): conn.close(); return jsonify({'error': 'No autorizado'}), 403 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//upload-photo', methods=['POST']) @login_required def upload_photo(woid): if 'photo' not in request.files: return jsonify({'error':'No file'}),400 conn_chk = get_db() if not _check_wo_access(conn_chk, woid): conn_chk.close(); return jsonify({'error': 'No autorizado'}), 403 conn_chk.close() 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//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() if not _check_wo_access(conn, woid): conn.close(); return jsonify({'error': 'No autorizado'}), 403 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/', 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/', 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//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//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/') @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//update-status', methods=['POST']) @login_required def purchase_update_status(pid): data = request.json new_status = data.get('status') if new_status not in PURCHASE_STATUSES: return jsonify({'error': 'Estado inválido'}), 400 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//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'}) if not allowed_file(f.filename, ALLOWED_DOCS): return jsonify({'ok': False, 'error': 'Tipo de archivo no permitido'}) 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//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//delete-item/', 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//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//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/') @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/') @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 '
'.join(results) + '

Back to Dashboard' # ── 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//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//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//update-equipment/', 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//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//remove-equipment/', 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/') @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 ─────────────────────────────────────────────────────────────── _SIG_COLS = {'tech': 'signature_tech', 'client': 'signature_client'} @app.route('/work-orders//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 # Allowlist: solo 'tech' o 'client' son válidos col = _SIG_COLS.get(who) if not col: return jsonify({'error': 'Parámetro who inválido'}), 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) conn = get_db() if not _check_wo_access(conn, woid): conn.close(); return jsonify({'error': 'No autorizado'}), 403 # Delete old file if exists (col ya está validado contra allowlist) 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//clear-signature', methods=['POST']) @login_required def clear_signature(woid): who = request.json.get('who') col = _SIG_COLS.get(who) if not col: return jsonify({'error': 'Parámetro who inválido'}), 400 conn = get_db() if not _check_wo_access(conn, woid): conn.close(); return jsonify({'error': 'No autorizado'}), 403 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='

¡Funciona!

Tu configuración de email está correcta.

', 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//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//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//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/') @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//versions//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//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//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//versions//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//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//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//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"

Mensaje: {message}

" if message else "" app_url = request.host_url.rstrip('/') body = f"""

Nueva Orden de Trabajo Asignada

Orden: {wo['order_number']}

Embarcación: {wo['vessel_name']}

Scope: {wo.get('scope','—')}

Asignado por: {sender_name}

{msg_extra}

Se adjunta el PDF de la orden de trabajo.

Ver Orden en el Sistema
""" 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//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"""

Actualización de Orden de Trabajo

Orden: {wo['order_number']}

Embarcación: {wo['vessel_name']}

Nuevo estado: {status_label}

Actualizado por: {u['full_name'] if u else '—'}

Ver Orden
""" 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)