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) <noreply@anthropic.com>
This commit is contained in:
David Malinowski
2026-04-12 21:05:58 +02:00
co-authored by Claude Opus 4.6
parent 4bf40ff70a
commit 7f5f7a5236
6 changed files with 1530 additions and 0 deletions
+23
View File
@@ -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",
});
}
},
});
});
}
},
});
+349
View File
@@ -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
}
+721
View File
@@ -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"<title[^>]*>(.*?)</title>", 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]
+431
View File
@@ -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 += `<div class="mt-3">
<button class="btn btn-primary btn-sm wizard-next-btn">
Weiter zur Kundenzuordnung &rarr;
</button>
</div>`;
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
? `<span class="indicator-pill ${
{ high: "green", medium: "orange", low: "gray" }[s.confidence]
}">${s.confidence}</span>`
: "";
const registrant = s.registrant_name
? `<br><small class="text-muted">${frappe.utils.escape_html(s.registrant_name)}</small>`
: "";
const customer_cell = s.suggested_customer
? `<span>${s.suggested_customer}</span>
<button class="btn btn-xs btn-default ml-1 change-customer" data-idx="${idx}" title="Aendern">
<i class="fa fa-pencil"></i>
</button>`
: `<button class="btn btn-xs btn-default pick-customer" data-idx="${idx}">
<i class="fa fa-search"></i> Waehlen
</button>`;
const actions = s.suggested_customer
? `<button class="btn btn-xs btn-primary apply-one" data-idx="${idx}" title="Zuweisen">
<i class="fa fa-check"></i>
</button>
<button class="btn btn-xs btn-default skip-one ml-1" data-idx="${idx}" title="Ueberspringen">
<i class="fa fa-times"></i>
</button>`
: "";
return `<tr data-idx="${idx}" data-domain="${s.domain}">
<td><a href="/app/it-domain/${s.domain}" target="_blank">${s.domain}</a>${registrant}</td>
<td>${customer_cell}</td>
<td class="text-center">${s.match_type || ""} ${conf_badge}</td>
<td class="text-center action-cell">${actions}</td>
</tr>`;
});
const dialog = new frappe.ui.Dialog({
title: `Kundenzuordnung — ${all.length} Domains offen`,
size: "extra-large",
fields: [{
fieldtype: "HTML",
fieldname: "assign_html",
options: `
${high.length ? `<div class="mb-3">
<button class="btn btn-sm btn-primary apply-all-high">
<i class="fa fa-check-circle"></i> Alle ${high.length} sicheren Vorschlaege zuweisen
</button>
</div>` : ""}
<div style="max-height:500px;overflow-y:auto">
<table class="table table-sm table-bordered" style="font-size:13px">
<thead><tr>
<th style="width:35%">Domain / Inhaber</th>
<th style="width:30%">Kunde</th>
<th style="width:15%">Match</th>
<th style="width:20%"></th>
</tr></thead>
<tbody>${rows.join("")}</tbody>
</table>
</div>`,
}],
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('<i class="fa fa-spinner fa-spin"></i> 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(`<span class="text-success"><i class="fa fa-check"></i> ${high.length} Domains zugewiesen</span>`);
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('<span class="text-success"><i class="fa fa-check"></i></span>');
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 = `<div class="alert alert-success mt-2">
<strong>${title}:</strong>
${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} unveraendert`;
if (result.errors && result.errors.length) {
html += `<br><small class="text-danger">${result.errors.length} Fehler</small>`;
}
return html + "</div>";
}
if (result.handles_parsed !== undefined) {
return `<div class="alert alert-success mt-2">
<strong>${title}:</strong>
${result.handles_parsed} Handles gelesen, ${result.domains_updated} Domains aktualisiert
</div>`;
}
return "";
}
@@ -0,0 +1,6 @@
import frappe
from frappe.tests.utils import FrappeTestCase
class TestITDomain(FrappeTestCase):
pass