mirror of
https://github.com/itsdave-de/msp.git
synced 2026-08-14 15:40:05 -03:00
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:
co-authored by
Claude Opus 4.6
parent
4bf40ff70a
commit
7f5f7a5236
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 →
|
||||
</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
|
||||
Reference in New Issue
Block a user