feat: add governed LDIF contact imports
This commit is contained in:
@@ -187,9 +187,9 @@ successful search can infer deletion. A timeout, bind failure, malformed entry,
|
|||||||
duplicate key, or configured entry limit retains existing contacts and reports
|
duplicate key, or configured entry limit retains existing contacts and reports
|
||||||
the source as failed/stale instead of creating tombstones.
|
the source as failed/stale instead of creating tombstones.
|
||||||
|
|
||||||
## Static Tabular Imports
|
## Static Mapped Imports
|
||||||
|
|
||||||
CSV and XLSX use versioned, scoped mapping profiles rather than live sync
|
CSV, XLSX, and LDIF use versioned, scoped mapping profiles rather than live sync
|
||||||
sources. Profiles retain delimiter, encoding, header or worksheet selection,
|
sources. Profiles retain delimiter, encoding, header or worksheet selection,
|
||||||
stable source-key mapping, field mappings, locale and tags, row limits, and
|
stable source-key mapping, field mappings, locale and tags, row limits, and
|
||||||
explicit duplicate, blank-value, and existing-contact policies. Updating a
|
explicit duplicate, blank-value, and existing-contact policies. Updating a
|
||||||
@@ -207,6 +207,15 @@ matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
|
|||||||
responsibility; Files and Datasources are optional origins, not prerequisites
|
responsibility; Files and Datasources are optional origins, not prerequisites
|
||||||
for direct upload.
|
for direct upload.
|
||||||
|
|
||||||
|
LDIF is unfolded and parsed as a bounded stream of entries. Attribute names are
|
||||||
|
case-insensitive; UTF-8 and base64-encoded text and repeated values are retained
|
||||||
|
for mapping, while binary and URL values produce diagnostics and are never
|
||||||
|
projected or fetched. The default change-record policy rejects change records.
|
||||||
|
A profile can instead ignore them, or treat `changetype: add` as a static entry;
|
||||||
|
modify and delete records are never translated into contact mutations. Entry
|
||||||
|
hashes, input/plan hashes, mapping-version provenance, and the same correction
|
||||||
|
and guarded rollback lifecycle apply as for CSV and XLSX.
|
||||||
|
|
||||||
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
|
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
|
||||||
The WebUI reloads the bounded run projection, selects its address book and
|
The WebUI reloads the bounded run projection, selects its address book and
|
||||||
mapping version, and restores statistics, diagnostics, effects, and lifecycle
|
mapping version, and restores statistics, diagnostics, effects, and lifecycle
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ Tasks:
|
|||||||
- [ ] [On-premises Exchange connector profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17)
|
- [ ] [On-premises Exchange connector profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17)
|
||||||
- [ ] [Google People contacts connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18)
|
- [ ] [Google People contacts connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18)
|
||||||
- [ ] [Reusable CSV/XLSX import mapping profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/19)
|
- [ ] [Reusable CSV/XLSX import mapping profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/19)
|
||||||
- [ ] [Bounded LDIF import profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
|
- [x] [Bounded LDIF import profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
|
||||||
- [ ] [Selective and large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21)
|
- [ ] [Selective and large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21)
|
||||||
- [x] classical address-list UI; reusable static/dynamic operational segments move
|
- [x] classical address-list UI; reusable static/dynamic operational segments move
|
||||||
to `govoplan-dist-lists`
|
to `govoplan-dist-lists`
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Literal
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
AddressImportFormat = Literal["csv", "xlsx"]
|
AddressImportFormat = Literal["csv", "xlsx", "ldif"]
|
||||||
AddressImportScope = Literal["user", "group", "tenant", "system"]
|
AddressImportScope = Literal["user", "group", "tenant", "system"]
|
||||||
|
|
||||||
IMPORT_TARGET_FIELDS = frozenset(
|
IMPORT_TARGET_FIELDS = frozenset(
|
||||||
@@ -41,6 +41,7 @@ class AddressImportConfiguration(BaseModel):
|
|||||||
duplicate_source_key_policy: Literal["reject", "first", "last"] = "reject"
|
duplicate_source_key_policy: Literal["reject", "first", "last"] = "reject"
|
||||||
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
|
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
|
||||||
blank_value_policy: Literal["ignore", "clear", "reject"] = "ignore"
|
blank_value_policy: Literal["ignore", "clear", "reject"] = "ignore"
|
||||||
|
ldif_change_record_policy: Literal["reject", "ignore", "treat_add_as_entry"] = "reject"
|
||||||
locale: str | None = Field(default=None, max_length=35)
|
locale: str | None = Field(default=None, max_length=35)
|
||||||
default_tags: list[str] = Field(default_factory=list, max_length=100)
|
default_tags: list[str] = Field(default_factory=list, max_length=100)
|
||||||
max_rows: int = Field(default=10_000, ge=1, le=10_000)
|
max_rows: int = Field(default=10_000, ge=1, le=10_000)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from govoplan_addresses.backend.import_schemas import (
|
|||||||
AddressImportProfileUpdateRequest,
|
AddressImportProfileUpdateRequest,
|
||||||
AddressImportRollbackRequest,
|
AddressImportRollbackRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_addresses.backend.ldif import parse_ldif_rows
|
||||||
from govoplan_addresses.backend.schemas import (
|
from govoplan_addresses.backend.schemas import (
|
||||||
ContactCreateRequest,
|
ContactCreateRequest,
|
||||||
ContactEmailPayload,
|
ContactEmailPayload,
|
||||||
@@ -199,6 +200,7 @@ def preview_address_import(
|
|||||||
input_hash=input_hash,
|
input_hash=input_hash,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
config=config,
|
config=config,
|
||||||
|
source_format=profile.source_format,
|
||||||
)
|
)
|
||||||
diagnostics = [*parse_diagnostics, *map_diagnostics]
|
diagnostics = [*parse_diagnostics, *map_diagnostics]
|
||||||
statistics = dict(Counter(item["action"] for item in plan_data))
|
statistics = dict(Counter(item["action"] for item in plan_data))
|
||||||
@@ -501,13 +503,24 @@ def _parse_rows(
|
|||||||
filename: str,
|
filename: str,
|
||||||
source_format: str,
|
source_format: str,
|
||||||
config: AddressImportConfiguration,
|
config: AddressImportConfiguration,
|
||||||
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
|
) -> tuple[list[tuple[int, dict[str, Any]]], list[dict[str, Any]]]:
|
||||||
if source_format == "csv":
|
if source_format == "csv":
|
||||||
return _parse_csv(raw, config=config)
|
return _parse_csv(raw, config=config)
|
||||||
if source_format == "xlsx":
|
if source_format == "xlsx":
|
||||||
if not filename.casefold().endswith(".xlsx"):
|
if not filename.casefold().endswith(".xlsx"):
|
||||||
raise AddressBookError("XLSX imports require an .xlsx file; macros and legacy workbooks are not accepted.")
|
raise AddressBookError("XLSX imports require an .xlsx file; macros and legacy workbooks are not accepted.")
|
||||||
return _parse_xlsx(raw, config=config)
|
return _parse_xlsx(raw, config=config)
|
||||||
|
if source_format == "ldif":
|
||||||
|
if not filename.casefold().endswith((".ldif", ".ldi")):
|
||||||
|
raise AddressBookError("LDIF imports require an .ldif or .ldi file.")
|
||||||
|
try:
|
||||||
|
return parse_ldif_rows(
|
||||||
|
raw,
|
||||||
|
max_entries=config.max_rows,
|
||||||
|
change_record_policy=config.ldif_change_record_policy,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AddressBookError(str(exc)) from exc
|
||||||
raise AddressBookError(f"Unsupported address import format: {source_format!r}.")
|
raise AddressBookError(f"Unsupported address import format: {source_format!r}.")
|
||||||
|
|
||||||
|
|
||||||
@@ -600,32 +613,81 @@ def _row_dict(headers: list[str], values: list[Any]) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _column_key(value: object, *, casefold: bool) -> str:
|
||||||
|
column = str(value).strip()
|
||||||
|
return column.casefold() if casefold else column
|
||||||
|
|
||||||
|
|
||||||
|
def _row_value(row: dict[str, Any], column: str, *, casefold: bool) -> object:
|
||||||
|
if column in row:
|
||||||
|
return row[column]
|
||||||
|
if not casefold:
|
||||||
|
return ""
|
||||||
|
expected = column.casefold()
|
||||||
|
for key, value in row.items():
|
||||||
|
if key.casefold() == expected:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _import_values(value: object) -> list[str]:
|
||||||
|
raw_values = value if isinstance(value, list) else [value]
|
||||||
|
return [str(item).strip() for item in raw_values if item is not None and str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _first_import_value(value: object) -> str:
|
||||||
|
values = _import_values(value)
|
||||||
|
return values[0] if values else ""
|
||||||
|
|
||||||
|
|
||||||
def _plan_rows(
|
def _plan_rows(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
book_id: str,
|
book_id: str,
|
||||||
profile: AddressImportProfile,
|
profile: AddressImportProfile,
|
||||||
input_hash: str,
|
input_hash: str,
|
||||||
rows: list[tuple[int, dict[str, str]]],
|
rows: list[tuple[int, dict[str, Any]]],
|
||||||
config: AddressImportConfiguration,
|
config: AddressImportConfiguration,
|
||||||
|
source_format: str,
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
diagnostics: list[dict[str, Any]] = []
|
diagnostics: list[dict[str, Any]] = []
|
||||||
plan: list[dict[str, Any]] = []
|
plan: list[dict[str, Any]] = []
|
||||||
headers = set(rows[0][1]) if rows else set()
|
casefold_columns = source_format == "ldif"
|
||||||
referenced_columns = set(config.field_mappings.values())
|
headers = {
|
||||||
|
_column_key(column, casefold=casefold_columns)
|
||||||
|
for column in (rows[0][1] if rows else set())
|
||||||
|
if not str(column).startswith("__")
|
||||||
|
}
|
||||||
|
referenced_columns = {
|
||||||
|
_column_key(column, casefold=casefold_columns)
|
||||||
|
for column in config.field_mappings.values()
|
||||||
|
}
|
||||||
|
key_column = config.source_key_column or config.field_mappings["source_key"]
|
||||||
|
normalized_key_column = _column_key(key_column, casefold=casefold_columns)
|
||||||
if config.source_key_column:
|
if config.source_key_column:
|
||||||
referenced_columns.add(config.source_key_column)
|
referenced_columns.add(_column_key(config.source_key_column, casefold=casefold_columns))
|
||||||
missing_columns = sorted(referenced_columns.difference(headers))
|
missing_columns = sorted(referenced_columns.difference(headers))
|
||||||
for column in missing_columns:
|
for column in missing_columns:
|
||||||
diagnostics.append(_diagnostic("error", "missing_column", f'Configured column "{column}" is missing.', field=column))
|
optional_ldif_attribute = casefold_columns and column != normalized_key_column
|
||||||
if missing_columns:
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"warning" if optional_ldif_attribute else "error",
|
||||||
|
"missing_attribute" if optional_ldif_attribute else "missing_column",
|
||||||
|
(
|
||||||
|
f'Configured LDIF attribute "{column}" is absent from this file.'
|
||||||
|
if optional_ldif_attribute
|
||||||
|
else f'Configured column "{column}" is missing.'
|
||||||
|
),
|
||||||
|
field=column,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if normalized_key_column in missing_columns or (missing_columns and not casefold_columns):
|
||||||
return [], diagnostics
|
return [], diagnostics
|
||||||
|
|
||||||
key_column = config.source_key_column or config.field_mappings["source_key"]
|
keyed_rows: list[tuple[int, dict[str, Any], str]] = []
|
||||||
keyed_rows: list[tuple[int, dict[str, str], str]] = []
|
|
||||||
key_counts: Counter[str] = Counter()
|
key_counts: Counter[str] = Counter()
|
||||||
for row_number, row in rows:
|
for row_number, row in rows:
|
||||||
key = row.get(key_column, "").strip()
|
key = _first_import_value(_row_value(row, key_column, casefold=casefold_columns)).strip()
|
||||||
if not key:
|
if not key:
|
||||||
diagnostics.append(_diagnostic("error", "missing_source_key", "Stable source key is blank.", row_number=row_number, field=key_column))
|
diagnostics.append(_diagnostic("error", "missing_source_key", "Stable source key is blank.", row_number=row_number, field=key_column))
|
||||||
plan.append(_plan_effect(row_number, "conflict", source_key=None, message="Stable source key is blank."))
|
plan.append(_plan_effect(row_number, "conflict", source_key=None, message="Stable source key is blank."))
|
||||||
@@ -651,11 +713,23 @@ def _plan_rows(
|
|||||||
plan.append(_plan_effect(row_number, "ignored", source_key=key, message="Duplicate row ignored by profile policy."))
|
plan.append(_plan_effect(row_number, "ignored", source_key=key, message="Duplicate row ignored by profile policy."))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
mapped, row_diagnostics = _mapped_fields(row_number, row, config=config)
|
mapped, row_diagnostics = _mapped_fields(
|
||||||
|
row_number,
|
||||||
|
row,
|
||||||
|
config=config,
|
||||||
|
casefold_columns=casefold_columns,
|
||||||
|
)
|
||||||
diagnostics.extend(row_diagnostics)
|
diagnostics.extend(row_diagnostics)
|
||||||
source_ref = f"import:{profile.profile_key}:{key}"
|
source_ref = f"import:{profile.profile_key}:{key}"
|
||||||
existing = _contact_by_source_ref(session, book_id, source_ref)
|
existing = _contact_by_source_ref(session, book_id, source_ref)
|
||||||
payload = _payload_from_mapped(mapped, profile=profile, input_hash=input_hash, row_number=row_number, source_key=key)
|
payload = _payload_from_mapped(
|
||||||
|
mapped,
|
||||||
|
profile=profile,
|
||||||
|
input_hash=input_hash,
|
||||||
|
row_number=row_number,
|
||||||
|
source_key=key,
|
||||||
|
source_row=row,
|
||||||
|
)
|
||||||
display_name = payload.get("display_name") or payload.get("email") or key
|
display_name = payload.get("display_name") or payload.get("email") or key
|
||||||
if any(item["severity"] == "error" for item in row_diagnostics):
|
if any(item["severity"] == "error" for item in row_diagnostics):
|
||||||
plan.append(_plan_effect(row_number, "conflict", source_key=key, display_name=display_name, source_ref=source_ref, payload=payload, message="Row validation failed."))
|
plan.append(_plan_effect(row_number, "conflict", source_key=key, display_name=display_name, source_ref=source_ref, payload=payload, message="Row validation failed."))
|
||||||
@@ -689,23 +763,46 @@ def _plan_rows(
|
|||||||
|
|
||||||
def _mapped_fields(
|
def _mapped_fields(
|
||||||
row_number: int,
|
row_number: int,
|
||||||
row: dict[str, str],
|
row: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
config: AddressImportConfiguration,
|
config: AddressImportConfiguration,
|
||||||
|
casefold_columns: bool = False,
|
||||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
mapped: dict[str, Any] = {}
|
mapped: dict[str, Any] = {}
|
||||||
diagnostics: list[dict[str, Any]] = []
|
diagnostics: list[dict[str, Any]] = []
|
||||||
for target, column in config.field_mappings.items():
|
for target, column in config.field_mappings.items():
|
||||||
if target == "source_key":
|
if target == "source_key":
|
||||||
continue
|
continue
|
||||||
value = row.get(column, "").strip()
|
values = _import_values(_row_value(row, column, casefold=casefold_columns))
|
||||||
if not value:
|
if not values:
|
||||||
if config.blank_value_policy == "reject":
|
if config.blank_value_policy == "reject":
|
||||||
diagnostics.append(_diagnostic("error", "blank_value", f'Column "{column}" is blank.', row_number=row_number, field=target))
|
diagnostics.append(_diagnostic("error", "blank_value", f'Column "{column}" is blank.', row_number=row_number, field=target))
|
||||||
elif config.blank_value_policy == "clear":
|
elif config.blank_value_policy == "clear":
|
||||||
mapped[target] = [] if target == "tags" else None
|
mapped[target] = [] if target == "tags" else None
|
||||||
continue
|
continue
|
||||||
mapped[target] = [item.strip() for item in value.split(",") if item.strip()] if target == "tags" else value
|
if target == "tags":
|
||||||
|
mapped[target] = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.strip()
|
||||||
|
for value in values
|
||||||
|
for item in value.split(",")
|
||||||
|
if item.strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif target in {"email", "phone"}:
|
||||||
|
mapped[target] = values if len(values) > 1 else values[0]
|
||||||
|
else:
|
||||||
|
mapped[target] = values[0]
|
||||||
|
if len(values) > 1:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"multiple_values_truncated",
|
||||||
|
f'Attribute "{column}" has multiple values; only the first maps to {target!r}.',
|
||||||
|
row_number=row_number,
|
||||||
|
field=target,
|
||||||
|
)
|
||||||
|
)
|
||||||
if config.default_tags:
|
if config.default_tags:
|
||||||
mapped["tags"] = list(dict.fromkeys([*(mapped.get("tags") or []), *config.default_tags]))
|
mapped["tags"] = list(dict.fromkeys([*(mapped.get("tags") or []), *config.default_tags]))
|
||||||
if not any(mapped.get(name) for name in ("display_name", "given_name", "family_name", "email", "organization")):
|
if not any(mapped.get(name) for name in ("display_name", "given_name", "family_name", "email", "organization")):
|
||||||
@@ -720,8 +817,22 @@ def _payload_from_mapped(
|
|||||||
input_hash: str,
|
input_hash: str,
|
||||||
row_number: int,
|
row_number: int,
|
||||||
source_key: str,
|
source_key: str,
|
||||||
|
source_row: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
display_name = mapped.get("display_name") or " ".join(filter(None, [mapped.get("given_name"), mapped.get("family_name")])) or mapped.get("email") or mapped.get("organization")
|
display_name = (
|
||||||
|
_first_import_value(mapped.get("display_name"))
|
||||||
|
or " ".join(
|
||||||
|
filter(
|
||||||
|
None,
|
||||||
|
[
|
||||||
|
_first_import_value(mapped.get("given_name")),
|
||||||
|
_first_import_value(mapped.get("family_name")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or _first_import_value(mapped.get("email"))
|
||||||
|
or _first_import_value(mapped.get("organization"))
|
||||||
|
)
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
key: mapped.get(key)
|
key: mapped.get(key)
|
||||||
for key in ("given_name", "family_name", "organization", "role_title", "note", "tags")
|
for key in ("given_name", "family_name", "organization", "role_title", "note", "tags")
|
||||||
@@ -729,9 +840,17 @@ def _payload_from_mapped(
|
|||||||
}
|
}
|
||||||
payload["display_name"] = display_name
|
payload["display_name"] = display_name
|
||||||
if "email" in mapped:
|
if "email" in mapped:
|
||||||
payload["emails"] = [] if mapped["email"] is None else [ContactEmailPayload(email=mapped["email"], is_primary=True).model_dump(mode="json")]
|
email_values = _import_values(mapped["email"])
|
||||||
|
payload["emails"] = [
|
||||||
|
ContactEmailPayload(email=value, is_primary=index == 0).model_dump(mode="json")
|
||||||
|
for index, value in enumerate(email_values)
|
||||||
|
]
|
||||||
if "phone" in mapped:
|
if "phone" in mapped:
|
||||||
payload["phones"] = [] if mapped["phone"] is None else [ContactPhonePayload(phone=mapped["phone"], is_primary=True).model_dump(mode="json")]
|
phone_values = _import_values(mapped["phone"])
|
||||||
|
payload["phones"] = [
|
||||||
|
ContactPhonePayload(phone=value, is_primary=index == 0).model_dump(mode="json")
|
||||||
|
for index, value in enumerate(phone_values)
|
||||||
|
]
|
||||||
postal_keys = {"street", "postal_code", "locality", "region", "country"}
|
postal_keys = {"street", "postal_code", "locality", "region", "country"}
|
||||||
if postal_keys.intersection(mapped):
|
if postal_keys.intersection(mapped):
|
||||||
postal = {key: mapped.get(key) for key in postal_keys if key in mapped}
|
postal = {key: mapped.get(key) for key in postal_keys if key in mapped}
|
||||||
@@ -746,6 +865,8 @@ def _payload_from_mapped(
|
|||||||
"source_key": source_key,
|
"source_key": source_key,
|
||||||
"locale": profile.configuration.get("locale"),
|
"locale": profile.configuration.get("locale"),
|
||||||
"visibility": mapped.get("visibility"),
|
"visibility": mapped.get("visibility"),
|
||||||
|
"source_format": profile.source_format,
|
||||||
|
"source_record_hash": (source_row or {}).get("__ldif_record_hash"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ContactCreateRequest.model_validate(payload).model_dump(
|
return ContactCreateRequest.model_validate(payload).model_dump(
|
||||||
@@ -770,7 +891,19 @@ def _changed_fields(contact: Contact, mapped: dict[str, Any]) -> list[str]:
|
|||||||
if contact.postal_addresses:
|
if contact.postal_addresses:
|
||||||
postal = contact.postal_addresses[0]
|
postal = contact.postal_addresses[0]
|
||||||
current.update({key: getattr(postal, key) for key in ("street", "postal_code", "locality", "region", "country")})
|
current.update({key: getattr(postal, key) for key in ("street", "postal_code", "locality", "region", "country")})
|
||||||
return sorted(key for key, value in mapped.items() if key != "visibility" and current.get(key) != value)
|
changed: list[str] = []
|
||||||
|
for key, value in mapped.items():
|
||||||
|
if key == "visibility":
|
||||||
|
continue
|
||||||
|
if key == "email" and isinstance(value, list):
|
||||||
|
current_value: Any = [item.email for item in contact.emails]
|
||||||
|
elif key == "phone" and isinstance(value, list):
|
||||||
|
current_value = [item.phone for item in contact.phones]
|
||||||
|
else:
|
||||||
|
current_value = current.get(key)
|
||||||
|
if current_value != value:
|
||||||
|
changed.append(key)
|
||||||
|
return sorted(changed)
|
||||||
|
|
||||||
|
|
||||||
def _plan_effect(
|
def _plan_effect(
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
||||||
|
LdifChangeRecordPolicy = Literal["reject", "ignore", "treat_add_as_entry"]
|
||||||
|
|
||||||
|
MAX_LDIF_ATTRIBUTES = 500
|
||||||
|
MAX_LDIF_VALUES_PER_ATTRIBUTE = 100
|
||||||
|
MAX_LDIF_LOGICAL_LINE_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ldif_rows(
|
||||||
|
raw: bytes,
|
||||||
|
*,
|
||||||
|
max_entries: int,
|
||||||
|
change_record_policy: LdifChangeRecordPolicy = "reject",
|
||||||
|
) -> tuple[list[tuple[int, dict[str, Any]]], list[dict[str, Any]]]:
|
||||||
|
"""Parse bounded LDIF entries without fetching URL or decoding binary values."""
|
||||||
|
|
||||||
|
rows: list[tuple[int, dict[str, Any]]] = []
|
||||||
|
diagnostics: list[dict[str, Any]] = []
|
||||||
|
record_lines: list[tuple[int, bytes]] = []
|
||||||
|
|
||||||
|
def finish_record() -> None:
|
||||||
|
if not record_lines:
|
||||||
|
return
|
||||||
|
row_number = record_lines[0][0]
|
||||||
|
row, record_diagnostics = _parse_record(record_lines)
|
||||||
|
diagnostics.extend(record_diagnostics)
|
||||||
|
record_lines.clear()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
if set(row) == {"version"} and _first(row.get("version")) == "1":
|
||||||
|
return
|
||||||
|
change_type = _first(row.get("changetype")).casefold()
|
||||||
|
if change_type:
|
||||||
|
if change_record_policy == "ignore":
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_change_record_ignored",
|
||||||
|
f"LDIF change record {change_type!r} was ignored by profile policy.",
|
||||||
|
row_number=row_number,
|
||||||
|
field="changetype",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if change_record_policy != "treat_add_as_entry" or change_type != "add":
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_change_record_rejected",
|
||||||
|
f"LDIF change record {change_type!r} is not permitted by the profile policy.",
|
||||||
|
row_number=row_number,
|
||||||
|
field="changetype",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"info",
|
||||||
|
"ldif_add_record_imported",
|
||||||
|
"LDIF add change record is treated as a static contact entry by profile policy.",
|
||||||
|
row_number=row_number,
|
||||||
|
field="changetype",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row.pop("changetype", None)
|
||||||
|
row["__ldif_record_hash"] = hashlib.sha256(_canonical_record(row)).hexdigest()
|
||||||
|
rows.append((row_number, row))
|
||||||
|
if len(rows) > max_entries:
|
||||||
|
raise ValueError(f"LDIF exceeds the configured {max_entries}-entry limit.")
|
||||||
|
|
||||||
|
for line_number, logical_line in _logical_lines(raw):
|
||||||
|
if not logical_line:
|
||||||
|
finish_record()
|
||||||
|
continue
|
||||||
|
if logical_line.startswith(b"#"):
|
||||||
|
continue
|
||||||
|
record_lines.append((line_number, logical_line))
|
||||||
|
finish_record()
|
||||||
|
if not rows and not any(item["severity"] == "error" for item in diagnostics):
|
||||||
|
diagnostics.append(_diagnostic("warning", "ldif_no_entries", "No importable LDIF entries were found."))
|
||||||
|
return rows, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _logical_lines(raw: bytes) -> Iterable[tuple[int, bytes]]:
|
||||||
|
current: bytearray | None = None
|
||||||
|
start_line = 0
|
||||||
|
for line_number, physical_with_ending in enumerate(BytesIO(raw), start=1):
|
||||||
|
physical = physical_with_ending.rstrip(b"\r\n")
|
||||||
|
if physical.startswith(b" "):
|
||||||
|
if current is None:
|
||||||
|
yield line_number, b"!invalid-fold-without-preceding-line"
|
||||||
|
continue
|
||||||
|
current.extend(physical[1:])
|
||||||
|
if len(current) > MAX_LDIF_LOGICAL_LINE_BYTES:
|
||||||
|
raise ValueError(f"LDIF logical line starting at {start_line} exceeds the size limit.")
|
||||||
|
continue
|
||||||
|
if current is not None:
|
||||||
|
yield start_line, bytes(current)
|
||||||
|
current = bytearray(physical)
|
||||||
|
start_line = line_number
|
||||||
|
if len(current) > MAX_LDIF_LOGICAL_LINE_BYTES:
|
||||||
|
raise ValueError(f"LDIF logical line {line_number} exceeds the size limit.")
|
||||||
|
if current is not None:
|
||||||
|
yield start_line, bytes(current)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_record(
|
||||||
|
lines: list[tuple[int, bytes]],
|
||||||
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
row: dict[str, list[str]] = {}
|
||||||
|
diagnostics: list[dict[str, Any]] = []
|
||||||
|
for line_number, line in lines:
|
||||||
|
if line == b"!invalid-fold-without-preceding-line":
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_fold",
|
||||||
|
"LDIF continuation line has no preceding attribute.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if b":" not in line:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_line",
|
||||||
|
"LDIF line is missing the attribute separator.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if line == b"-":
|
||||||
|
# Attribute-operation separators are meaningful only inside change
|
||||||
|
# records, whose enclosing policy is evaluated after the record.
|
||||||
|
continue
|
||||||
|
raw_name, raw_value = line.split(b":", 1)
|
||||||
|
try:
|
||||||
|
name_parts = [part.strip().casefold() for part in raw_name.decode("ascii").split(";")]
|
||||||
|
name = name_parts[0]
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
name_parts = []
|
||||||
|
name = ""
|
||||||
|
if not name or any(character.isspace() for character in name):
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_attribute",
|
||||||
|
"LDIF attribute name is invalid.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if "binary" in name_parts[1:]:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_binary_value_ignored",
|
||||||
|
f"Binary LDIF attribute {name!r} was ignored; binary data is never projected into contacts.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if name not in row and len(row) >= MAX_LDIF_ATTRIBUTES:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_too_many_attributes",
|
||||||
|
f"LDIF entry exceeds the {MAX_LDIF_ATTRIBUTES}-attribute limit.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
value, value_diagnostic = _decode_value(raw_value, attribute=name, line_number=line_number)
|
||||||
|
if value_diagnostic is not None:
|
||||||
|
diagnostics.append(value_diagnostic)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
values = row.setdefault(name, [])
|
||||||
|
if len(values) >= MAX_LDIF_VALUES_PER_ATTRIBUTE:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_too_many_values",
|
||||||
|
f"LDIF attribute {name!r} exceeds the value limit.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
values.append(value)
|
||||||
|
return row, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_value(
|
||||||
|
raw_value: bytes,
|
||||||
|
*,
|
||||||
|
attribute: str,
|
||||||
|
line_number: int,
|
||||||
|
) -> tuple[str | None, dict[str, Any] | None]:
|
||||||
|
if raw_value.startswith(b":"):
|
||||||
|
encoded = raw_value[1:].lstrip(b" ")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(encoded, validate=True)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
return None, _diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_base64",
|
||||||
|
f"LDIF attribute {attribute!r} contains invalid base64.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return decoded.decode("utf-8"), None
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None, _diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_binary_value_ignored",
|
||||||
|
f"Binary LDIF attribute {attribute!r} was ignored; binary data is never projected into contacts.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
if raw_value.startswith(b"<"):
|
||||||
|
return None, _diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_url_value_ignored",
|
||||||
|
f"External LDIF URL value for {attribute!r} was ignored; imports never fetch referenced content.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
value_bytes = raw_value[1:] if raw_value.startswith(b" ") else raw_value
|
||||||
|
try:
|
||||||
|
return value_bytes.decode("utf-8"), None
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None, _diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_utf8",
|
||||||
|
f"LDIF attribute {attribute!r} is not valid UTF-8.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_record(row: dict[str, Any]) -> bytes:
|
||||||
|
lines = []
|
||||||
|
for name in sorted(key for key in row if not key.startswith("__")):
|
||||||
|
values = row[name] if isinstance(row[name], list) else [row[name]]
|
||||||
|
lines.extend(f"{name}:{value}" for value in values)
|
||||||
|
return "\n".join(lines).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _first(value: object) -> str:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return str(value[0]) if value else ""
|
||||||
|
return str(value or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic(
|
||||||
|
severity: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
row_number: int | None = None,
|
||||||
|
field: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"severity": severity,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"row_number": row_number,
|
||||||
|
"field": field,
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["LdifChangeRecordPolicy", "parse_ldif_rows"]
|
||||||
@@ -440,17 +440,19 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="addresses.tabular-imports",
|
id="addresses.tabular-imports",
|
||||||
title="CSV and XLSX contact imports",
|
title="CSV, XLSX, and LDIF contact imports",
|
||||||
summary="Preview and apply reusable, versioned contact mappings without silent row loss.",
|
summary="Preview and apply reusable, versioned contact mappings without silent row or entry loss.",
|
||||||
body=(
|
body=(
|
||||||
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
|
"CSV, XLSX, and LDIF files can be mapped with scoped, reusable profile versions. Each preview validates headers or attributes, "
|
||||||
"encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. "
|
"encodings, source keys, duplicates, blank values, format limits, and contact identity before any mutation. "
|
||||||
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
|
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
|
||||||
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
|
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
|
||||||
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
|
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
|
||||||
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
|
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
|
||||||
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
|
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
|
||||||
"are never executed or imported."
|
"are never executed or imported. LDIF folded lines, UTF-8 and base64 text, repeated attributes, and comments are parsed; "
|
||||||
|
"binary and URL values are never projected or fetched. Change records default to rejected diagnostics and may only be ignored "
|
||||||
|
"or treat add records as static entries through an explicit profile policy."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import AddressBook, Contact
|
||||||
|
from govoplan_addresses.backend.import_schemas import (
|
||||||
|
AddressImportConfiguration,
|
||||||
|
AddressImportPreviewRequest,
|
||||||
|
AddressImportProfileCreateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.imports import (
|
||||||
|
apply_address_import,
|
||||||
|
create_import_profile,
|
||||||
|
import_run_payload,
|
||||||
|
preview_address_import,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.ldif import parse_ldif_rows
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Principal:
|
||||||
|
account_id = "account-1"
|
||||||
|
group_ids = frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tenant_id(self) -> str:
|
||||||
|
return "tenant-1"
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in {
|
||||||
|
"addresses:address_book:read",
|
||||||
|
"addresses:address_book:write",
|
||||||
|
"addresses:contact:read",
|
||||||
|
"addresses:contact:write",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(value: bytes | str) -> str:
|
||||||
|
raw = value.encode() if isinstance(value, str) else value
|
||||||
|
return base64.b64encode(raw).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressLdifImportTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
self.principal = Principal()
|
||||||
|
self.book = AddressBook(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Imported contacts",
|
||||||
|
source_kind="local",
|
||||||
|
read_only=False,
|
||||||
|
)
|
||||||
|
self.session.add(self.book)
|
||||||
|
|
||||||
|
def create_profile(self, **configuration_overrides):
|
||||||
|
configuration = AddressImportConfiguration(
|
||||||
|
field_mappings={
|
||||||
|
"source_key": "dn",
|
||||||
|
"display_name": "cn",
|
||||||
|
"given_name": "givenName",
|
||||||
|
"family_name": "sn",
|
||||||
|
"email": "mail",
|
||||||
|
"phone": "telephoneNumber",
|
||||||
|
"organization": "o",
|
||||||
|
},
|
||||||
|
**configuration_overrides,
|
||||||
|
)
|
||||||
|
profile = create_import_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressImportProfileCreateRequest(
|
||||||
|
scope_type="tenant",
|
||||||
|
name="Directory export",
|
||||||
|
source_format="ldif",
|
||||||
|
configuration=configuration,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def test_parser_unfolds_and_decodes_text_without_projecting_binary_or_urls(self) -> None:
|
||||||
|
rows, diagnostics = parse_ldif_rows(
|
||||||
|
b"version: 1\n\n"
|
||||||
|
b"# exported contact\n"
|
||||||
|
b"dn: uid=ada,ou=people,dc=example,dc=test\n"
|
||||||
|
b"cn:: QWRhIExvdmVsYWNl\n"
|
||||||
|
b"sn: Love\n"
|
||||||
|
b" lace\n"
|
||||||
|
b"mail: ada@example.test\n"
|
||||||
|
b"mail: ada.work@example.test\n"
|
||||||
|
b"jpegPhoto:: /9j/\n"
|
||||||
|
b"seeAlso:< https://example.test/contact/ada\n",
|
||||||
|
max_entries=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(rows))
|
||||||
|
self.assertEqual(["Ada Lovelace"], rows[0][1]["cn"])
|
||||||
|
self.assertEqual(["Lovelace"], rows[0][1]["sn"])
|
||||||
|
self.assertEqual(["ada@example.test", "ada.work@example.test"], rows[0][1]["mail"])
|
||||||
|
self.assertNotIn("jpegphoto", rows[0][1])
|
||||||
|
self.assertNotIn("seealso", rows[0][1])
|
||||||
|
self.assertEqual(
|
||||||
|
{"ldif_binary_value_ignored", "ldif_url_value_ignored"},
|
||||||
|
{item["code"] for item in diagnostics},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preview_apply_and_repeat_preserve_multivalue_provenance(self) -> None:
|
||||||
|
profile = self.create_profile(default_tags=["ldif"])
|
||||||
|
source = (
|
||||||
|
"dn: uid=ada,ou=people,dc=example,dc=test\n"
|
||||||
|
"cn: Ada Lovelace\n"
|
||||||
|
"givenName: Ada\n"
|
||||||
|
"sn: Lovelace\n"
|
||||||
|
"mail: ada@example.test\n"
|
||||||
|
"mail: ada.work@example.test\n"
|
||||||
|
"telephoneNumber: +49 30 123\n"
|
||||||
|
"o: Analysis Office\n"
|
||||||
|
)
|
||||||
|
request = AddressImportPreviewRequest(
|
||||||
|
profile_id=profile.id,
|
||||||
|
filename="contacts.ldif",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
)
|
||||||
|
run = preview_address_import(self.session, self.principal, self.book.id, request)
|
||||||
|
self.assertEqual(1, run.statistics["create"])
|
||||||
|
self.assertFalse([item for item in run.diagnostics if item["severity"] == "error"])
|
||||||
|
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
self.assertEqual(["ada@example.test", "ada.work@example.test"], [item.email for item in contact.emails])
|
||||||
|
self.assertEqual("ldif", contact.source_kind)
|
||||||
|
self.assertEqual("ldif", contact.provenance["import"]["source_format"])
|
||||||
|
self.assertEqual(64, len(contact.provenance["import"]["source_record_hash"]))
|
||||||
|
self.assertNotIn(source, repr(import_run_payload(run)))
|
||||||
|
|
||||||
|
repeated = preview_address_import(self.session, self.principal, self.book.id, request)
|
||||||
|
self.assertEqual(1, repeated.statistics["unchanged"])
|
||||||
|
apply_address_import(self.session, self.principal, repeated.id, expected_plan_hash=repeated.plan_hash)
|
||||||
|
self.assertEqual(1, self.session.query(Contact).count())
|
||||||
|
|
||||||
|
def test_change_records_are_rejected_by_default_and_add_requires_explicit_policy(self) -> None:
|
||||||
|
source = (
|
||||||
|
"dn: uid=ada,ou=people,dc=example,dc=test\n"
|
||||||
|
"changetype: add\n"
|
||||||
|
"cn: Ada Lovelace\n"
|
||||||
|
"mail: ada@example.test\n"
|
||||||
|
)
|
||||||
|
rejected_profile = self.create_profile()
|
||||||
|
rejected = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=rejected_profile.id,
|
||||||
|
filename="changes.ldif",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(0, rejected.row_count)
|
||||||
|
self.assertIn("ldif_change_record_rejected", {item["code"] for item in rejected.diagnostics})
|
||||||
|
self.assertFalse(import_run_payload(rejected)["can_apply"])
|
||||||
|
|
||||||
|
allowed_profile = self.create_profile(ldif_change_record_policy="treat_add_as_entry")
|
||||||
|
allowed = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=allowed_profile.id,
|
||||||
|
filename="changes.ldif",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, allowed.statistics["create"])
|
||||||
|
self.assertIn("ldif_add_record_imported", {item["code"] for item in allowed.diagnostics})
|
||||||
|
|
||||||
|
def test_invalid_base64_is_a_correction_diagnostic_and_blocks_apply(self) -> None:
|
||||||
|
profile = self.create_profile()
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=profile.id,
|
||||||
|
filename="broken.ldif",
|
||||||
|
content_base64=encoded(
|
||||||
|
"dn: uid=ada,dc=example,dc=test\n"
|
||||||
|
"cn:: this-is-not-base64!\n"
|
||||||
|
"mail: ada@example.test\n"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertIn("ldif_invalid_base64", {item["code"] for item in run.diagnostics})
|
||||||
|
self.assertFalse(import_run_payload(run)["can_apply"])
|
||||||
|
with self.assertRaisesRegex(ValueError, "error diagnostics"):
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -494,6 +494,7 @@ export type AddressImportConfiguration = {
|
|||||||
duplicate_source_key_policy: "reject" | "first" | "last";
|
duplicate_source_key_policy: "reject" | "first" | "last";
|
||||||
existing_contact_policy: "update" | "ignore" | "reject";
|
existing_contact_policy: "update" | "ignore" | "reject";
|
||||||
blank_value_policy: "ignore" | "clear" | "reject";
|
blank_value_policy: "ignore" | "clear" | "reject";
|
||||||
|
ldif_change_record_policy: "reject" | "ignore" | "treat_add_as_entry";
|
||||||
locale?: string | null;
|
locale?: string | null;
|
||||||
default_tags: string[];
|
default_tags: string[];
|
||||||
max_rows: number;
|
max_rows: number;
|
||||||
@@ -508,7 +509,7 @@ export type AddressImportProfile = {
|
|||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
source_format: "csv" | "xlsx";
|
source_format: "csv" | "xlsx" | "ldif";
|
||||||
configuration: AddressImportConfiguration;
|
configuration: AddressImportConfiguration;
|
||||||
is_current: boolean;
|
is_current: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -1068,7 +1069,7 @@ export function createAddressImportProfile(
|
|||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
source_format: "csv" | "xlsx";
|
source_format: "csv" | "xlsx" | "ldif";
|
||||||
configuration: AddressImportConfiguration;
|
configuration: AddressImportConfiguration;
|
||||||
}
|
}
|
||||||
): Promise<AddressImportProfile> {
|
): Promise<AddressImportProfile> {
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ type ImportMode = "vcard" | "tabular";
|
|||||||
|
|
||||||
type ImportProfileFormState = {
|
type ImportProfileFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
source_format: "csv" | "xlsx";
|
source_format: "csv" | "xlsx" | "ldif";
|
||||||
delimiter: "," | ";" | "\t" | "|";
|
delimiter: "," | ";" | "\t" | "|";
|
||||||
encoding: "utf-8" | "utf-8-sig" | "cp1252" | "latin-1";
|
encoding: "utf-8" | "utf-8-sig" | "cp1252" | "latin-1";
|
||||||
header_row: string;
|
header_row: string;
|
||||||
@@ -245,6 +245,7 @@ type ImportProfileFormState = {
|
|||||||
duplicate_source_key_policy: "reject" | "first" | "last";
|
duplicate_source_key_policy: "reject" | "first" | "last";
|
||||||
existing_contact_policy: "update" | "ignore" | "reject";
|
existing_contact_policy: "update" | "ignore" | "reject";
|
||||||
blank_value_policy: "ignore" | "clear" | "reject";
|
blank_value_policy: "ignore" | "clear" | "reject";
|
||||||
|
ldif_change_record_policy: "reject" | "ignore" | "treat_add_as_entry";
|
||||||
locale: string;
|
locale: string;
|
||||||
default_tags: string;
|
default_tags: string;
|
||||||
max_rows: string;
|
max_rows: string;
|
||||||
@@ -398,20 +399,7 @@ const EMPTY_LDAP_FORM: LdapFormState = {
|
|||||||
attribute_map: DEFAULT_LDAP_ATTRIBUTE_MAP
|
attribute_map: DEFAULT_LDAP_ATTRIBUTE_MAP
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_IMPORT_PROFILE_FORM: ImportProfileFormState = {
|
const DEFAULT_TABULAR_FIELD_MAPPINGS: Record<string, string> = {
|
||||||
name: "",
|
|
||||||
source_format: "csv",
|
|
||||||
delimiter: ";",
|
|
||||||
encoding: "utf-8-sig",
|
|
||||||
header_row: "1",
|
|
||||||
sheet_name: "",
|
|
||||||
duplicate_source_key_policy: "reject",
|
|
||||||
existing_contact_policy: "update",
|
|
||||||
blank_value_policy: "ignore",
|
|
||||||
locale: "",
|
|
||||||
default_tags: "",
|
|
||||||
max_rows: "10000",
|
|
||||||
field_mappings: {
|
|
||||||
source_key: "id",
|
source_key: "id",
|
||||||
display_name: "display_name",
|
display_name: "display_name",
|
||||||
given_name: "given_name",
|
given_name: "given_name",
|
||||||
@@ -426,7 +414,39 @@ const EMPTY_IMPORT_PROFILE_FORM: ImportProfileFormState = {
|
|||||||
region: "region",
|
region: "region",
|
||||||
country: "country",
|
country: "country",
|
||||||
tags: "tags"
|
tags: "tags"
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LDIF_FIELD_MAPPINGS: Record<string, string> = {
|
||||||
|
source_key: "dn",
|
||||||
|
display_name: "cn",
|
||||||
|
given_name: "givenname",
|
||||||
|
family_name: "sn",
|
||||||
|
organization: "o",
|
||||||
|
role_title: "title",
|
||||||
|
email: "mail",
|
||||||
|
phone: "telephonenumber",
|
||||||
|
street: "street",
|
||||||
|
postal_code: "postalcode",
|
||||||
|
locality: "l",
|
||||||
|
region: "st",
|
||||||
|
country: "c"
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_IMPORT_PROFILE_FORM: ImportProfileFormState = {
|
||||||
|
name: "",
|
||||||
|
source_format: "csv",
|
||||||
|
delimiter: ";",
|
||||||
|
encoding: "utf-8-sig",
|
||||||
|
header_row: "1",
|
||||||
|
sheet_name: "",
|
||||||
|
duplicate_source_key_policy: "reject",
|
||||||
|
existing_contact_policy: "update",
|
||||||
|
blank_value_policy: "ignore",
|
||||||
|
ldif_change_record_policy: "reject",
|
||||||
|
locale: "",
|
||||||
|
default_tags: "",
|
||||||
|
max_rows: "10000",
|
||||||
|
field_mappings: DEFAULT_TABULAR_FIELD_MAPPINGS
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMPORT_MAPPING_FIELDS = [
|
const IMPORT_MAPPING_FIELDS = [
|
||||||
@@ -2384,6 +2404,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
duplicate_source_key_policy: importProfileForm.duplicate_source_key_policy,
|
duplicate_source_key_policy: importProfileForm.duplicate_source_key_policy,
|
||||||
existing_contact_policy: importProfileForm.existing_contact_policy,
|
existing_contact_policy: importProfileForm.existing_contact_policy,
|
||||||
blank_value_policy: importProfileForm.blank_value_policy,
|
blank_value_policy: importProfileForm.blank_value_policy,
|
||||||
|
ldif_change_record_policy: importProfileForm.ldif_change_record_policy,
|
||||||
locale: importProfileForm.locale.trim() || null,
|
locale: importProfileForm.locale.trim() || null,
|
||||||
default_tags: importProfileForm.default_tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
default_tags: importProfileForm.default_tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||||
max_rows: Number(importProfileForm.max_rows) || 10000
|
max_rows: Number(importProfileForm.max_rows) || 10000
|
||||||
@@ -2438,6 +2459,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
duplicate_source_key_policy: config.duplicate_source_key_policy,
|
duplicate_source_key_policy: config.duplicate_source_key_policy,
|
||||||
existing_contact_policy: config.existing_contact_policy,
|
existing_contact_policy: config.existing_contact_policy,
|
||||||
blank_value_policy: config.blank_value_policy,
|
blank_value_policy: config.blank_value_policy,
|
||||||
|
ldif_change_record_policy: config.ldif_change_record_policy ?? "reject",
|
||||||
locale: config.locale ?? "",
|
locale: config.locale ?? "",
|
||||||
default_tags: config.default_tags.join(", "),
|
default_tags: config.default_tags.join(", "),
|
||||||
max_rows: String(config.max_rows),
|
max_rows: String(config.max_rows),
|
||||||
@@ -3798,7 +3820,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
role="group"
|
role="group"
|
||||||
size="equal"
|
size="equal"
|
||||||
ariaLabel="Contact import format"
|
ariaLabel="Contact import format"
|
||||||
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
|
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "Mapped file" }]}
|
||||||
value={importMode}
|
value={importMode}
|
||||||
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
|
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
|
||||||
/>
|
/>
|
||||||
@@ -3861,9 +3883,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<FormGrid columns={2} collapseAt="standard" className="">
|
<FormGrid columns={2} collapseAt="standard" className="">
|
||||||
<FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField>
|
<FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField>
|
||||||
<FormField label="Format">
|
<FormField label="Format">
|
||||||
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({ ...current, source_format: event.target.value as "csv" | "xlsx" }))}>
|
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({
|
||||||
|
...current,
|
||||||
|
source_format: event.target.value as ImportProfileFormState["source_format"],
|
||||||
|
field_mappings: event.target.value === "ldif"
|
||||||
|
? DEFAULT_LDIF_FIELD_MAPPINGS
|
||||||
|
: current.source_format === "ldif"
|
||||||
|
? DEFAULT_TABULAR_FIELD_MAPPINGS
|
||||||
|
: current.field_mappings
|
||||||
|
}))}>
|
||||||
<option value="csv">CSV</option>
|
<option value="csv">CSV</option>
|
||||||
<option value="xlsx">XLSX</option>
|
<option value="xlsx">XLSX</option>
|
||||||
|
<option value="ldif">LDIF</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
{importProfileForm.source_format === "csv" && <>
|
{importProfileForm.source_format === "csv" && <>
|
||||||
@@ -3879,7 +3910,15 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</FormField>
|
</FormField>
|
||||||
</>}
|
</>}
|
||||||
{importProfileForm.source_format === "xlsx" && <FormField label="Sheet name"><input value={importProfileForm.sheet_name} onChange={(event) => setImportProfileForm((current) => ({ ...current, sheet_name: event.target.value }))} placeholder="First sheet" /></FormField>}
|
{importProfileForm.source_format === "xlsx" && <FormField label="Sheet name"><input value={importProfileForm.sheet_name} onChange={(event) => setImportProfileForm((current) => ({ ...current, sheet_name: event.target.value }))} placeholder="First sheet" /></FormField>}
|
||||||
<FormField label="Header row"><input type="number" min="1" max="100" value={importProfileForm.header_row} onChange={(event) => setImportProfileForm((current) => ({ ...current, header_row: event.target.value }))} /></FormField>
|
{importProfileForm.source_format !== "ldif" && <FormField label="Header row"><input type="number" min="1" max="100" value={importProfileForm.header_row} onChange={(event) => setImportProfileForm((current) => ({ ...current, header_row: event.target.value }))} /></FormField>}
|
||||||
|
{importProfileForm.source_format === "ldif" &&
|
||||||
|
<FormField label="LDIF change records">
|
||||||
|
<select value={importProfileForm.ldif_change_record_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, ldif_change_record_policy: event.target.value as ImportProfileFormState["ldif_change_record_policy"] }))}>
|
||||||
|
<option value="reject">Reject with diagnostics</option>
|
||||||
|
<option value="ignore">Ignore all change records</option>
|
||||||
|
<option value="treat_add_as_entry">Import add records; reject modify/delete</option>
|
||||||
|
</select>
|
||||||
|
</FormField>}
|
||||||
<FormField label="Duplicate source keys">
|
<FormField label="Duplicate source keys">
|
||||||
<select value={importProfileForm.duplicate_source_key_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, duplicate_source_key_policy: event.target.value as ImportProfileFormState["duplicate_source_key_policy"] }))}>
|
<select value={importProfileForm.duplicate_source_key_policy} onChange={(event) => setImportProfileForm((current) => ({ ...current, duplicate_source_key_policy: event.target.value as ImportProfileFormState["duplicate_source_key_policy"] }))}>
|
||||||
<option value="reject">Reject duplicates</option><option value="first">Use first row</option><option value="last">Use last row</option>
|
<option value="reject">Reject duplicates</option><option value="first">Use first row</option><option value="last">Use last row</option>
|
||||||
@@ -3905,7 +3944,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<input
|
<input
|
||||||
value={importProfileForm.field_mappings[target] ?? ""}
|
value={importProfileForm.field_mappings[target] ?? ""}
|
||||||
onChange={(event) => setImportProfileForm((current) => ({ ...current, field_mappings: { ...current.field_mappings, [target]: event.target.value } }))}
|
onChange={(event) => setImportProfileForm((current) => ({ ...current, field_mappings: { ...current.field_mappings, [target]: event.target.value } }))}
|
||||||
placeholder="Source column"
|
placeholder={importProfileForm.source_format === "ldif" ? "LDIF attribute" : "Source column"}
|
||||||
/>
|
/>
|
||||||
</FormField>)}
|
</FormField>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3914,7 +3953,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<FormField label="Import file">
|
<FormField label="Import file">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
accept=".csv,.xlsx,.ldif,.ldi,text/csv,application/ldif,text/ldif,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
|
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
Reference in New Issue
Block a user