From 7f5f7a523612f9a2002cd50bd7ade0eac783ed43 Mon Sep 17 00:00:00 2001 From: David Malinowski Date: Fri, 10 Apr 2026 16:37:32 +0200 Subject: [PATCH] feat: add IT Domain DocType with import wizard and enrichment - New DocType IT Domain for centralized domain management - Hetzner CSV import (domains + handles with registrant data) - DomainReselling.de API import (live domain + contact fetch) - DNS/HTTP/SSL enrichment (IP, MX, website title, SSL info) - Customer suggestion engine (handle, registrant, title, domain matching) - Interactive assignment dialog with bulk-assign - Colored badges for DNS hosting and MX routing in list view - German translations Co-Authored-By: Claude Opus 4.6 (1M context) --- msp/msp/doctype/it_domain/__init__.py | 0 msp/msp/doctype/it_domain/it_domain.js | 23 + msp/msp/doctype/it_domain/it_domain.json | 349 ++++++++++ msp/msp/doctype/it_domain/it_domain.py | 721 ++++++++++++++++++++ msp/msp/doctype/it_domain/it_domain_list.js | 431 ++++++++++++ msp/msp/doctype/it_domain/test_it_domain.py | 6 + 6 files changed, 1530 insertions(+) create mode 100644 msp/msp/doctype/it_domain/__init__.py create mode 100644 msp/msp/doctype/it_domain/it_domain.js create mode 100644 msp/msp/doctype/it_domain/it_domain.json create mode 100644 msp/msp/doctype/it_domain/it_domain.py create mode 100644 msp/msp/doctype/it_domain/it_domain_list.js create mode 100644 msp/msp/doctype/it_domain/test_it_domain.py diff --git a/msp/msp/doctype/it_domain/__init__.py b/msp/msp/doctype/it_domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/doctype/it_domain/it_domain.js b/msp/msp/doctype/it_domain/it_domain.js new file mode 100644 index 0000000..dd13b98 --- /dev/null +++ b/msp/msp/doctype/it_domain/it_domain.js @@ -0,0 +1,23 @@ +frappe.ui.form.on("IT Domain", { + refresh(frm) { + if (!frm.is_new()) { + frm.add_custom_button("Enrich (DNS/HTTP/SSL)", () => { + frappe.call({ + method: "msp.msp.doctype.it_domain.it_domain.enrich_domain", + args: { name: frm.doc.name }, + freeze: true, + freeze_message: `Resolving ${frm.doc.domain}...`, + callback(r) { + frm.reload_doc(); + if (r.message) { + frappe.show_alert({ + message: `${frm.doc.domain}: ${r.message.website_title || "kein Titel"} (${r.message.resolved_ip || "keine IP"})`, + indicator: "green", + }); + } + }, + }); + }); + } + }, +}); diff --git a/msp/msp/doctype/it_domain/it_domain.json b/msp/msp/doctype/it_domain/it_domain.json new file mode 100644 index 0000000..e539ac1 --- /dev/null +++ b/msp/msp/doctype/it_domain/it_domain.json @@ -0,0 +1,349 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:domain", + "creation": "2026-04-09 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "domain", + "customer", + "it_landscape", + "column_break_main", + "domain_type", + "dns_hosting", + "mail_routing", + "is_email_domain", + "is_hosting_domain", + "registrar_section", + "registrar", + "registration_date", + "expiry_date", + "column_break_registrar", + "auto_renew", + "dns_provider", + "nameservers", + "dns_section", + "resolved_ip", + "mx_records", + "column_break_dns", + "website_title", + "ssl_issuer", + "ssl_expiry", + "last_enriched", + "registrant_section", + "registrant_name", + "registrant_email", + "column_break_registrant", + "registrant_address", + "handles_section", + "handle_holder", + "handle_admin_c", + "column_break_handles", + "handle_tech_c", + "handle_zone_c", + "hosting_section", + "hosted_on", + "it_contract", + "notes_section", + "notes" + ], + "fields": [ + { + "fieldname": "domain", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Domain", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Customer", + "options": "Customer" + }, + { + "fieldname": "it_landscape", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "IT Landscape", + "options": "IT Landscape" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "default": "Primary", + "fieldname": "domain_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Domain Type", + "options": "Primary\nAlias\nInternal\nDev" + }, + { + "fieldname": "dns_hosting", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "DNS", + "options": "\nitsdave\nCloudflare\nHetzner\nINWX\nExtern", + "read_only": 1 + }, + { + "fieldname": "mail_routing", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "MX", + "options": "\nHornetsecurity\nMicrosoft 365\nGoogle\nSelf-hosted\nKein MX", + "read_only": 1 + }, + { + "default": "0", + "description": "Domain wird fuer E-Mail-Zuordnung bei der Arbeitszeitermittlung genutzt", + "fieldname": "is_email_domain", + "fieldtype": "Check", + "label": "Is Email Domain" + }, + { + "default": "0", + "description": "Domain wird fuer Hosting-Abrechnung genutzt", + "fieldname": "is_hosting_domain", + "fieldtype": "Check", + "label": "Is Hosting Domain" + }, + { + "fieldname": "registrar_section", + "fieldtype": "Section Break", + "label": "Registrar & DNS" + }, + { + "fieldname": "registrar", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Registrar" + }, + { + "fieldname": "registration_date", + "fieldtype": "Date", + "label": "Registration Date" + }, + { + "fieldname": "expiry_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Expiry Date" + }, + { + "fieldname": "column_break_registrar", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "auto_renew", + "fieldtype": "Check", + "label": "Auto Renew" + }, + { + "fieldname": "dns_provider", + "fieldtype": "Data", + "label": "DNS Provider" + }, + { + "fieldname": "nameservers", + "fieldtype": "Small Text", + "label": "Nameservers", + "read_only": 1 + }, + { + "depends_on": "eval:doc.resolved_ip || doc.website_title", + "fieldname": "dns_section", + "fieldtype": "Section Break", + "label": "Resolved Info" + }, + { + "fieldname": "resolved_ip", + "fieldtype": "Data", + "label": "IP Address", + "read_only": 1 + }, + { + "fieldname": "mx_records", + "fieldtype": "Small Text", + "label": "MX Records", + "read_only": 1 + }, + { + "fieldname": "column_break_dns", + "fieldtype": "Column Break" + }, + { + "fieldname": "website_title", + "fieldtype": "Data", + "label": "Website Title", + "read_only": 1 + }, + { + "fieldname": "ssl_issuer", + "fieldtype": "Data", + "label": "SSL Issuer", + "read_only": 1 + }, + { + "fieldname": "ssl_expiry", + "fieldtype": "Data", + "label": "SSL Expiry", + "read_only": 1 + }, + { + "fieldname": "last_enriched", + "fieldtype": "Datetime", + "label": "Last Enriched", + "read_only": 1 + }, + { + "depends_on": "eval:doc.registrant_name", + "fieldname": "registrant_section", + "fieldtype": "Section Break", + "label": "Registrant" + }, + { + "fieldname": "registrant_name", + "fieldtype": "Data", + "label": "Registrant Name", + "read_only": 1 + }, + { + "fieldname": "registrant_email", + "fieldtype": "Data", + "label": "Registrant Email", + "options": "Email", + "read_only": 1 + }, + { + "fieldname": "column_break_registrant", + "fieldtype": "Column Break" + }, + { + "fieldname": "registrant_address", + "fieldtype": "Small Text", + "label": "Registrant Address", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "handles_section", + "fieldtype": "Section Break", + "label": "Registry Handles" + }, + { + "description": "Registrant / Domain-Inhaber — dient zur Kunden-Gruppierung", + "fieldname": "handle_holder", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Handle Holder" + }, + { + "fieldname": "handle_admin_c", + "fieldtype": "Data", + "label": "Handle Admin-c" + }, + { + "fieldname": "column_break_handles", + "fieldtype": "Column Break" + }, + { + "fieldname": "handle_tech_c", + "fieldtype": "Data", + "label": "Handle Tech-c" + }, + { + "fieldname": "handle_zone_c", + "fieldtype": "Data", + "label": "Handle Zone-c" + }, + { + "collapsible": 1, + "fieldname": "hosting_section", + "fieldtype": "Section Break", + "label": "Hosting" + }, + { + "fieldname": "hosted_on", + "fieldtype": "Link", + "label": "Hosted On", + "options": "IT Object" + }, + { + "fieldname": "it_contract", + "fieldtype": "Link", + "label": "IT Contract", + "options": "IT Contract" + }, + { + "fieldname": "notes_section", + "fieldtype": "Section Break", + "label": "Notes" + }, + { + "fieldname": "notes", + "fieldtype": "Text Editor", + "label": "Notes" + } + ], + "links": [], + "modified": "2026-04-09 23:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "IT Domain", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "MSP User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "MSP Admin", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "search_fields": "customer,domain_type,handle_holder", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "domain", + "track_changes": 1 +} diff --git a/msp/msp/doctype/it_domain/it_domain.py b/msp/msp/doctype/it_domain/it_domain.py new file mode 100644 index 0000000..5250ddf --- /dev/null +++ b/msp/msp/doctype/it_domain/it_domain.py @@ -0,0 +1,721 @@ +import csv +import io +import re +import urllib.request +import urllib.parse +from datetime import datetime + +import frappe +from frappe.model.document import Document + + +class ITDomain(Document): + def validate(self): + self.domain = self.domain.strip().lower() + if self.domain.startswith("http://") or self.domain.startswith("https://"): + frappe.throw("Domain darf kein Protokoll-Prefix enthalten (http:// oder https://)") + + +@frappe.whitelist() +def import_hetzner_csv(file_content): + """Import domains from Hetzner Registration Robot CSV export. + + Idempotent: existing domains are updated, new ones created. + Returns summary dict with created/updated/skipped counts. + """ + reader = csv.DictReader( + io.StringIO(file_content), + delimiter=";", + quotechar='"', + ) + + created = 0 + updated = 0 + skipped = 0 + errors = [] + + for row in reader: + domain = row.get("Domain", "").strip().lower() + if not domain: + skipped += 1 + continue + + try: + # Parse Hetzner date format DD.MM.YY -> Date + registration_date = _parse_hetzner_date(row.get("Erstellt", "")) + expiry_date = _parse_hetzner_date(row.get("abgerechnet bis", "")) + cancellation = row.get("Kündigung", "").strip() + + # Collect nameservers + nameservers = [] + for i in range(1, 6): + ns = row.get(f"{i}. Nameserver", "").strip() + if ns: + nameservers.append(ns) + + # Determine DNS provider from nameservers + dns_provider = _detect_dns_provider(nameservers) + + # Extract handles + handle_holder = row.get("Handle Holder", "").strip() + handle_admin_c = ( + row.get("Handle Admin-c", "").strip() + or row.get("Handle General-c", "").strip() + ) + handle_tech_c = ( + row.get("Handle Tech-c", "").strip() + or row.get("Handle Abuse-c", "").strip() + ) + handle_zone_c = row.get("Handle Zone-c", "").strip() + + ns_text = "\n".join(nameservers) + field_map = { + "registrar": "Hetzner", + "registration_date": registration_date, + "expiry_date": expiry_date, + "dns_provider": dns_provider, + "dns_hosting": _classify_dns(ns_text), + "auto_renew": 0 if cancellation else 1, + "nameservers": ns_text, + "handle_holder": handle_holder, + "handle_admin_c": handle_admin_c, + "handle_tech_c": handle_tech_c, + "handle_zone_c": handle_zone_c, + "is_hosting_domain": 1 if _is_itsdave_hosted(nameservers) else 0, + } + + if frappe.db.exists("IT Domain", domain): + doc = frappe.get_doc("IT Domain", domain) + changed = False + + for field, value in field_map.items(): + if value is not None and str(doc.get(field) or "") != str(value): + doc.set(field, value) + changed = True + + if changed: + doc.flags.ignore_permissions = True + doc.save() + updated += 1 + else: + skipped += 1 + else: + doc = frappe.get_doc({ + "doctype": "IT Domain", + "domain": domain, + "domain_type": "Primary", + **field_map, + }) + doc.flags.ignore_permissions = True + doc.insert() + created += 1 + + except Exception as e: + errors.append(f"{domain}: {str(e)}") + + frappe.db.commit() + + return { + "created": created, + "updated": updated, + "skipped": skipped, + "total": created + updated + skipped, + "errors": errors, + } + + +def _parse_hetzner_date(date_str): + """Parse DD.MM.YY to YYYY-MM-DD string.""" + date_str = date_str.strip() + if not date_str: + return None + try: + dt = datetime.strptime(date_str, "%d.%m.%y") + return dt.strftime("%Y-%m-%d") + except ValueError: + return None + + +def _detect_dns_provider(nameservers): + """Detect DNS provider from nameserver hostnames.""" + ns_str = " ".join(nameservers).lower() + if "cloudflare" in ns_str: + return "Cloudflare" + elif "itsdave" in ns_str: + return "itsdave (self-hosted)" + elif "hetzner" in ns_str: + return "Hetzner" + elif "inwx" in ns_str: + return "INWX" + elif nameservers: + return nameservers[0] + return None + + +def _is_itsdave_hosted(nameservers): + """Check if domain is hosted on itsdave servers.""" + return any("itsdave" in ns.lower() for ns in nameservers) + + +@frappe.whitelist() +def import_hetzner_handles_csv(file_content): + """Import Hetzner handles CSV and update IT Domains with registrant data. + + Reads the handles CSV, builds a handle→registrant map, then updates all + IT Domains that reference those handles. + """ + reader = csv.DictReader( + io.StringIO(file_content), + delimiter=";", + quotechar='"', + ) + + handle_map = {} + for row in reader: + handle = row.get("Handle", "").strip() + if not handle: + continue + + org = row.get("Organisation", "").strip() + nachname = row.get("Nachname", "").strip() + vorname = row.get("Vorname", "").strip() + name = org or f"{vorname} {nachname}".strip() + + email = row.get("E-Mail", "").strip() + # Clean mailto: URIs + if email.startswith("mailto:"): + email = email.split("mailto:")[1].split("?")[0] + + parts = [] + street = row.get("Straße", "").strip() + plz = row.get("PLZ", "").strip() + ort = row.get("Ort", "").strip() + land = row.get("Land", "").strip() + if street: + parts.append(street) + if plz or ort: + parts.append(f"{plz} {ort}".strip()) + if land and land != "DE": + parts.append(land) + + handle_map[handle] = { + "name": name, + "email": email, + "address": "\n".join(parts), + } + + # Update all IT Domains that have matching handle_holder + updated = 0 + domains = frappe.get_all( + "IT Domain", + filters={"handle_holder": ["is", "set"]}, + fields=["name", "handle_holder"], + ) + + for dom in domains: + registrant = handle_map.get(dom.handle_holder) + if not registrant or not registrant["name"]: + continue + + doc = frappe.get_doc("IT Domain", dom.name) + changed = False + for field, key in [ + ("registrant_name", "name"), + ("registrant_email", "email"), + ("registrant_address", "address"), + ]: + val = registrant[key] + if val and str(doc.get(field) or "") != val: + doc.set(field, val) + changed = True + + if changed: + doc.flags.ignore_permissions = True + doc.save() + updated += 1 + + frappe.db.commit() + return {"handles_parsed": len(handle_map), "domains_updated": updated} + + +@frappe.whitelist() +def enrich_domain(name): + """Enrich a single IT Domain with DNS, HTTP, and SSL data.""" + import re + import socket + import ssl + import subprocess + import urllib.request + + doc = frappe.get_doc("IT Domain", name) + domain = doc.domain + + # DNS A record + try: + out = subprocess.check_output( + ["dig", "+short", "A", domain], timeout=5 + ).decode().strip() + ips = [line for line in out.split("\n") if line and not line.endswith(".")] + doc.resolved_ip = ips[0] if ips else None + except Exception: + doc.resolved_ip = None + + # DNS MX records + try: + out = subprocess.check_output( + ["dig", "+short", "MX", domain], timeout=5 + ).decode().strip() + doc.mx_records = out if out else None + except Exception: + doc.mx_records = None + + # HTTP title + doc.website_title = None + for scheme in ("https", "http"): + try: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + req = urllib.request.Request( + f"{scheme}://{domain}/", + headers={"User-Agent": "Mozilla/5.0"}, + ) + resp = urllib.request.urlopen(req, timeout=5, context=ctx) + body = resp.read(8192).decode("utf-8", errors="ignore") + title = re.search( + r"]*>(.*?)", body, re.IGNORECASE | re.DOTALL + ) + if title: + doc.website_title = title.group(1).strip()[:140] + break + except Exception: + continue + + # SSL certificate info + try: + ctx = ssl.create_default_context() + with ctx.wrap_socket(socket.socket(), server_hostname=domain) as s: + s.settimeout(5) + s.connect((domain, 443)) + cert = s.getpeercert() + doc.ssl_issuer = dict(x[0] for x in cert["issuer"]).get( + "organizationName" + ) + doc.ssl_expiry = cert.get("notAfter") + except Exception: + doc.ssl_issuer = None + doc.ssl_expiry = None + + # Classify DNS hosting from nameservers + doc.dns_hosting = _classify_dns(doc.nameservers or "") + + # Classify mail routing from MX records + doc.mail_routing = _classify_mail(doc.mx_records or "") + + doc.last_enriched = frappe.utils.now() + doc.flags.ignore_permissions = True + doc.save() + frappe.db.commit() + + return { + "resolved_ip": doc.resolved_ip, + "mx_records": doc.mx_records, + "website_title": doc.website_title, + "ssl_issuer": doc.ssl_issuer, + "ssl_expiry": doc.ssl_expiry, + "dns_hosting": doc.dns_hosting, + "mail_routing": doc.mail_routing, + } + + +def _classify_dns(nameservers_text): + ns = nameservers_text.lower() + if "itsdave" in ns: + return "itsdave" + if "cloudflare" in ns: + return "Cloudflare" + if "hetzner" in ns: + return "Hetzner" + if "inwx" in ns: + return "INWX" + if ns.strip(): + return "Extern" + return "" + + +def _classify_mail(mx_text): + mx = mx_text.lower() + if not mx.strip(): + return "Kein MX" + if "hornetsecurity" in mx: + return "Hornetsecurity" + if "outlook.com" in mx or "microsoft" in mx: + return "Microsoft 365" + if "google" in mx or "googlemail" in mx or "gmail" in mx: + return "Google" + # Self-hosted: MX points to same or related domain + return "Self-hosted" + + +@frappe.whitelist() +def enrich_all_domains(): + """Enrich all IT Domains that haven't been enriched yet (background job).""" + domains = frappe.get_all( + "IT Domain", + filters={"last_enriched": ["is", "not set"]}, + pluck="name", + ) + total = len(domains) + for i, name in enumerate(domains): + try: + enrich_domain(name) + except Exception as e: + frappe.log_error(f"Enrich failed for {name}: {e}") + if i % 10 == 0: + frappe.publish_realtime( + "enrich_progress", + {"current": i + 1, "total": total}, + ) + return {"enriched": total} + + +_NOISE_WORDS = frozenset( + "gmbh gbr ohg kg co e.k. ug ag inc ltd " + "und and the der die das von zu".split() +) + + +def _significant_words(text): + """Extract significant words (3+ chars, no legal forms) from text.""" + return { + w for w in text.lower().split() + if len(w) >= 3 and w not in _NOISE_WORDS + } + + +def _score_match(source_text, customer_name): + """Score how well source_text matches customer_name. Returns 0-1.""" + src = source_text.lower().strip() + cust = customer_name.lower().strip() + if not src or not cust: + return 0 + + # Exact containment + if cust in src or src in cust: + return min(len(src), len(cust)) / max(len(src), len(cust)) + + # Word overlap + src_words = _significant_words(src) + cust_words = _significant_words(cust) + if not cust_words: + return 0 + overlap = src_words & cust_words + if overlap: + return len(overlap) / len(cust_words) + return 0 + + +@frappe.whitelist() +def suggest_customers(): + """Match unassigned domains to customers using all available signals. + + Priority: Handle propagation > Registrant name > Website title > Domain name. + Returns list of suggestions sorted by confidence. + """ + domains = frappe.get_all( + "IT Domain", + filters={"customer": ["is", "not set"]}, + fields=[ + "name", "domain", "website_title", "handle_holder", + "registrant_name", "registrant_email", "registrant_address", + ], + ) + + customers = frappe.get_all("Customer", fields=["name", "customer_name"]) + + # Handle → customer from already-assigned domains + handle_map = {} + for row in frappe.get_all( + "IT Domain", + filters={"customer": ["is", "set"], "handle_holder": ["is", "set"]}, + fields=["handle_holder", "customer"], + ): + handle_map[row.handle_holder] = row.customer + + suggestions = [] + for dom in domains: + suggestion = _match_domain(dom, customers, handle_map) + if suggestion: + suggestions.append(suggestion) + + # Sort: high first, then medium, then low + order = {"high": 0, "medium": 1, "low": 2} + suggestions.sort(key=lambda s: (order.get(s["confidence"], 9), s["domain"])) + return suggestions + + +def _match_domain(dom, customers, handle_map): + """Try to match a single domain against customers. Returns best match or None.""" + # 1. Handle propagation (highest confidence) + if dom.handle_holder and dom.handle_holder in handle_map: + return { + "domain": dom.name, + "suggested_customer": handle_map[dom.handle_holder], + "match_type": "Handle", + "confidence": "high", + "registrant_name": dom.registrant_name, + "registrant_address": dom.registrant_address, + } + + # 2. Registrant name match (very reliable) + if dom.registrant_name: + match, score = _best_customer_match(dom.registrant_name, customers) + if match: + return { + "domain": dom.name, + "suggested_customer": match, + "match_type": "Registrant", + "confidence": "high" if score > 0.5 else "medium", + "registrant_name": dom.registrant_name, + "registrant_address": dom.registrant_address, + } + + # 3. Website title match + if dom.website_title: + match, score = _best_customer_match(dom.website_title, customers) + if match: + return { + "domain": dom.name, + "suggested_customer": match, + "match_type": "Website", + "confidence": "high" if score > 0.6 else "medium", + "registrant_name": dom.registrant_name, + "registrant_address": dom.registrant_address, + } + + # 4. Domain name match (lowest confidence) + domain_base = dom.domain.rsplit(".", 1)[0].replace("-", " ").replace(".", " ") + match, score = _best_customer_match(domain_base, customers) + if match and score >= 0.3: + return { + "domain": dom.name, + "suggested_customer": match, + "match_type": "Domain", + "confidence": "low", + "registrant_name": dom.registrant_name, + "registrant_address": dom.registrant_address, + } + + # 5. No match — still return for manual assignment + return { + "domain": dom.name, + "suggested_customer": None, + "match_type": None, + "confidence": None, + "registrant_name": dom.registrant_name, + "registrant_address": dom.registrant_address, + } + + +def _best_customer_match(text, customers): + """Find best matching customer for given text. Returns (customer_name, score).""" + best_match = None + best_score = 0 + for cust in customers: + score = _score_match(text, cust.customer_name) + if score > best_score: + best_score = score + best_match = cust.name + return (best_match, best_score) if best_score >= 0.25 else (None, 0) + + +# --- DomainReselling.de API integration --- + +_DR_API_URL = "https://api.domainreselling.de/api/call.cgi" + + +def _dr_api_call(command, login, password, **params): + """Call DomainReselling.de API (read-only). Returns parsed response dict.""" + data = { + "s_login": login, + "s_pw": password, + "command": command, + **params, + } + encoded = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(_DR_API_URL, data=encoded) + resp = urllib.request.urlopen(req, timeout=30) + body = resp.read().decode("utf-8") + + # Parse the key-value response + result = {"properties": {}} + for line in body.strip().split("\n"): + line = line.strip() + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip().lower() + value = value.strip() + + if key == "code": + result["code"] = int(value) + elif key == "description": + result["description"] = value + elif key.startswith("property["): + # Parse property[fieldname][index] = value + m = re.match(r"property\[(\w+)\]\[(\d+)\]", key) + if m: + field, idx = m.group(1), int(m.group(2)) + result["properties"].setdefault(field, {}) + result["properties"][field][idx] = value + + if result.get("code", 0) != 200: + frappe.throw(f"DomainReselling API Error: {result.get('description', 'Unknown')}") + + return result + + +@frappe.whitelist() +def import_domainreselling(login, password): + """Import domains from DomainReselling.de API. + + Fetches all domains with details, resolves contact handles, and creates/updates + IT Domain records. Read-only API calls only. + """ + # Step 1: Fetch all domains + resp = _dr_api_call("QueryDomainList", login, password, first="0", limit="500", wide="1") + props = resp["properties"] + total = int(props.get("total", {}).get(0, 0)) + + if not total: + return {"created": 0, "updated": 0, "skipped": 0, "total": 0, "errors": []} + + # Step 2: Collect unique contact handles + contact_handles = set() + for i in range(total): + handle = props.get("domainownercontact", {}).get(i, "") + if handle: + contact_handles.add(handle) + + # Step 3: Resolve contact handles to names/addresses + contact_map = {} + for handle in contact_handles: + try: + c = _dr_api_call("StatusContact", login, password, contact=handle) + cp = c["properties"] + org = cp.get("organization", {}).get(0, "") + name = org or cp.get("name", {}).get(0, "") + email = cp.get("email", {}).get(0, "") + street = cp.get("street", {}).get(0, "") + zip_code = cp.get("zip", {}).get(0, "") + city = cp.get("city", {}).get(0, "") + + addr_parts = [] + if street: + addr_parts.append(street) + if zip_code or city: + addr_parts.append(f"{zip_code} {city}".strip()) + + contact_map[handle] = { + "name": name, + "email": email, + "address": "\n".join(addr_parts), + } + except Exception: + contact_map[handle] = {"name": "", "email": "", "address": ""} + + # Step 4: Import domains + created = 0 + updated = 0 + skipped = 0 + errors = [] + + for i in range(total): + domain = props.get("domain", {}).get(i, "").strip().lower() + if not domain: + continue + + try: + # Parse dates (format: 2023-07-06 15:17:50) + created_date = _parse_dr_date(props.get("domaincreateddate", {}).get(i, "")) + expiry_date = _parse_dr_date( + props.get("domainregistrationexpirationdate", {}).get(i, "") + ) + + # Nameservers + ns_list = [] + for ns_idx in range(5): + ns = props.get(f"nameserver{ns_idx}", {}).get(i, "") + if ns: + ns_list.append(ns) + ns_text = "\n".join(ns_list) + + # Contact + owner_handle = props.get("domainownercontact", {}).get(i, "") + admin_handle = props.get("domainadmincontact", {}).get(i, "") + tech_handle = props.get("domaintechcontact", {}).get(i, "") + registrant = contact_map.get(owner_handle, {}) + + # Renewal mode + renewal_mode = props.get("domainrenewalmode", {}).get(i, "") + auto_renew = 1 if renewal_mode == "AUTORENEW" else 0 + + field_map = { + "registrar": "DomainReselling.de", + "registration_date": created_date, + "expiry_date": expiry_date, + "dns_provider": _detect_dns_provider(ns_list), + "dns_hosting": _classify_dns(ns_text), + "auto_renew": auto_renew, + "nameservers": ns_text, + "handle_holder": owner_handle, + "handle_admin_c": admin_handle, + "handle_tech_c": tech_handle, + "registrant_name": registrant.get("name", ""), + "registrant_email": registrant.get("email", ""), + "registrant_address": registrant.get("address", ""), + "is_hosting_domain": 1 if _is_itsdave_hosted(ns_list) else 0, + } + + if frappe.db.exists("IT Domain", domain): + doc = frappe.get_doc("IT Domain", domain) + changed = False + for field, value in field_map.items(): + if value is not None and str(doc.get(field) or "") != str(value): + doc.set(field, value) + changed = True + if changed: + doc.flags.ignore_permissions = True + doc.save() + updated += 1 + else: + skipped += 1 + else: + doc = frappe.get_doc({ + "doctype": "IT Domain", + "domain": domain, + "domain_type": "Primary", + **field_map, + }) + doc.flags.ignore_permissions = True + doc.insert() + created += 1 + + except Exception as e: + errors.append(f"{domain}: {str(e)}") + + frappe.db.commit() + return { + "created": created, + "updated": updated, + "skipped": skipped, + "total": created + updated + skipped, + "errors": errors, + } + + +def _parse_dr_date(date_str): + """Parse DomainReselling date (YYYY-MM-DD HH:MM:SS) to YYYY-MM-DD.""" + if not date_str or date_str.startswith("0000"): + return None + return date_str.split(" ")[0] diff --git a/msp/msp/doctype/it_domain/it_domain_list.js b/msp/msp/doctype/it_domain/it_domain_list.js new file mode 100644 index 0000000..b631507 --- /dev/null +++ b/msp/msp/doctype/it_domain/it_domain_list.js @@ -0,0 +1,431 @@ +frappe.listview_settings["IT Domain"] = { + onload(listview) { + listview.page.add_inner_button("Domain Import Wizard", () => { + show_import_wizard(listview); + }); + + listview.page.add_inner_button("Kunden zuordnen", () => { + show_assignment_dialog(listview); + }); + + listview.page.add_inner_button("Alle anreichern (DNS/HTTP)", () => { + frappe.confirm( + "Alle Domains ohne bisherige Enrichment-Daten per DNS und HTTP abfragen? Das kann einige Minuten dauern.", + () => { + frappe.call({ + method: "msp.msp.doctype.it_domain.it_domain.enrich_all_domains", + freeze: true, + freeze_message: "Domains werden abgefragt...", + callback(r) { + if (r.message) { + frappe.msgprint({ + title: "Enrichment", + message: `${r.message.enriched} Domains angereichert.`, + indicator: "green", + }); + listview.refresh(); + } + }, + }); + } + ); + }); + }, + + get_indicator(doc) { + if (!doc.customer) { + return ["Nicht zugeordnet", "orange", "customer,is,not set"]; + } + const now = frappe.datetime.nowdate(); + const in30 = frappe.datetime.add_days(now, 30); + if (doc.expiry_date && doc.expiry_date < now) { + return ["Abgelaufen", "red", `expiry_date,<,${now}`]; + } + if (doc.expiry_date && doc.expiry_date <= in30) { + return ["Läuft bald ab", "orange", `expiry_date,<=,${in30}`]; + } + }, + + refresh() { + if (document.getElementById("it-domain-badges-css")) return; + const style = document.createElement("style"); + style.id = "it-domain-badges-css"; + style.textContent = ` + /* DNS badges */ + .indicator-pill[data-filter*="dns_hosting"][data-filter*="itsdave"] { + background: #e8f5e9 !important; color: #2e7d32 !important; + } + .indicator-pill[data-filter*="dns_hosting"][data-filter*="Cloudflare"] { + background: #fff3e0 !important; color: #e65100 !important; + } + .indicator-pill[data-filter*="dns_hosting"][data-filter*="Hetzner"], + .indicator-pill[data-filter*="dns_hosting"][data-filter*="INWX"] { + background: #e3f2fd !important; color: #1565c0 !important; + } + /* Mail badges */ + .indicator-pill[data-filter*="mail_routing"][data-filter*="Hornetsecurity"] { + background: #c6f421 !important; color: #1a1a1a !important; + } + .indicator-pill[data-filter*="mail_routing"][data-filter*="Microsoft"] { + background: #e3f2fd !important; color: #0078d4 !important; + } + .indicator-pill[data-filter*="mail_routing"][data-filter*="Google"] { + background: #fce4ec !important; color: #c62828 !important; + } + .indicator-pill[data-filter*="mail_routing"][data-filter*="Self-hosted"] { + background: #f3e5f5 !important; color: #6a1b9a !important; + } + `; + document.head.appendChild(style); + }, +}; + + +function show_import_wizard(listview) { + const dialog = new frappe.ui.Dialog({ + title: "Domain Import Wizard", + size: "large", + fields: [ + { + fieldname: "source", + fieldtype: "Select", + label: "Quelle", + options: "Hetzner (CSV)\nDomainReselling.de (API)", + default: "Hetzner (CSV)", + reqd: 1, + change() { + const val = dialog.get_value("source"); + const is_hetzner = val.startsWith("Hetzner"); + dialog.fields_dict.hetzner_section.$wrapper.toggle(is_hetzner); + dialog.fields_dict.dr_section.$wrapper.toggle(!is_hetzner); + }, + }, + { + fieldname: "hetzner_section", + fieldtype: "Section Break", + label: "Hetzner CSV-Dateien", + }, + { + fieldname: "domain_csv", + fieldtype: "Attach", + label: "Domain-CSV", + }, + { + fieldname: "handle_csv", + fieldtype: "Attach", + label: "Handle-CSV (optional)", + }, + { + fieldname: "dr_section", + fieldtype: "Section Break", + label: "DomainReselling.de Zugangsdaten", + hidden: 1, + }, + { + fieldname: "dr_login", + fieldtype: "Data", + label: "Login", + }, + { + fieldname: "dr_password", + fieldtype: "Password", + label: "Passwort", + }, + { fieldtype: "Section Break" }, + { + fieldname: "result_html", + fieldtype: "HTML", + }, + ], + primary_action_label: "Importieren", + primary_action(values) { + dialog.disable_primary_action(); + const is_hetzner = values.source.startsWith("Hetzner"); + + if (is_hetzner) { + if (!values.domain_csv) { + frappe.msgprint("Bitte Domain-CSV hochladen"); + dialog.enable_primary_action(); + return; + } + run_hetzner_import(dialog, listview, values); + } else { + if (!values.dr_login || !values.dr_password) { + frappe.msgprint("Bitte Login und Passwort eingeben"); + dialog.enable_primary_action(); + return; + } + run_dr_import(dialog, listview, values); + } + }, + }); + dialog.show(); +} + + +function run_hetzner_import(dialog, listview, values) { + fetch_file_and_call( + values.domain_csv, + "msp.msp.doctype.it_domain.it_domain.import_hetzner_csv", + "file_content" + ).then((domain_result) => { + let html = format_import_result("Hetzner Domain-Import", domain_result); + + const handle_promise = values.handle_csv + ? fetch_file_and_call( + values.handle_csv, + "msp.msp.doctype.it_domain.it_domain.import_hetzner_handles_csv", + "file_content" + ) + : Promise.resolve(null); + + return handle_promise.then((handle_result) => { + if (handle_result) { + html += format_import_result("Handle-Import", handle_result); + } + finish_import(dialog, listview, html); + }); + }).catch((err) => { + dialog.enable_primary_action(); + frappe.msgprint({ title: "Fehler", message: String(err), indicator: "red" }); + }); +} + + +function run_dr_import(dialog, listview, values) { + frappe.call({ + method: "msp.msp.doctype.it_domain.it_domain.import_domainreselling", + args: { login: values.dr_login, password: values.dr_password }, + callback(r) { + if (r.message) { + const html = format_import_result("DomainReselling.de Import", r.message); + finish_import(dialog, listview, html); + } + }, + error() { + dialog.enable_primary_action(); + }, + }); +} + + +function finish_import(dialog, listview, html) { + html += `
+ +
`; + dialog.fields_dict.result_html.$wrapper.html(html); + dialog.set_primary_action("Schliessen", () => { + dialog.hide(); + listview.refresh(); + }); + dialog.$wrapper.find(".wizard-next-btn").on("click", () => { + dialog.hide(); + listview.refresh(); + show_assignment_dialog(listview); + }); +} + + +function show_assignment_dialog(listview) { + frappe.call({ + method: "msp.msp.doctype.it_domain.it_domain.suggest_customers", + freeze: true, + freeze_message: "Analysiere Domains...", + callback(r) { + if (!r.message || !r.message.length) { + frappe.msgprint("Alle Domains sind bereits zugeordnet."); + return; + } + + const all = r.message; + const high = all.filter(s => s.confidence === "high"); + const rest = all.filter(s => s.confidence !== "high"); + + let rows = all.map((s, idx) => { + const conf_badge = s.confidence + ? `${s.confidence}` + : ""; + + const registrant = s.registrant_name + ? `
${frappe.utils.escape_html(s.registrant_name)}` + : ""; + + const customer_cell = s.suggested_customer + ? `${s.suggested_customer} + ` + : ``; + + const actions = s.suggested_customer + ? ` + ` + : ""; + + return ` + ${s.domain}${registrant} + ${customer_cell} + ${s.match_type || ""} ${conf_badge} + ${actions} + `; + }); + + const dialog = new frappe.ui.Dialog({ + title: `Kundenzuordnung — ${all.length} Domains offen`, + size: "extra-large", + fields: [{ + fieldtype: "HTML", + fieldname: "assign_html", + options: ` + ${high.length ? `
+ +
` : ""} +
+ + + + + + + + ${rows.join("")} +
Domain / InhaberKundeMatch
+
`, + }], + primary_action_label: "Schliessen", + primary_action() { + dialog.hide(); + listview.refresh(); + }, + }); + + // Single assign + dialog.$wrapper.on("click", ".apply-one", function () { + const idx = $(this).data("idx"); + const s = all[idx]; + if (!s.suggested_customer) return; + apply_single(s.domain, s.suggested_customer, $(this).closest("tr")); + }); + + // Skip + dialog.$wrapper.on("click", ".skip-one", function () { + $(this).closest("tr").fadeOut(200); + }); + + // Pick / change customer + dialog.$wrapper.on("click", ".pick-customer, .change-customer", function () { + const idx = $(this).data("idx"); + const s = all[idx]; + frappe.prompt( + { + fieldname: "customer", + fieldtype: "Link", + options: "Customer", + label: `Kunde fuer ${s.domain}`, + description: s.registrant_name ? `Inhaber: ${s.registrant_name}` : "", + }, + (values) => { + if (values.customer) { + s.suggested_customer = values.customer; + apply_single(s.domain, values.customer, dialog.$wrapper.find(`tr[data-idx="${idx}"]`)); + } + }, + "Kunde zuweisen" + ); + }); + + // Bulk assign high + dialog.$wrapper.on("click", ".apply-all-high", function () { + const btn = $(this); + btn.prop("disabled", true).html(' Wird zugewiesen...'); + + const promises = high.map(s => + frappe.call({ + method: "frappe.client.set_value", + args: { doctype: "IT Domain", name: s.domain, fieldname: "customer", value: s.suggested_customer }, + }).then(() => { + dialog.$wrapper.find(`tr[data-domain="${s.domain}"]`).each(function () { + mark_done($(this)); + }); + }) + ); + + Promise.all(promises).then(() => { + btn.replaceWith(` ${high.length} Domains zugewiesen`); + frappe.show_alert({ message: `${high.length} Domains zugewiesen`, indicator: "green" }); + }); + }); + + dialog.show(); + }, + }); +} + + +function apply_single(domain, customer, tr_el) { + frappe.call({ + method: "frappe.client.set_value", + args: { doctype: "IT Domain", name: domain, fieldname: "customer", value: customer }, + callback() { mark_done(tr_el); }, + }); +} + + +function mark_done(tr_el) { + tr_el.find(".action-cell").html(''); + tr_el.css("opacity", "0.5"); +} + + +function fetch_file_and_call(file_url, method, arg_name) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", file_url, true); + xhr.onload = function () { + if (xhr.status !== 200) { + reject("Datei konnte nicht gelesen werden"); + return; + } + frappe.call({ + method, + args: { [arg_name]: xhr.responseText }, + callback(r) { resolve(r.message); }, + error: reject, + }); + }; + xhr.onerror = () => reject("Netzwerkfehler"); + xhr.send(); + }); +} + + +function format_import_result(title, result) { + if (result.created !== undefined) { + let html = `
+ ${title}: + ${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} unveraendert`; + if (result.errors && result.errors.length) { + html += `
${result.errors.length} Fehler`; + } + return html + "
"; + } + if (result.handles_parsed !== undefined) { + return `
+ ${title}: + ${result.handles_parsed} Handles gelesen, ${result.domains_updated} Domains aktualisiert +
`; + } + return ""; +} diff --git a/msp/msp/doctype/it_domain/test_it_domain.py b/msp/msp/doctype/it_domain/test_it_domain.py new file mode 100644 index 0000000..a12207c --- /dev/null +++ b/msp/msp/doctype/it_domain/test_it_domain.py @@ -0,0 +1,6 @@ +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestITDomain(FrappeTestCase): + pass