gigafibre-fsm/scripts/migration/add_missing_custom_fields.py
louispaulb bfffed2b41 feat: ONT diagnostics — grouped mesh topology, signal RSSI, management link
- EquipmentDetail: collapsible node groups (clients grouped by mesh node)
- Signal strength as RSSI % (0-255 per 802.11-2020) with 10-tone color scale
- Management IP clickable link to device web GUI (/superadmin/)
- Fibre status compact top bar (status + Rx/Tx power when available)
- targo-hub: WAN IP detection across all VLAN interfaces
- targo-hub: full WiFi client count (direct + EasyMesh mesh repeaters)
- targo-hub: /devices/:id/hosts endpoint with client-to-node mapping
- ClientsPage: start empty, load only on search (no auto-load all)
- nginx: dynamic ollama resolver (won't crash if ollama is down)
- Cleanup: remove unused BillingKPIs.vue and TagInput.vue
- New docs and migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:26:14 -04:00

397 lines
22 KiB
Python

"""
add_missing_custom_fields.py — Add all missing custom fields to ERPNext doctypes.
Covers the complete field gap identified in LEGACY-FIELD-GAP-ANALYSIS.md.
Adds ~73 custom fields across Customer, Service Subscription, Service Equipment,
Service Location, and Dispatch Job.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/add_missing_custom_fields.py
"""
import frappe
import os
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print("Connected:", frappe.local.site)
def add_custom_fields(doctype, fields):
"""Add a list of custom fields to a doctype, skipping existing ones."""
count = 0
for f in fields:
fieldname = f["fieldname"]
exists = frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": fieldname})
if exists:
continue
try:
doc = frappe.get_doc({
"doctype": "Custom Field",
"dt": doctype,
**f,
})
doc.insert(ignore_permissions=True)
count += 1
print(" + {}.{}".format(doctype, fieldname))
except Exception as e:
print(" ! {}.{} — ERROR: {}".format(doctype, fieldname, str(e)[:100]))
frappe.db.commit()
return count
# ═══════════════════════════════════════════════════════════════════════════
# 1. CUSTOMER — PPA, Stripe, Termination, VIP, Portal, Marketing
# ═══════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("CUSTOMER — Adding missing fields")
print("=" * 60)
customer_fields = [
# ── PPA (Pre-Authorized Payment) Section ──
{"fieldname": "ppa_section", "label": "Paiement pré-autorisé (PPA)", "fieldtype": "Section Break",
"insert_after": "notes_internal", "collapsible": 1},
{"fieldname": "ppa_enabled", "label": "PPA activé", "fieldtype": "Check",
"insert_after": "ppa_section"},
{"fieldname": "ppa_name", "label": "Nom du payeur", "fieldtype": "Data",
"insert_after": "ppa_enabled", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_code", "label": "Transit bancaire", "fieldtype": "Data",
"insert_after": "ppa_name", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_branch", "label": "Succursale", "fieldtype": "Data",
"insert_after": "ppa_code", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_col_break", "fieldtype": "Column Break",
"insert_after": "ppa_branch"},
{"fieldname": "ppa_account", "label": "Numéro de compte", "fieldtype": "Data",
"insert_after": "ppa_col_break", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_amount", "label": "Montant PPA", "fieldtype": "Currency",
"insert_after": "ppa_account", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_amount_buffer", "label": "Montant tampon", "fieldtype": "Currency",
"insert_after": "ppa_amount", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_fixed", "label": "Montant fixe", "fieldtype": "Check",
"insert_after": "ppa_amount_buffer", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_cc", "label": "PPA par carte de crédit", "fieldtype": "Check",
"insert_after": "ppa_fixed", "depends_on": "eval:doc.ppa_enabled"},
{"fieldname": "ppa_all_invoice", "label": "Appliquer à toutes les factures", "fieldtype": "Check",
"insert_after": "ppa_cc", "depends_on": "eval:doc.ppa_enabled"},
# ── Stripe Section ──
{"fieldname": "stripe_section", "label": "Stripe", "fieldtype": "Section Break",
"insert_after": "ppa_all_invoice", "collapsible": 1},
{"fieldname": "stripe_customer_id", "label": "Stripe Customer ID", "fieldtype": "Data",
"insert_after": "stripe_section", "description": "cus_XXXXX"},
{"fieldname": "stripe_ppa_enabled", "label": "Paiement auto Stripe", "fieldtype": "Check",
"insert_after": "stripe_customer_id"},
# ── Flags ──
{"fieldname": "is_vip", "label": "Client VIP", "fieldtype": "Check",
"insert_after": "is_bad_payer"},
{"fieldname": "is_land_owner", "label": "Propriétaire terrain", "fieldtype": "Check",
"insert_after": "is_vip"},
{"fieldname": "marketing_optin", "label": "Accepte marketing", "fieldtype": "Check",
"insert_after": "is_land_owner"},
{"fieldname": "call_contact", "label": "Contact téléphonique", "fieldtype": "Check",
"insert_after": "marketing_optin"},
# ── Termination Section ──
{"fieldname": "termination_section", "label": "Résiliation", "fieldtype": "Section Break",
"insert_after": "stripe_ppa_enabled", "collapsible": 1},
{"fieldname": "terminate_date", "label": "Date résiliation", "fieldtype": "Date",
"insert_after": "termination_section"},
{"fieldname": "terminate_reason", "label": "Raison", "fieldtype": "Small Text",
"insert_after": "terminate_date"},
{"fieldname": "terminate_col", "fieldtype": "Column Break",
"insert_after": "terminate_reason"},
{"fieldname": "terminate_cie", "label": "Compétiteur", "fieldtype": "Data",
"insert_after": "terminate_col"},
{"fieldname": "terminate_note", "label": "Notes résiliation", "fieldtype": "Small Text",
"insert_after": "terminate_cie"},
# ── Client-visible notes ──
{"fieldname": "notes_client", "label": "Notes client (visible)", "fieldtype": "Small Text",
"insert_after": "notes_internal", "description": "Notes visibles par le client"},
# ── Portal ──
{"fieldname": "portal_section", "label": "Portail client (legacy)", "fieldtype": "Section Break",
"insert_after": "terminate_note", "collapsible": 1},
{"fieldname": "portal_username", "label": "Nom d'utilisateur", "fieldtype": "Data",
"insert_after": "portal_section"},
{"fieldname": "portal_password_hash", "label": "Hash mot de passe (MD5)", "fieldtype": "Data",
"insert_after": "portal_username", "read_only": 1},
# ── Misc ──
{"fieldname": "middle_name", "label": "Deuxième prénom", "fieldtype": "Data",
"insert_after": "customer_name"},
{"fieldname": "salutation_legacy", "label": "Titre (legacy)", "fieldtype": "Data",
"insert_after": "middle_name"},
{"fieldname": "search_keyword", "label": "Mot-clé recherche", "fieldtype": "Data",
"insert_after": "portal_password_hash"},
]
add_custom_fields("Customer", customer_fields)
# ═══════════════════════════════════════════════════════════════════════════
# 2. SERVICE SUBSCRIPTION — Custom pricing, dates, quotas, IP
# ═══════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("SERVICE SUBSCRIPTION — Adding missing fields")
print("=" * 60)
subscription_fields = [
# ── Custom pricing ──
{"fieldname": "custom_pricing_section", "label": "Tarification personnalisée", "fieldtype": "Section Break",
"insert_after": "notes", "collapsible": 1},
{"fieldname": "is_custom_pricing", "label": "Prix personnalisé (hijack)", "fieldtype": "Check",
"insert_after": "custom_pricing_section"},
{"fieldname": "custom_pricing_desc", "label": "Description prix custom", "fieldtype": "Data",
"insert_after": "is_custom_pricing", "depends_on": "eval:doc.is_custom_pricing"},
{"fieldname": "custom_col", "fieldtype": "Column Break",
"insert_after": "custom_pricing_desc"},
{"fieldname": "quota_day_gb", "label": "Quota jour (Go)", "fieldtype": "Float",
"insert_after": "custom_col"},
{"fieldname": "quota_night_gb", "label": "Quota nuit (Go)", "fieldtype": "Float",
"insert_after": "quota_day_gb"},
# ── Extended dates ──
{"fieldname": "dates_extra_section", "label": "Dates supplémentaires", "fieldtype": "Section Break",
"insert_after": "quota_night_gb", "collapsible": 1},
{"fieldname": "date_suspended", "label": "Date suspension", "fieldtype": "Date",
"insert_after": "dates_extra_section"},
{"fieldname": "active_until", "label": "Actif jusqu'au", "fieldtype": "Date",
"insert_after": "date_suspended"},
{"fieldname": "dates_extra_col", "fieldtype": "Column Break",
"insert_after": "active_until"},
{"fieldname": "next_invoice_date", "label": "Prochaine facturation", "fieldtype": "Date",
"insert_after": "dates_extra_col"},
# ── Network ──
{"fieldname": "network_section", "label": "Réseau", "fieldtype": "Section Break",
"insert_after": "next_invoice_date", "collapsible": 1},
{"fieldname": "static_ip", "label": "IP fixe", "fieldtype": "Data",
"insert_after": "network_section"},
{"fieldname": "forfait_internet", "label": "Forfait internet", "fieldtype": "Check",
"insert_after": "static_ip"},
{"fieldname": "radius_consumption", "label": "Consommation RADIUS", "fieldtype": "Data",
"insert_after": "forfait_internet"},
]
add_custom_fields("Service Subscription", subscription_fields)
# ═══════════════════════════════════════════════════════════════════════════
# 3. SERVICE EQUIPMENT — Management, Provisioning, ACS, IPTV
# ═══════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("SERVICE EQUIPMENT — Adding missing fields")
print("=" * 60)
# Note: Service Equipment is a custom doctype, so we add fields directly
# to the DocType definition (not as Custom Field). We use ALTER TABLE.
# But if it was created via setup_fsm_doctypes.py, we can still use Custom Field pattern.
equipment_fields = [
# ── Management Access ──
{"fieldname": "mgmt_section", "label": "Accès de gestion", "fieldtype": "Section Break",
"insert_after": "login_password", "collapsible": 1},
{"fieldname": "manage_url", "label": "URL de gestion", "fieldtype": "Data",
"insert_after": "mgmt_section", "description": "http://172.17.x.x:8080 ou IP simple"},
{"fieldname": "manage_port", "label": "Port gestion", "fieldtype": "Int",
"insert_after": "manage_url"},
{"fieldname": "manage_protocol", "label": "Protocole gestion", "fieldtype": "Select",
"options": "\nHTTP\nHTTPS\nSSH\nTelnet\nSNMP", "insert_after": "manage_port"},
{"fieldname": "mgmt_col", "fieldtype": "Column Break",
"insert_after": "manage_protocol"},
{"fieldname": "cli_ip", "label": "IP CLI", "fieldtype": "Data",
"insert_after": "mgmt_col"},
{"fieldname": "cli_port", "label": "Port CLI", "fieldtype": "Int",
"insert_after": "cli_ip"},
{"fieldname": "cli_protocol", "label": "Protocole CLI", "fieldtype": "Select",
"options": "\nSSH\nTelnet", "insert_after": "cli_port"},
# ── Device hierarchy ──
{"fieldname": "parent_device", "label": "Équipement parent", "fieldtype": "Link",
"options": "Service Equipment", "insert_after": "cli_protocol",
"description": "ONT parent pour routeurs/décodeurs, ou OLT pour ONTs"},
{"fieldname": "legacy_category", "label": "Catégorie legacy", "fieldtype": "Data",
"insert_after": "parent_device", "read_only": 1},
{"fieldname": "device_name_legacy", "label": "Nom legacy", "fieldtype": "Data",
"insert_after": "legacy_category", "read_only": 1},
# ── WiFi Provisioning ──
{"fieldname": "wifi_section", "label": "WiFi (provisionnement)", "fieldtype": "Section Break",
"insert_after": "device_name_legacy", "collapsible": 1},
{"fieldname": "wifi_ssid", "label": "SSID WiFi", "fieldtype": "Data",
"insert_after": "wifi_section"},
{"fieldname": "wifi_password", "label": "Mot de passe WiFi", "fieldtype": "Password",
"insert_after": "wifi_ssid"},
{"fieldname": "wifi_col", "fieldtype": "Column Break",
"insert_after": "wifi_password"},
{"fieldname": "wifi_ssid_5g", "label": "SSID WiFi 5GHz", "fieldtype": "Data",
"insert_after": "wifi_col", "description": "Si différent du 2.4GHz"},
{"fieldname": "wifi_enabled", "label": "WiFi activé", "fieldtype": "Check",
"insert_after": "wifi_ssid_5g", "default": "1"},
# ── VoIP Provisioning ──
{"fieldname": "voip_section", "label": "VoIP (provisionnement)", "fieldtype": "Section Break",
"insert_after": "wifi_enabled", "collapsible": 1},
{"fieldname": "sip_username", "label": "SIP Username", "fieldtype": "Data",
"insert_after": "voip_section"},
{"fieldname": "sip_password", "label": "SIP Password", "fieldtype": "Password",
"insert_after": "sip_username"},
{"fieldname": "voip_col", "fieldtype": "Column Break",
"insert_after": "sip_password"},
{"fieldname": "sip_line", "label": "Ligne SIP", "fieldtype": "Int",
"insert_after": "voip_col", "default": "1"},
# ── GPON / ACS ──
{"fieldname": "gpon_section", "label": "GPON / ACS", "fieldtype": "Section Break",
"insert_after": "sip_line", "collapsible": 1},
{"fieldname": "gpon_serial", "label": "GPON Serial (physique)", "fieldtype": "Data",
"insert_after": "gpon_section", "description": "RCMG/TPLG sur l'étiquette"},
{"fieldname": "cwmp_serial", "label": "CWMP Serial (GenieACS)", "fieldtype": "Data",
"insert_after": "gpon_serial", "description": "Serial interne TR-069"},
{"fieldname": "acs_device_id", "label": "ACS Device ID", "fieldtype": "Data",
"insert_after": "cwmp_serial", "description": "ID complet GenieACS/Oktopus"},
{"fieldname": "gpon_col", "fieldtype": "Column Break",
"insert_after": "acs_device_id"},
{"fieldname": "fibre_line_profile", "label": "Line Profile ID", "fieldtype": "Data",
"insert_after": "gpon_col"},
{"fieldname": "fibre_service_profile", "label": "Service Profile ID", "fieldtype": "Data",
"insert_after": "fibre_line_profile"},
{"fieldname": "acs_last_inform", "label": "Dernier inform ACS", "fieldtype": "Datetime",
"insert_after": "fibre_service_profile", "read_only": 1},
{"fieldname": "acs_online", "label": "En ligne (ACS)", "fieldtype": "Check",
"insert_after": "acs_last_inform", "read_only": 1},
{"fieldname": "wan_ip", "label": "IP WAN", "fieldtype": "Data",
"insert_after": "acs_online", "read_only": 1},
# ── IPTV ──
{"fieldname": "iptv_section", "label": "IPTV", "fieldtype": "Section Break",
"insert_after": "wan_ip", "collapsible": 1},
{"fieldname": "iptv_subscription_id", "label": "Ministra/IPTV Sub ID", "fieldtype": "Data",
"insert_after": "iptv_section"},
{"fieldname": "mac_addresses_json", "label": "MACs par interface (JSON)", "fieldtype": "Long Text",
"insert_after": "iptv_subscription_id", "read_only": 1,
"description": '{"eth0":"AA:BB:CC:DD:EE:FF","eth1":"..."}'},
]
# For Service Equipment (custom doctype), we need to check if these should be
# Custom Fields or direct DocType field additions.
# Since it was created as custom=1, Custom Fields work.
add_custom_fields("Service Equipment", equipment_fields)
# ═══════════════════════════════════════════════════════════════════════════
# 4. SERVICE LOCATION — Fibre infrastructure, VLANs, legacy IDs
# ═══════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("SERVICE LOCATION — Adding missing fields")
print("=" * 60)
location_fields = [
# ── OLT / Fibre detail ──
{"fieldname": "fibre_detail_section", "label": "Détails fibre", "fieldtype": "Section Break",
"insert_after": "network_notes", "collapsible": 1},
{"fieldname": "legacy_fibre_id", "label": "Fibre ID (legacy)", "fieldtype": "Int",
"insert_after": "fibre_detail_section"},
{"fieldname": "ont_serial", "label": "ONT Serial", "fieldtype": "Data",
"insert_after": "legacy_fibre_id"},
{"fieldname": "olt_ip", "label": "OLT IP", "fieldtype": "Data",
"insert_after": "ont_serial"},
{"fieldname": "olt_name", "label": "Nom OLT", "fieldtype": "Data",
"insert_after": "olt_ip"},
{"fieldname": "fibre_col1", "fieldtype": "Column Break",
"insert_after": "olt_name"},
{"fieldname": "ont_id", "label": "ONT ID", "fieldtype": "Int",
"insert_after": "fibre_col1"},
{"fieldname": "terrain_type", "label": "Type terrain", "fieldtype": "Data",
"insert_after": "ont_id"},
{"fieldname": "fibre_distance_m", "label": "Distance fibre (m)", "fieldtype": "Float",
"insert_after": "terrain_type"},
{"fieldname": "fibre_spans", "label": "Nombre de portées", "fieldtype": "Int",
"insert_after": "fibre_distance_m"},
{"fieldname": "install_time_estimate", "label": "Temps d'installation estimé", "fieldtype": "Data",
"insert_after": "fibre_spans"},
# ── VLANs (individual, replacing concatenated network_id) ──
{"fieldname": "vlan_section", "label": "VLANs", "fieldtype": "Section Break",
"insert_after": "install_time_estimate", "collapsible": 1},
{"fieldname": "vlan_manage", "label": "VLAN Gestion", "fieldtype": "Int",
"insert_after": "vlan_section"},
{"fieldname": "vlan_internet", "label": "VLAN Internet", "fieldtype": "Int",
"insert_after": "vlan_manage"},
{"fieldname": "vlan_col", "fieldtype": "Column Break",
"insert_after": "vlan_internet"},
{"fieldname": "vlan_telephone", "label": "VLAN Téléphone", "fieldtype": "Int",
"insert_after": "vlan_col"},
{"fieldname": "vlan_tv", "label": "VLAN Télévision", "fieldtype": "Int",
"insert_after": "vlan_telephone"},
# ── Building / Apartment ──
{"fieldname": "building_section", "label": "Immeuble", "fieldtype": "Section Break",
"insert_after": "vlan_tv", "collapsible": 1},
{"fieldname": "address_line_2", "label": "Adresse ligne 2", "fieldtype": "Data",
"insert_after": "building_section"},
{"fieldname": "apartment_number", "label": "Numéro d'appartement", "fieldtype": "Data",
"insert_after": "address_line_2"},
{"fieldname": "is_apartment", "label": "Est un appartement", "fieldtype": "Check",
"insert_after": "apartment_number"},
{"fieldname": "apartment_building_id", "label": "ID immeuble", "fieldtype": "Data",
"insert_after": "is_apartment"},
{"fieldname": "box_not_installed", "label": "Boîtier pas installé", "fieldtype": "Check",
"insert_after": "apartment_building_id"},
# ── Legacy service IDs (for cross-reference) ──
{"fieldname": "legacy_ids_section", "label": "IDs legacy (référence)", "fieldtype": "Section Break",
"insert_after": "box_not_installed", "collapsible": 1, "hidden": 1},
{"fieldname": "manage_service_id", "label": "Service gestion ID", "fieldtype": "Int",
"insert_after": "legacy_ids_section"},
{"fieldname": "internet_service_id", "label": "Service internet ID", "fieldtype": "Int",
"insert_after": "manage_service_id"},
{"fieldname": "telephone_service_id", "label": "Service téléphone ID", "fieldtype": "Int",
"insert_after": "internet_service_id"},
{"fieldname": "tv_service_id", "label": "Service TV ID", "fieldtype": "Int",
"insert_after": "telephone_service_id"},
{"fieldname": "placemarks_id", "label": "Placemarks ID", "fieldtype": "Data",
"insert_after": "tv_service_id"},
{"fieldname": "delivery_notes", "label": "Notes adresse", "fieldtype": "Small Text",
"insert_after": "access_notes"},
]
add_custom_fields("Service Location", location_fields)
# ═══════════════════════════════════════════════════════════════════════════
# 5. DISPATCH JOB — Second tech + time tracking
# ═══════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("DISPATCH JOB — Adding missing fields")
print("=" * 60)
dispatch_fields = [
{"fieldname": "second_technician", "label": "Deuxième technicien", "fieldtype": "Data",
"insert_after": "actual_end"},
{"fieldname": "tech1_arrival", "label": "Arrivée tech 1", "fieldtype": "Datetime",
"insert_after": "second_technician"},
{"fieldname": "tech1_departure", "label": "Départ tech 1", "fieldtype": "Datetime",
"insert_after": "tech1_arrival"},
{"fieldname": "tech2_arrival", "label": "Arrivée tech 2", "fieldtype": "Datetime",
"insert_after": "tech1_departure"},
{"fieldname": "tech2_departure", "label": "Départ tech 2", "fieldtype": "Datetime",
"insert_after": "tech2_arrival"},
]
add_custom_fields("Dispatch Job", dispatch_fields)
# ═══════════════════════════════════════════════════════════════════════════
# DONE
# ═══════════════════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("ALL CUSTOM FIELDS ADDED")
print("=" * 60)
print("Next steps:")
print(" 1. Run migrate_missing_data.py to populate the new fields")
print(" 2. Run migrate_provisioning_data.py for WiFi/VoIP from GenieACS")