feat: implement CSV contact import with two-step preview and validation flow
This commit is contained in:
parent
c0d5f5892f
commit
e0127e26c5
8 changed files with 1857 additions and 1 deletions
133
backend/contacts/import_views.py
Normal file
133
backend/contacts/import_views.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""CSV import endpoints for contacts.
|
||||
|
||||
Preview reads the uploaded file and returns row-level validation results
|
||||
without touching the DB. Commit re-runs validation and writes inside a single
|
||||
transaction. Both endpoints are gated to ADMIN or sales-access users so
|
||||
non-privileged members cannot mass-create contacts through this surface.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.permissions import HasOrgContext
|
||||
from contacts.services.csv_import import commit_rows, parse_and_validate
|
||||
|
||||
|
||||
MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB; matches the UI hint
|
||||
|
||||
|
||||
def _can_import(profile) -> bool:
|
||||
"""Mass-create requires admin or explicit sales-access permission."""
|
||||
if profile is None:
|
||||
return False
|
||||
if getattr(profile, "role", None) == "ADMIN":
|
||||
return True
|
||||
if getattr(profile, "is_admin", False):
|
||||
return True
|
||||
return bool(getattr(profile, "has_sales_access", False))
|
||||
|
||||
|
||||
def _read_upload(request):
|
||||
"""Return (file_bytes, error_response). One of the two will be None.
|
||||
|
||||
The size cap is enforced against the actual bytes read, not against
|
||||
`upload.size`. `upload.size` is derived from a client-supplied
|
||||
Content-Length header for in-memory uploads and can be zero or absent
|
||||
even when the body is large, so trusting it for the limit check creates
|
||||
a bypass.
|
||||
"""
|
||||
upload = request.FILES.get("file")
|
||||
if not upload:
|
||||
return None, Response(
|
||||
{"error": True, "message": "No file uploaded (expected field 'file')"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
name = (upload.name or "").lower()
|
||||
if not name.endswith(".csv"):
|
||||
return None, Response(
|
||||
{"error": True, "message": "File must have a .csv extension"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
file_bytes = upload.read()
|
||||
if len(file_bytes) > MAX_UPLOAD_BYTES:
|
||||
return None, Response(
|
||||
{"error": True, "message": "File exceeds the 5 MB upload limit"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return file_bytes, None
|
||||
|
||||
|
||||
class ContactImportPreviewView(APIView):
|
||||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
parser_classes = (MultiPartParser,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"],
|
||||
request=inline_serializer(
|
||||
name="ContactImportPreviewRequest",
|
||||
fields={"file": serializers.FileField()},
|
||||
),
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ContactImportPreviewResponse",
|
||||
fields={
|
||||
"header_error": serializers.CharField(allow_null=True),
|
||||
"valid": serializers.ListField(child=serializers.DictField()),
|
||||
"errors": serializers.ListField(child=serializers.DictField()),
|
||||
"summary": serializers.DictField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not _can_import(request.profile):
|
||||
return Response(
|
||||
{"error": True, "message": "Permission denied"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
file_bytes, err = _read_upload(request)
|
||||
if err is not None:
|
||||
return err
|
||||
result = parse_and_validate(file_bytes, request.profile.org)
|
||||
return Response(result.to_dict(), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ContactImportCommitView(APIView):
|
||||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
parser_classes = (MultiPartParser,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"],
|
||||
request=inline_serializer(
|
||||
name="ContactImportCommitRequest",
|
||||
fields={"file": serializers.FileField()},
|
||||
),
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ContactImportCommitResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"created": serializers.IntegerField(),
|
||||
"ids": serializers.ListField(child=serializers.CharField()),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not _can_import(request.profile):
|
||||
return Response(
|
||||
{"error": True, "message": "Permission denied"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
file_bytes, err = _read_upload(request)
|
||||
if err is not None:
|
||||
return err
|
||||
result = commit_rows(file_bytes, request.profile.org, request.profile)
|
||||
http_status = (
|
||||
status.HTTP_400_BAD_REQUEST if result.get("error") else status.HTTP_200_OK
|
||||
)
|
||||
return Response(result, status=http_status)
|
||||
0
backend/contacts/services/__init__.py
Normal file
0
backend/contacts/services/__init__.py
Normal file
729
backend/contacts/services/csv_import.py
Normal file
729
backend/contacts/services/csv_import.py
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
"""CSV import for contacts.
|
||||
|
||||
Two-phase: `parse_and_validate` reads + validates a CSV without writing to the
|
||||
DB beyond read-only lookups; `commit_rows` writes inside a transaction. Both
|
||||
phases re-run validation so the commit endpoint is safe even if called
|
||||
directly.
|
||||
|
||||
Duplicate policy (per org):
|
||||
* email — hard error (DB enforces a per-org case-insensitive unique
|
||||
constraint; we mirror it in the importer so users see a clean
|
||||
row-level message instead of an IntegrityError at commit).
|
||||
* phone — hard error. There is no DB constraint, but most callers
|
||||
consider phone a unique identifier in practice. We normalize
|
||||
using `common.validators.normalize_phone` (digits-only, last
|
||||
10) so "+1 (202) 555-1234" matches "2025551234".
|
||||
* full name — hard error ONLY when the row has neither email nor phone to
|
||||
disambiguate from an existing same-named contact. Two people
|
||||
can legitimately share a name when other identifiers differ.
|
||||
|
||||
All reference lookups (account, assignees, teams) are bulk-prefetched once per
|
||||
call (one SELECT per reference type), scoped to the caller's org so a
|
||||
malicious CSV cannot reach across tenants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.text import slugify
|
||||
|
||||
from accounts.models import Account
|
||||
from common.models import Profile, Tags, Teams
|
||||
from common.utils import COUNTRIES
|
||||
from common.validators import normalize_phone
|
||||
from contacts.models import Contact
|
||||
|
||||
|
||||
REQUIRED_HEADERS = ("first_name", "last_name")
|
||||
OPTIONAL_HEADERS = (
|
||||
"email",
|
||||
"phone",
|
||||
"organization",
|
||||
"title",
|
||||
"department",
|
||||
"do_not_call",
|
||||
"linkedin_url",
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
"description",
|
||||
"account_name",
|
||||
"assigned_emails",
|
||||
"team_names",
|
||||
"tags",
|
||||
)
|
||||
KNOWN_HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
|
||||
|
||||
MAX_ROWS = 5000
|
||||
NAME_MAX_LEN = 255
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
PHONE_RE = re.compile(r"^[\d\s\-\(\)\+\.]{7,25}$")
|
||||
_url_validator = URLValidator(schemes=("http", "https"))
|
||||
TRUTHY = {"1", "true", "yes", "y", "t"}
|
||||
FALSY = {"", "0", "false", "no", "n", "f"}
|
||||
COUNTRY_CODES = {code.upper() for code, _label in COUNTRIES}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RowError:
|
||||
row: int # 1-based, matching CSV line numbers (header is row 0)
|
||||
field: str
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"row": self.row, "field": self.field, "message": self.message}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidatedRow:
|
||||
"""A row that passed validation; reference fields resolved to UUID strings."""
|
||||
|
||||
row: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
organization: str | None = None
|
||||
title: str | None = None
|
||||
department: str | None = None
|
||||
do_not_call: bool = False
|
||||
linkedin_url: str | None = None
|
||||
address_line: str | None = None
|
||||
city: str | None = None
|
||||
state: str | None = None
|
||||
postcode: str | None = None
|
||||
country: str | None = None
|
||||
description: str | None = None
|
||||
account_id: str | None = None
|
||||
assigned_ids: list[str] = field(default_factory=list)
|
||||
team_ids: list[str] = field(default_factory=list)
|
||||
tag_names: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"row": self.row,
|
||||
"first_name": self.first_name,
|
||||
"last_name": self.last_name,
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
"organization": self.organization,
|
||||
"title": self.title,
|
||||
"department": self.department,
|
||||
"do_not_call": self.do_not_call,
|
||||
"linkedin_url": self.linkedin_url,
|
||||
"address_line": self.address_line,
|
||||
"city": self.city,
|
||||
"state": self.state,
|
||||
"postcode": self.postcode,
|
||||
"country": self.country,
|
||||
"description": self.description,
|
||||
"account_id": self.account_id,
|
||||
"assigned_ids": list(self.assigned_ids),
|
||||
"team_ids": list(self.team_ids),
|
||||
"tag_names": list(self.tag_names),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
valid: list[ValidatedRow]
|
||||
errors: list[RowError]
|
||||
header_error: str | None = None
|
||||
|
||||
@property
|
||||
def summary(self) -> dict[str, int]:
|
||||
return {
|
||||
"total": len(self.valid) + len({e.row for e in self.errors}),
|
||||
"valid": len(self.valid),
|
||||
"invalid": len({e.row for e in self.errors}),
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"header_error": self.header_error,
|
||||
"valid": [r.to_dict() for r in self.valid],
|
||||
"errors": [e.to_dict() for e in self.errors],
|
||||
"summary": self.summary,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RefMaps:
|
||||
"""Reference values needed to validate + resolve rows in one pass.
|
||||
|
||||
Keys are lowercased; values are canonical UUID strings for the match.
|
||||
The three "existing_*" sets exist only for duplicate detection.
|
||||
"""
|
||||
|
||||
accounts: dict[str, str]
|
||||
profiles: dict[str, str]
|
||||
teams: dict[str, str]
|
||||
existing_emails: set[str]
|
||||
existing_phones: set[str]
|
||||
existing_full_names: set[str]
|
||||
|
||||
|
||||
def _decode(file_bytes: bytes) -> str | None:
|
||||
"""Decode CSV bytes as UTF-8 (with or without BOM).
|
||||
|
||||
Return None for non-UTF-8 so the caller can surface a "save as UTF-8"
|
||||
header_error rather than producing mojibake that passes validation.
|
||||
"""
|
||||
for encoding in ("utf-8-sig", "utf-8"):
|
||||
try:
|
||||
return file_bytes.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_headers(raw_headers: Iterable[str]) -> list[str]:
|
||||
return [(h or "").strip().lower() for h in raw_headers]
|
||||
|
||||
|
||||
def _split_multi(raw: str) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
return [part.strip() for part in raw.split(";") if part.strip()]
|
||||
|
||||
|
||||
def _parse_bool(raw: str) -> bool | None:
|
||||
if raw is None:
|
||||
return False
|
||||
val = raw.strip().lower()
|
||||
if val in TRUTHY:
|
||||
return True
|
||||
if val in FALSY:
|
||||
return False
|
||||
return None # caller treats None as "invalid input"
|
||||
|
||||
|
||||
def parse_and_validate(file_bytes: bytes, org) -> ImportResult:
|
||||
"""Parse a CSV byte string and validate every row against the given org.
|
||||
|
||||
All reference and duplicate lookups are scoped to `org` so a malicious CSV
|
||||
cannot reach across tenants.
|
||||
"""
|
||||
text = _decode(file_bytes)
|
||||
if text is None:
|
||||
return ImportResult(
|
||||
valid=[],
|
||||
errors=[],
|
||||
header_error="File could not be decoded as UTF-8. Save your CSV as UTF-8 and try again.",
|
||||
)
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return ImportResult(valid=[], errors=[], header_error="CSV is empty")
|
||||
|
||||
headers = _normalize_headers(rows[0])
|
||||
missing = [h for h in REQUIRED_HEADERS if h not in headers]
|
||||
if missing:
|
||||
return ImportResult(
|
||||
valid=[],
|
||||
errors=[],
|
||||
header_error=f"Missing required header(s): {', '.join(missing)}",
|
||||
)
|
||||
unknown = [h for h in headers if h and h not in KNOWN_HEADERS]
|
||||
if unknown:
|
||||
return ImportResult(
|
||||
valid=[],
|
||||
errors=[],
|
||||
header_error=f"Unknown header(s): {', '.join(unknown)}",
|
||||
)
|
||||
|
||||
data_rows = rows[1:]
|
||||
if len(data_rows) > MAX_ROWS:
|
||||
return ImportResult(
|
||||
valid=[],
|
||||
errors=[],
|
||||
header_error=f"Too many rows ({len(data_rows)}); limit is {MAX_ROWS}",
|
||||
)
|
||||
|
||||
# First pass: parse each row into a dict and skip blank rows.
|
||||
parsed: list[tuple[int, dict[str, str]]] = []
|
||||
for idx, raw_row in enumerate(data_rows, start=1):
|
||||
if not any((cell or "").strip() for cell in raw_row):
|
||||
continue
|
||||
record = {h: (raw_row[i].strip() if i < len(raw_row) else "") for i, h in enumerate(headers)}
|
||||
parsed.append((idx, record))
|
||||
|
||||
ref_maps = _build_ref_maps(parsed, org)
|
||||
|
||||
valid: list[ValidatedRow] = []
|
||||
errors: list[RowError] = []
|
||||
seen_emails: dict[str, int] = {} # email.lower() -> row number
|
||||
seen_phones: dict[str, int] = {} # normalized phone -> row number
|
||||
# Tracks rows that had NO email and NO phone — the same disambiguation
|
||||
# rule used vs the DB. A row with an email/phone is exempt because the
|
||||
# email/phone uniqueness checks already prevent silent duplicates.
|
||||
seen_full_names: dict[str, int] = {} # "first|last".lower() -> row number
|
||||
|
||||
for idx, record in parsed:
|
||||
row_errors, validated = _validate_and_build(
|
||||
idx, record, ref_maps, seen_emails, seen_phones, seen_full_names
|
||||
)
|
||||
if row_errors:
|
||||
errors.extend(row_errors)
|
||||
continue
|
||||
valid.append(validated)
|
||||
if validated.email:
|
||||
seen_emails[validated.email.lower()] = idx
|
||||
if validated.phone:
|
||||
normalized = normalize_phone(validated.phone)
|
||||
if normalized:
|
||||
seen_phones[normalized] = idx
|
||||
if not validated.email and not validated.phone:
|
||||
key = f"{validated.first_name.lower()}|{validated.last_name.lower()}"
|
||||
seen_full_names[key] = idx
|
||||
|
||||
return ImportResult(valid=valid, errors=errors)
|
||||
|
||||
|
||||
def _build_ref_maps(parsed: list[tuple[int, dict[str, str]]], org) -> _RefMaps:
|
||||
"""Bulk-prefetch every reference value referenced anywhere in the file.
|
||||
|
||||
One query per reference type, scoped to `org`. Keys are lowercased so
|
||||
row-level lookups are O(1) and case-insensitive. The three "existing_*"
|
||||
sets cover the duplicate-detection policy: email (DB-unique), phone
|
||||
(normalized digits), and full_name (only used when row has no email or
|
||||
phone for disambiguation).
|
||||
"""
|
||||
account_names: set[str] = set()
|
||||
assigned_emails: set[str] = set()
|
||||
team_names: set[str] = set()
|
||||
candidate_emails: set[str] = set()
|
||||
candidate_phones: set[str] = set()
|
||||
candidate_full_names: set[str] = set()
|
||||
|
||||
for _idx, record in parsed:
|
||||
account_name = record.get("account_name", "")
|
||||
if account_name:
|
||||
account_names.add(account_name.lower())
|
||||
for email in _split_multi(record.get("assigned_emails", "")):
|
||||
assigned_emails.add(email.lower())
|
||||
for team in _split_multi(record.get("team_names", "")):
|
||||
team_names.add(team.lower())
|
||||
email = record.get("email", "")
|
||||
if email:
|
||||
candidate_emails.add(email.lower())
|
||||
phone = record.get("phone", "")
|
||||
if phone:
|
||||
normalized = normalize_phone(phone)
|
||||
if normalized:
|
||||
candidate_phones.add(normalized)
|
||||
first = record.get("first_name", "")
|
||||
last = record.get("last_name", "")
|
||||
if first and last and not email and not phone:
|
||||
candidate_full_names.add(f"{first.lower()}|{last.lower()}")
|
||||
|
||||
accounts: dict[str, str] = {}
|
||||
if account_names:
|
||||
for pk, name_lower in (
|
||||
Account.objects.filter(org=org)
|
||||
.annotate(name_lower=Lower("name"))
|
||||
.filter(name_lower__in=account_names)
|
||||
.values_list("id", "name_lower")
|
||||
):
|
||||
accounts.setdefault(name_lower, str(pk))
|
||||
|
||||
profiles: dict[str, str] = {}
|
||||
if assigned_emails:
|
||||
for pk, email_lower in (
|
||||
Profile.objects.filter(org=org, is_active=True)
|
||||
.annotate(email_lower=Lower("user__email"))
|
||||
.filter(email_lower__in=assigned_emails)
|
||||
.values_list("id", "email_lower")
|
||||
):
|
||||
profiles.setdefault(email_lower, str(pk))
|
||||
|
||||
teams: dict[str, str] = {}
|
||||
if team_names:
|
||||
for pk, name_lower in (
|
||||
Teams.objects.filter(org=org)
|
||||
.annotate(name_lower=Lower("name"))
|
||||
.filter(name_lower__in=team_names)
|
||||
.values_list("id", "name_lower")
|
||||
):
|
||||
teams.setdefault(name_lower, str(pk))
|
||||
|
||||
existing_emails: set[str] = set()
|
||||
if candidate_emails:
|
||||
existing_emails = set(
|
||||
Contact.objects.filter(org=org)
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.filter(email_lower__in=candidate_emails)
|
||||
.values_list("email_lower", flat=True)
|
||||
)
|
||||
|
||||
existing_phones: set[str] = set()
|
||||
if candidate_phones:
|
||||
# Phone has no DB constraint and no normalized column; we have to scan
|
||||
# all contacts that have a phone in this org and normalize in Python.
|
||||
# Bounded by org size, not file size — fine for any reasonable tenant.
|
||||
for raw_phone in Contact.objects.filter(org=org).exclude(
|
||||
phone__isnull=True
|
||||
).exclude(phone="").values_list("phone", flat=True):
|
||||
normalized = normalize_phone(raw_phone)
|
||||
if normalized and normalized in candidate_phones:
|
||||
existing_phones.add(normalized)
|
||||
|
||||
existing_full_names: set[str] = set()
|
||||
if candidate_full_names:
|
||||
# Only run this query if any row has a full name but no email and no
|
||||
# phone — that's the only case where full-name dedup applies.
|
||||
for first, last in (
|
||||
Contact.objects.filter(org=org)
|
||||
.annotate(
|
||||
first_lower=Lower("first_name"),
|
||||
last_lower=Lower("last_name"),
|
||||
)
|
||||
.values_list("first_lower", "last_lower")
|
||||
):
|
||||
key = f"{first}|{last}"
|
||||
if key in candidate_full_names:
|
||||
existing_full_names.add(key)
|
||||
|
||||
return _RefMaps(
|
||||
accounts=accounts,
|
||||
profiles=profiles,
|
||||
teams=teams,
|
||||
existing_emails=existing_emails,
|
||||
existing_phones=existing_phones,
|
||||
existing_full_names=existing_full_names,
|
||||
)
|
||||
|
||||
|
||||
def _validate_and_build(
|
||||
idx: int,
|
||||
record: dict,
|
||||
refs: _RefMaps,
|
||||
seen_emails: dict[str, int],
|
||||
seen_phones: dict[str, int],
|
||||
seen_full_names: dict[str, int],
|
||||
) -> tuple[list[RowError], ValidatedRow | None]:
|
||||
"""Validate a single row and, on success, return the resolved ValidatedRow.
|
||||
|
||||
All reference resolution uses the prefetched maps — no DB calls here.
|
||||
"""
|
||||
errors: list[RowError] = []
|
||||
|
||||
first_name = record.get("first_name", "")
|
||||
last_name = record.get("last_name", "")
|
||||
if not first_name:
|
||||
errors.append(RowError(idx, "first_name", "First name is required"))
|
||||
elif len(first_name) > NAME_MAX_LEN:
|
||||
errors.append(
|
||||
RowError(idx, "first_name", f"First name exceeds {NAME_MAX_LEN} characters")
|
||||
)
|
||||
if not last_name:
|
||||
errors.append(RowError(idx, "last_name", "Last name is required"))
|
||||
elif len(last_name) > NAME_MAX_LEN:
|
||||
errors.append(
|
||||
RowError(idx, "last_name", f"Last name exceeds {NAME_MAX_LEN} characters")
|
||||
)
|
||||
|
||||
email = record.get("email", "")
|
||||
if email:
|
||||
if not EMAIL_RE.match(email):
|
||||
errors.append(RowError(idx, "email", f"'{email}' is not a valid email"))
|
||||
else:
|
||||
email_lower = email.lower()
|
||||
prior = seen_emails.get(email_lower)
|
||||
if prior:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"email",
|
||||
f"Duplicate email also used by row {prior} in this file",
|
||||
)
|
||||
)
|
||||
elif email_lower in refs.existing_emails:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"email",
|
||||
"A contact with this email already exists in your organization",
|
||||
)
|
||||
)
|
||||
|
||||
phone = record.get("phone", "")
|
||||
normalized_phone: str | None = None
|
||||
if phone:
|
||||
if not PHONE_RE.match(phone):
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"phone",
|
||||
"Phone must be 7-25 characters of digits and separators (+ - ( ) . space)",
|
||||
)
|
||||
)
|
||||
else:
|
||||
normalized_phone = normalize_phone(phone)
|
||||
if normalized_phone:
|
||||
prior = seen_phones.get(normalized_phone)
|
||||
if prior:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"phone",
|
||||
f"Duplicate phone number also used by row {prior} in this file",
|
||||
)
|
||||
)
|
||||
elif normalized_phone in refs.existing_phones:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"phone",
|
||||
"A contact with this phone number already exists in your organization",
|
||||
)
|
||||
)
|
||||
|
||||
# Full-name dedup only kicks in when there's nothing else to tell two
|
||||
# same-named people apart. If the row has an email or phone, we trust
|
||||
# those as the disambiguator (and have already checked them above).
|
||||
if first_name and last_name and not email and not phone:
|
||||
key = f"{first_name.lower()}|{last_name.lower()}"
|
||||
prior = seen_full_names.get(key)
|
||||
if prior:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"first_name",
|
||||
f"Same name as row {prior} in this file; add an email or phone to either row to confirm they're different people",
|
||||
)
|
||||
)
|
||||
elif key in refs.existing_full_names:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"first_name",
|
||||
"A contact with this name already exists; add an email or phone to confirm it's a different person",
|
||||
)
|
||||
)
|
||||
|
||||
linkedin_url = record.get("linkedin_url", "")
|
||||
if linkedin_url:
|
||||
try:
|
||||
_url_validator(linkedin_url)
|
||||
except DjangoValidationError:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"linkedin_url",
|
||||
"Must be a valid URL starting with http:// or https://",
|
||||
)
|
||||
)
|
||||
|
||||
country_raw = record.get("country", "")
|
||||
country: str | None = None
|
||||
if country_raw:
|
||||
country = country_raw.strip().upper()
|
||||
if country not in COUNTRY_CODES:
|
||||
errors.append(
|
||||
RowError(idx, "country", f"'{country_raw}' is not a known country code")
|
||||
)
|
||||
country = None
|
||||
|
||||
do_not_call_raw = record.get("do_not_call", "")
|
||||
do_not_call = _parse_bool(do_not_call_raw)
|
||||
if do_not_call is None:
|
||||
errors.append(
|
||||
RowError(idx, "do_not_call", "Use yes/no, true/false, or 1/0")
|
||||
)
|
||||
do_not_call = False
|
||||
|
||||
account_id: str | None = None
|
||||
account_name = record.get("account_name", "")
|
||||
if account_name:
|
||||
account_id = refs.accounts.get(account_name.lower())
|
||||
if account_id is None:
|
||||
errors.append(
|
||||
RowError(idx, "account_name", f"No account named '{account_name}'")
|
||||
)
|
||||
|
||||
assigned_ids: list[str] = []
|
||||
for assigned_email in _split_multi(record.get("assigned_emails", "")):
|
||||
if not EMAIL_RE.match(assigned_email):
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"assigned_emails",
|
||||
f"'{assigned_email}' is not a valid email",
|
||||
)
|
||||
)
|
||||
continue
|
||||
resolved = refs.profiles.get(assigned_email.lower())
|
||||
if resolved is None:
|
||||
errors.append(
|
||||
RowError(
|
||||
idx,
|
||||
"assigned_emails",
|
||||
f"No active member with email '{assigned_email}'",
|
||||
)
|
||||
)
|
||||
else:
|
||||
assigned_ids.append(resolved)
|
||||
|
||||
team_ids: list[str] = []
|
||||
for team_name in _split_multi(record.get("team_names", "")):
|
||||
resolved = refs.teams.get(team_name.lower())
|
||||
if resolved is None:
|
||||
errors.append(
|
||||
RowError(idx, "team_names", f"No team named '{team_name}'")
|
||||
)
|
||||
else:
|
||||
team_ids.append(resolved)
|
||||
|
||||
for length_field in (
|
||||
"organization",
|
||||
"title",
|
||||
"department",
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
):
|
||||
val = record.get(length_field, "")
|
||||
if val and len(val) > NAME_MAX_LEN:
|
||||
errors.append(
|
||||
RowError(idx, length_field, f"Exceeds {NAME_MAX_LEN} characters")
|
||||
)
|
||||
|
||||
postcode = record.get("postcode", "")
|
||||
if postcode and len(postcode) > 64:
|
||||
errors.append(RowError(idx, "postcode", "Exceeds 64 characters"))
|
||||
|
||||
if errors:
|
||||
return errors, None
|
||||
|
||||
return [], ValidatedRow(
|
||||
row=idx,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email or None,
|
||||
phone=phone or None,
|
||||
organization=record.get("organization") or None,
|
||||
title=record.get("title") or None,
|
||||
department=record.get("department") or None,
|
||||
do_not_call=do_not_call,
|
||||
linkedin_url=linkedin_url or None,
|
||||
address_line=record.get("address_line") or None,
|
||||
city=record.get("city") or None,
|
||||
state=record.get("state") or None,
|
||||
postcode=postcode or None,
|
||||
country=country,
|
||||
description=record.get("description") or None,
|
||||
account_id=account_id,
|
||||
assigned_ids=assigned_ids,
|
||||
team_ids=team_ids,
|
||||
tag_names=_split_multi(record.get("tags", "")),
|
||||
)
|
||||
|
||||
|
||||
def commit_rows(file_bytes: bytes, org, profile) -> dict[str, Any]:
|
||||
"""Re-parse the uploaded CSV and create contacts in a single transaction.
|
||||
|
||||
Returning structured counts lets the UI render a per-row outcome strip.
|
||||
Any unexpected error rolls back the whole batch. IntegrityError races
|
||||
(e.g. a contact with the same email created between preview and commit
|
||||
by another request) are caught and reported as a 400-equivalent payload
|
||||
so the user sees an actionable message instead of a generic 500.
|
||||
"""
|
||||
result = parse_and_validate(file_bytes, org)
|
||||
if result.header_error:
|
||||
return {
|
||||
"error": True,
|
||||
"header_error": result.header_error,
|
||||
"created": 0,
|
||||
}
|
||||
if result.errors:
|
||||
# Refuse to write anything if any row failed; users fix the file first.
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Fix the invalid rows before importing",
|
||||
"errors": [e.to_dict() for e in result.errors],
|
||||
"created": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
return _commit_validated(result.valid, org, profile)
|
||||
except IntegrityError as exc:
|
||||
# The atomic block rolled back; surface a friendly message and ask
|
||||
# the user to re-preview so the conflict shows up as a row error.
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
"A contact was created concurrently that conflicts with this "
|
||||
"import (likely a duplicate email). Re-run preview and try again."
|
||||
),
|
||||
"detail": str(exc),
|
||||
"created": 0,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _commit_validated(rows: list[ValidatedRow], org, profile) -> dict[str, Any]:
|
||||
created_ids: list[str] = []
|
||||
tag_cache: dict[str, Tags] = {}
|
||||
|
||||
for vr in rows:
|
||||
contact = Contact.objects.create(
|
||||
first_name=vr.first_name,
|
||||
last_name=vr.last_name,
|
||||
email=vr.email,
|
||||
phone=vr.phone,
|
||||
organization=vr.organization,
|
||||
title=vr.title,
|
||||
department=vr.department,
|
||||
do_not_call=vr.do_not_call,
|
||||
linkedin_url=vr.linkedin_url,
|
||||
address_line=vr.address_line,
|
||||
city=vr.city,
|
||||
state=vr.state,
|
||||
postcode=vr.postcode,
|
||||
country=vr.country,
|
||||
description=vr.description,
|
||||
account_id=vr.account_id,
|
||||
org=org,
|
||||
created_by=profile.user,
|
||||
)
|
||||
if vr.assigned_ids:
|
||||
contact.assigned_to.set(vr.assigned_ids)
|
||||
if vr.team_ids:
|
||||
contact.teams.set(vr.team_ids)
|
||||
if vr.tag_names:
|
||||
tag_objs = [_get_or_create_tag(name, org, tag_cache) for name in vr.tag_names]
|
||||
contact.tags.set(tag_objs)
|
||||
created_ids.append(str(contact.id))
|
||||
|
||||
return {
|
||||
"error": False,
|
||||
"created": len(created_ids),
|
||||
"ids": created_ids,
|
||||
}
|
||||
|
||||
|
||||
def _get_or_create_tag(name: str, org, cache: dict[str, Tags]) -> Tags:
|
||||
# slug+org is the unique key on Tags; using it for get_or_create collapses
|
||||
# the SELECT-then-INSERT race when concurrent imports reference the same tag.
|
||||
slug = slugify(name) or name.lower()
|
||||
if slug in cache:
|
||||
return cache[slug]
|
||||
tag, _ = Tags.objects.get_or_create(
|
||||
slug=slug,
|
||||
org=org,
|
||||
defaults={"name": name},
|
||||
)
|
||||
cache[slug] = tag
|
||||
return tag
|
||||
435
backend/contacts/tests/test_import.py
Normal file
435
backend/contacts/tests/test_import.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""Tests for the contacts CSV import endpoints (preview + commit).
|
||||
|
||||
Edge cases the policy specifically targets:
|
||||
* email dup vs DB and within-file (DB-enforced unique per org)
|
||||
* phone dup vs DB and within-file (normalized to last-10 digits)
|
||||
* full-name dup vs DB, but only when the row has no email and no phone
|
||||
* permission gate (non-admin user blocked)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from accounts.models import Account
|
||||
from common.models import Profile, Tags, Teams
|
||||
from contacts.models import Contact
|
||||
|
||||
|
||||
def _csv(headers: list[str], rows: list[list[str]]) -> SimpleUploadedFile:
|
||||
"""Build an in-memory CSV upload from header + row lists."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
return SimpleUploadedFile(
|
||||
"contacts.csv",
|
||||
buf.getvalue().encode("utf-8"),
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def existing_contact(org_a, admin_user):
|
||||
"""A contact already in org_a — used for vs-DB duplicate tests."""
|
||||
return Contact.objects.create(
|
||||
first_name="Pat",
|
||||
last_name="Lee",
|
||||
email="pat@acme.test",
|
||||
phone="+1 (202) 555-1234",
|
||||
org=org_a,
|
||||
created_by=admin_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_a(org_a, admin_user):
|
||||
return Account.objects.create(name="Acme Corp", org=org_a, created_by=admin_user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_a(org_a):
|
||||
return Teams.objects.create(name="Sales", description="", org=org_a)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestImportPreview:
|
||||
def test_happy_path(self, admin_client, org_a, account_a, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email", "phone", "account_name", "tags"],
|
||||
[["Alice", "Smith", "alice@example.com", "+1 555 111 2222", "Acme Corp", "vip;newsletter"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["header_error"] is None
|
||||
assert body["summary"] == {"total": 1, "valid": 1, "invalid": 0}
|
||||
row = body["valid"][0]
|
||||
assert row["first_name"] == "Alice"
|
||||
assert row["last_name"] == "Smith"
|
||||
assert row["account_id"] == str(account_a.id)
|
||||
assert row["tag_names"] == ["vip", "newsletter"]
|
||||
# Preview must not write anything
|
||||
assert Contact.objects.filter(org=org_a).count() == 0
|
||||
|
||||
def test_missing_required_header(self, admin_client, admin_profile):
|
||||
csv_file = _csv(["first_name"], [["Alice"]])
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "last_name" in (body["header_error"] or "")
|
||||
|
||||
def test_unknown_header_rejected(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "totally_made_up"],
|
||||
[["Alice", "Smith", "x"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["header_error"] and "totally_made_up" in body["header_error"]
|
||||
|
||||
def test_email_duplicate_vs_db(
|
||||
self, admin_client, existing_contact, admin_profile
|
||||
):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[["Different", "Person", "pat@acme.test"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
fields = {(e["row"], e["field"]) for e in body["errors"]}
|
||||
assert (1, "email") in fields
|
||||
|
||||
def test_email_duplicate_case_insensitive(
|
||||
self, admin_client, existing_contact, admin_profile
|
||||
):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[["Different", "Person", "PAT@ACME.TEST"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
|
||||
def test_email_duplicate_within_file(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[
|
||||
["A", "One", "dup@example.com"],
|
||||
["B", "Two", "dup@example.com"],
|
||||
],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["valid"] == 1
|
||||
assert body["summary"]["invalid"] == 1
|
||||
# The second row is the one that's flagged
|
||||
err = body["errors"][0]
|
||||
assert err["row"] == 2
|
||||
assert err["field"] == "email"
|
||||
assert "row 1" in err["message"]
|
||||
|
||||
def test_phone_duplicate_vs_db_normalized(
|
||||
self, admin_client, existing_contact, admin_profile
|
||||
):
|
||||
# existing_contact.phone == "+1 (202) 555-1234"; this is the same
|
||||
# number written differently — normalizer strips to "2025551234".
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "phone"],
|
||||
[["Different", "Person", "202-555-1234"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
fields = {(e["row"], e["field"]) for e in body["errors"]}
|
||||
assert (1, "phone") in fields
|
||||
|
||||
def test_phone_duplicate_within_file(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "phone"],
|
||||
[
|
||||
["A", "One", "+1 555 000 1111"],
|
||||
["B", "Two", "5550001111"],
|
||||
],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["valid"] == 1
|
||||
assert body["summary"]["invalid"] == 1
|
||||
err = body["errors"][0]
|
||||
assert err["row"] == 2 and err["field"] == "phone"
|
||||
|
||||
def test_full_name_dup_blocked_without_disambiguator(
|
||||
self, admin_client, existing_contact, admin_profile
|
||||
):
|
||||
# existing_contact is "Pat Lee" with pat@acme.test + a phone. Row has
|
||||
# the same name but NO email and NO phone → can't disambiguate.
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name"],
|
||||
[["Pat", "Lee"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
fields = {(e["row"], e["field"]) for e in body["errors"]}
|
||||
assert (1, "first_name") in fields
|
||||
|
||||
def test_full_name_dup_allowed_with_different_email(
|
||||
self, admin_client, existing_contact, admin_profile
|
||||
):
|
||||
# Same name, different email → allowed; could be a different person.
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[["Pat", "Lee", "other-pat@acme.test"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"] == {"total": 1, "valid": 1, "invalid": 0}
|
||||
|
||||
def test_full_name_dup_within_file_blocked(
|
||||
self, admin_client, admin_profile
|
||||
):
|
||||
# Two name-only rows with no email/phone — second row must be flagged.
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name"],
|
||||
[
|
||||
["Same", "Name"],
|
||||
["Same", "Name"],
|
||||
],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["valid"] == 1
|
||||
assert body["summary"]["invalid"] == 1
|
||||
err = body["errors"][0]
|
||||
assert err["row"] == 2 and err["field"] == "first_name"
|
||||
assert "row 1" in err["message"]
|
||||
|
||||
def test_linkedin_url_with_only_scheme_rejected(
|
||||
self, admin_client, admin_profile
|
||||
):
|
||||
# "https://" passes the old regex but is not a valid URL — Django's
|
||||
# URLValidator catches it.
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "linkedin_url"],
|
||||
[["A", "B", "https://"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
assert any(e["field"] == "linkedin_url" for e in body["errors"])
|
||||
|
||||
def test_invalid_email_format(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[["A", "B", "not-an-email"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
assert any(e["field"] == "email" for e in body["errors"])
|
||||
|
||||
def test_invalid_phone_format(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "phone"],
|
||||
[["A", "B", "abc"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
assert any(e["field"] == "phone" for e in body["errors"])
|
||||
|
||||
def test_invalid_country_code(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "country"],
|
||||
[["A", "B", "ZZ"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
assert any(e["field"] == "country" for e in body["errors"])
|
||||
|
||||
def test_invalid_linkedin_url(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "linkedin_url"],
|
||||
[["A", "B", "linkedin.com/in/a"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
assert any(e["field"] == "linkedin_url" for e in body["errors"])
|
||||
|
||||
def test_unknown_account_ref(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "account_name"],
|
||||
[["A", "B", "Nonexistent Co"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["summary"]["invalid"] == 1
|
||||
assert any(e["field"] == "account_name" for e in body["errors"])
|
||||
|
||||
def test_unknown_assigned_email_ref(self, admin_client, admin_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "assigned_emails"],
|
||||
[["A", "B", "ghost@nowhere.test"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
body = response.json()
|
||||
assert any(e["field"] == "assigned_emails" for e in body["errors"])
|
||||
|
||||
def test_permission_denied_for_regular_user(
|
||||
self, user_client, user_profile
|
||||
):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name"],
|
||||
[["A", "B"]],
|
||||
)
|
||||
response = user_client.post(
|
||||
"/api/contacts/import/preview/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_non_csv_extension_rejected(self, admin_client, admin_profile):
|
||||
upload = SimpleUploadedFile(
|
||||
"contacts.xlsx",
|
||||
b"first_name,last_name\nA,B\n",
|
||||
content_type="text/csv",
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/preview/", {"file": upload}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestImportCommit:
|
||||
def test_commits_in_one_transaction(
|
||||
self, admin_client, org_a, account_a, team_a, admin_profile
|
||||
):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email", "phone", "account_name", "team_names", "tags"],
|
||||
[
|
||||
["Alice", "Smith", "alice@example.com", "+1 555 111 2222", "Acme Corp", "Sales", "vip"],
|
||||
["Bob", "Jones", "bob@example.com", "+1 555 333 4444", "Acme Corp", "Sales", "vip;newsletter"],
|
||||
],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/commit/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["error"] is False
|
||||
assert body["created"] == 2
|
||||
contacts = Contact.objects.filter(org=org_a)
|
||||
assert contacts.count() == 2
|
||||
alice = contacts.get(email="alice@example.com")
|
||||
assert alice.account_id == account_a.id
|
||||
assert list(alice.teams.values_list("name", flat=True)) == ["Sales"]
|
||||
assert set(alice.tags.values_list("name", flat=True)) == {"vip"}
|
||||
# Tag was reused across both rows
|
||||
assert Tags.objects.filter(name="vip", org=org_a).count() == 1
|
||||
|
||||
def test_commit_refuses_if_any_row_invalid(
|
||||
self, admin_client, org_a, existing_contact, admin_profile
|
||||
):
|
||||
# Row 1 is valid; row 2 collides on email with the existing contact.
|
||||
# The whole batch must be rejected — atomic = all-or-nothing.
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[
|
||||
["New", "Person", "newp@example.com"],
|
||||
["Other", "Pat", "pat@acme.test"],
|
||||
],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/commit/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["error"] is True
|
||||
assert body["created"] == 0
|
||||
# Only the original contact exists
|
||||
assert Contact.objects.filter(org=org_a).count() == 1
|
||||
|
||||
def test_permission_denied_for_regular_user(self, user_client, user_profile):
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name"],
|
||||
[["A", "B"]],
|
||||
)
|
||||
response = user_client.post(
|
||||
"/api/contacts/import/commit/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_integrity_error_race_returns_400_not_500(
|
||||
self, admin_client, org_a, admin_user, admin_profile, monkeypatch
|
||||
):
|
||||
# Simulate the case where another request inserts a colliding contact
|
||||
# AFTER parse_and_validate has read the DB but BEFORE the create runs.
|
||||
# The DB then raises IntegrityError on the unique_contact_email_per_org
|
||||
# constraint — this must surface as a 400 with a friendly message, not
|
||||
# a 500 from Django's exception middleware.
|
||||
from django.db import IntegrityError
|
||||
|
||||
from contacts.services import csv_import
|
||||
|
||||
original_create = csv_import.Contact.objects.create
|
||||
|
||||
def racing_create(**kwargs):
|
||||
raise IntegrityError("duplicate key value violates unique constraint")
|
||||
|
||||
monkeypatch.setattr(csv_import.Contact.objects, "create", racing_create)
|
||||
|
||||
csv_file = _csv(
|
||||
["first_name", "last_name", "email"],
|
||||
[["Race", "Condition", "race@example.com"]],
|
||||
)
|
||||
response = admin_client.post(
|
||||
"/api/contacts/import/commit/", {"file": csv_file}, format="multipart"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["error"] is True
|
||||
assert body["created"] == 0
|
||||
assert "concurrently" in body["message"].lower()
|
||||
|
|
@ -1,11 +1,22 @@
|
|||
from django.urls import path
|
||||
|
||||
from contacts import views
|
||||
from contacts import import_views, views
|
||||
|
||||
app_name = "api_contacts"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ContactsListView.as_view()),
|
||||
# CSV import (must be before <str:pk>/ to avoid being captured as an ID)
|
||||
path(
|
||||
"import/preview/",
|
||||
import_views.ContactImportPreviewView.as_view(),
|
||||
name="contacts_import_preview",
|
||||
),
|
||||
path(
|
||||
"import/commit/",
|
||||
import_views.ContactImportCommitView.as_view(),
|
||||
name="contacts_import_commit",
|
||||
),
|
||||
path("<str:pk>/", views.ContactDetailView.as_view()),
|
||||
path("comment/<str:pk>/", views.ContactCommentView.as_view()),
|
||||
path("attachment/<str:pk>/", views.ContactAttachmentView.as_view()),
|
||||
|
|
|
|||
478
frontend/src/lib/components/contacts/ContactImportDrawer.svelte
Normal file
478
frontend/src/lib/components/contacts/ContactImportDrawer.svelte
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
<script>
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
FileText,
|
||||
Download,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
|
||||
/**
|
||||
* Two-step CSV import flow: upload → preview → confirm.
|
||||
* Network calls go to the SvelteKit form actions on contacts/+page.server.js,
|
||||
* which forward the multipart upload to Django.
|
||||
*
|
||||
* @type {{ open: boolean, onOpenChange?: (v: boolean) => void }}
|
||||
*/
|
||||
let { open = $bindable(false), onOpenChange } = $props();
|
||||
|
||||
/** @type {'select' | 'preview' | 'done'} */
|
||||
let step = $state('select');
|
||||
/** @type {File | null} */
|
||||
let file = $state(null);
|
||||
/** @type {HTMLInputElement | null} */
|
||||
let fileInputEl = $state(null);
|
||||
let dragOver = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
/** @type {{ summary: { total: number, valid: number, invalid: number }, valid: any[], errors: { row: number, field: string, message: string }[], header_error: string | null } | null} */
|
||||
let preview = $state(null);
|
||||
/** @type {string | null} */
|
||||
let formError = $state(null);
|
||||
/**
|
||||
* Per-row errors returned by the commit endpoint if the file changed
|
||||
* between preview and commit.
|
||||
* @type {{ row: number, field: string, message: string }[]}
|
||||
*/
|
||||
let commitErrors = $state([]);
|
||||
|
||||
/** @type {{ created: number, ids: string[] } | null} */
|
||||
let commitResult = $state(null);
|
||||
|
||||
const TEMPLATE_HEADERS = [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'phone',
|
||||
'organization',
|
||||
'title',
|
||||
'department',
|
||||
'do_not_call',
|
||||
'linkedin_url',
|
||||
'address_line',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
'description',
|
||||
'account_name',
|
||||
'assigned_emails',
|
||||
'team_names',
|
||||
'tags'
|
||||
];
|
||||
const TEMPLATE_EXAMPLE = [
|
||||
'Alice',
|
||||
'Smith',
|
||||
'alice@acme.test',
|
||||
'+1 (555) 123-4567',
|
||||
'Acme Corp',
|
||||
'VP of Sales',
|
||||
'Sales',
|
||||
'no',
|
||||
'https://linkedin.com/in/alice',
|
||||
'500 Main St',
|
||||
'Austin',
|
||||
'TX',
|
||||
'78701',
|
||||
'US',
|
||||
'Met at SaaStr 2024',
|
||||
'Acme Corp',
|
||||
'agent@yourco.com',
|
||||
'Sales',
|
||||
'vip;newsletter'
|
||||
];
|
||||
|
||||
function reset() {
|
||||
step = 'select';
|
||||
file = null;
|
||||
// Clearing the input's value lets the user re-pick the same filename
|
||||
// after editing the file on disk — without this, the browser dedupes
|
||||
// the change event and the drop zone appears unresponsive.
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
preview = null;
|
||||
commitResult = null;
|
||||
commitErrors = [];
|
||||
formError = null;
|
||||
busy = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) reset();
|
||||
});
|
||||
|
||||
function downloadTemplate() {
|
||||
const csv = [
|
||||
TEMPLATE_HEADERS.join(','),
|
||||
TEMPLATE_EXAMPLE.map((v) => (v.includes(',') ? `"${v}"` : v)).join(',')
|
||||
].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'contacts-import-template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** @param {{ row: number, field: string, message: string }[]} errs */
|
||||
function downloadErrorsList(errs) {
|
||||
if (!errs?.length) return;
|
||||
const rows = [['row', 'field', 'message'], ...errs.map((e) => [e.row, e.field, e.message])];
|
||||
const csv = rows
|
||||
.map((r) => r.map((c) => (String(c).includes(',') ? `"${c}"` : String(c))).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'contacts-import-errors.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** @param {Event & { currentTarget: HTMLInputElement }} e */
|
||||
function onFileChange(e) {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f) file = f;
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e */
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) file = f;
|
||||
}
|
||||
|
||||
async function submitPreview() {
|
||||
if (!file) return;
|
||||
formError = null;
|
||||
commitErrors = [];
|
||||
busy = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('?/importPreview', { method: 'POST', body: fd });
|
||||
const result = /** @type {any} */ (deserialize(await res.text()));
|
||||
if (result.type === 'failure') {
|
||||
formError = String(result.data?.importError || 'Preview failed');
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success' && result.data?.importPreview) {
|
||||
preview = result.data.importPreview;
|
||||
if (preview?.header_error) {
|
||||
formError = preview.header_error;
|
||||
return;
|
||||
}
|
||||
step = 'preview';
|
||||
} else {
|
||||
formError = 'Unexpected response from server';
|
||||
}
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Preview failed';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCommit() {
|
||||
if (!file || !preview || preview.errors.length > 0) return;
|
||||
commitErrors = [];
|
||||
busy = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('?/importCommit', { method: 'POST', body: fd });
|
||||
const result = /** @type {any} */ (deserialize(await res.text()));
|
||||
if (result.type === 'failure') {
|
||||
formError = String(result.data?.importError || 'Import failed');
|
||||
const errs = result.data?.importErrors;
|
||||
if (Array.isArray(errs) && errs.length) {
|
||||
commitErrors = errs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success' && result.data?.importCommit) {
|
||||
commitResult = result.data.importCommit;
|
||||
step = 'done';
|
||||
await invalidateAll();
|
||||
const n = commitResult?.created ?? 0;
|
||||
toast.success(`Imported ${n} contact${n === 1 ? '' : 's'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Import failed';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => onOpenChange?.(v)}>
|
||||
<Dialog.Content class="sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4" />
|
||||
Import contacts from CSV
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Upload a CSV of contacts. We'll validate every row — including duplicates
|
||||
by email, phone, and name — before writing anything.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if step === 'select'}
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full rounded-lg border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] p-6 text-left transition-colors hover:bg-[var(--surface-default)] {dragOver ? 'border-[var(--color-primary-default)] bg-[var(--surface-default)]' : ''}"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={onDrop}
|
||||
onclick={() => document.getElementById('contact-import-file')?.click()}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="h-8 w-8 shrink-0 text-[var(--text-secondary)]" />
|
||||
<div class="min-w-0">
|
||||
{#if file}
|
||||
<p class="truncate text-sm font-medium">{file.name}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{(file.size / 1024).toFixed(1)} KB · click to replace or drop another file
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm font-medium">Drop a CSV file here, or click to choose</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">.csv only, up to 5 MB</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
id="contact-import-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
class="hidden"
|
||||
onchange={onFileChange}
|
||||
/>
|
||||
|
||||
<div class="rounded-lg border border-[var(--border-default)] bg-[var(--surface-muted)] p-3 text-xs">
|
||||
<p class="font-medium">CSV format</p>
|
||||
<p class="mt-1 text-[var(--text-secondary)]">
|
||||
Required headers: <code class="rounded bg-[var(--surface-default)] px-1">first_name</code>,
|
||||
<code class="rounded bg-[var(--surface-default)] px-1">last_name</code>.
|
||||
Optional: email, phone, organization, title, department, do_not_call (yes/no),
|
||||
linkedin_url, address_line, city, state, postcode, country (2-letter code),
|
||||
description, account_name, assigned_emails, team_names, tags
|
||||
(semicolon-separated for the last three).
|
||||
</p>
|
||||
<p class="mt-1 text-[var(--text-secondary)]">
|
||||
Duplicates: rows with a previously-used email or phone (in your org or earlier in
|
||||
the file) are flagged. Two people with the same name are allowed only if at least
|
||||
one has an email or phone to tell them apart.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 inline-flex items-center gap-1 text-[var(--color-primary-default)] hover:underline"
|
||||
onclick={downloadTemplate}
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />Download CSV template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<div class="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">
|
||||
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!file || busy} onclick={submitPreview}>
|
||||
{#if busy}<Loader2 class="mr-1 h-3.5 w-3.5 animate-spin" />{/if}
|
||||
Preview
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else if step === 'preview' && preview}
|
||||
{@const hasErrors = preview.errors.length > 0}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-medium">
|
||||
Total: {preview.summary.total}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-3 py-1 text-xs font-medium text-green-800">
|
||||
<CheckCircle2 class="h-3.5 w-3.5" />Valid: {preview.summary.valid}
|
||||
</span>
|
||||
{#if preview.summary.invalid > 0}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-red-50 px-3 py-1 text-xs font-medium text-red-800">
|
||||
<AlertCircle class="h-3.5 w-3.5" />Invalid: {preview.summary.invalid}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if preview.valid.length > 0}
|
||||
<div class="max-h-48 overflow-auto rounded-md border border-[var(--border-default)]">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-[var(--surface-muted)]">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">#</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Name</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Email</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Phone</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Company</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.valid.slice(0, 20) as row (row.row)}
|
||||
<tr class="border-t border-[var(--border-default)]">
|
||||
<td class="px-2 py-1 text-[var(--text-secondary)]">{row.row}</td>
|
||||
<td class="max-w-[160px] truncate px-2 py-1">
|
||||
{row.first_name} {row.last_name}
|
||||
</td>
|
||||
<td class="max-w-[180px] truncate px-2 py-1 text-[var(--text-secondary)]">
|
||||
{row.email || '—'}
|
||||
</td>
|
||||
<td class="max-w-[140px] truncate px-2 py-1 text-[var(--text-secondary)]">
|
||||
{row.phone || '—'}
|
||||
</td>
|
||||
<td class="max-w-[140px] truncate px-2 py-1 text-[var(--text-secondary)]">
|
||||
{row.organization || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if preview.valid.length > 20}
|
||||
<div class="border-t border-[var(--border-default)] bg-[var(--surface-muted)] px-2 py-1 text-center text-[10px] text-[var(--text-secondary)]">
|
||||
Showing first 20 of {preview.valid.length} valid rows
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasErrors}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-red-800">
|
||||
{preview.errors.length} error{preview.errors.length === 1 ? '' : 's'} — fix the CSV before importing
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-[var(--color-primary-default)] hover:underline"
|
||||
onclick={() => downloadErrorsList(preview?.errors ?? [])}
|
||||
>
|
||||
<Download class="h-3 w-3" />Download errors
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-auto rounded-md border border-red-200 bg-red-50">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-red-100">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">Row</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Field</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.errors as err (`${err.row}-${err.field}-${err.message}`)}
|
||||
<tr class="border-t border-red-200">
|
||||
<td class="px-2 py-1 font-mono">{err.row}</td>
|
||||
<td class="px-2 py-1">{err.field}</td>
|
||||
<td class="px-2 py-1">{err.message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if commitErrors.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-red-800">
|
||||
Server rejected {commitErrors.length} row{commitErrors.length === 1 ? '' : 's'} during import — the file may have changed since preview
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-[var(--color-primary-default)] hover:underline"
|
||||
onclick={() => downloadErrorsList(commitErrors)}
|
||||
>
|
||||
<Download class="h-3 w-3" />Download errors
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-auto rounded-md border border-red-200 bg-red-50">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-red-100">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">Row</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Field</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each commitErrors as err (`${err.row}-${err.field}-${err.message}`)}
|
||||
<tr class="border-t border-red-200">
|
||||
<td class="px-2 py-1 font-mono">{err.row}</td>
|
||||
<td class="px-2 py-1">{err.field}</td>
|
||||
<td class="px-2 py-1">{err.message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formError}
|
||||
<div class="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">
|
||||
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => reset()}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={hasErrors || preview.summary.valid === 0 || busy}
|
||||
onclick={submitCommit}
|
||||
>
|
||||
{#if busy}<Loader2 class="mr-1 h-3.5 w-3.5 animate-spin" />{/if}
|
||||
Import {preview.summary.valid} contact{preview.summary.valid === 1 ? '' : 's'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else if step === 'done' && commitResult}
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<CheckCircle2 class="h-6 w-6 text-green-700" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-900">
|
||||
Imported {commitResult.created} contact{commitResult.created === 1 ? '' : 's'}
|
||||
</p>
|
||||
<p class="text-xs text-green-800">
|
||||
The contacts list has been refreshed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" onclick={() => (open = false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -10,8 +10,38 @@
|
|||
*/
|
||||
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { apiRequest, buildQueryParams } from '$lib/api-helpers.js';
|
||||
|
||||
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`;
|
||||
|
||||
/**
|
||||
* Forward a multipart upload to Django, preserving the auth cookie.
|
||||
* apiRequest only handles JSON, so file uploads need a direct fetch.
|
||||
*/
|
||||
async function forwardMultipart(endpoint, file, cookies) {
|
||||
const accessToken = cookies?.get?.('jwt_access');
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name || 'upload.csv');
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
||||
body: fd
|
||||
});
|
||||
const raw = await response.text();
|
||||
let body;
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : {};
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`forwardMultipart: non-JSON response from ${endpoint} (status ${response.status}):`,
|
||||
raw.slice(0, 500)
|
||||
);
|
||||
body = {};
|
||||
}
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load({ url, locals, cookies }) {
|
||||
const org = locals.org;
|
||||
|
|
@ -384,5 +414,34 @@ export const actions = {
|
|||
console.error('Error deleting contact:', err);
|
||||
return fail(400, { error: err.message || 'Failed to delete contact' });
|
||||
}
|
||||
},
|
||||
|
||||
importPreview: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { importError: 'Please choose a CSV file.' });
|
||||
}
|
||||
const { status, body } = await forwardMultipart('/contacts/import/preview/', file, cookies);
|
||||
if (status !== 200) {
|
||||
return fail(status, { importError: body?.message || 'Preview failed' });
|
||||
}
|
||||
return { importPreview: body };
|
||||
},
|
||||
|
||||
importCommit: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { importError: 'Please choose a CSV file.' });
|
||||
}
|
||||
const { status, body } = await forwardMultipart('/contacts/import/commit/', file, cookies);
|
||||
if (status !== 200) {
|
||||
return fail(status, {
|
||||
importError: body?.message || body?.header_error || 'Import failed',
|
||||
importErrors: body?.errors || []
|
||||
});
|
||||
}
|
||||
return { importCommit: body };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import {
|
||||
Plus,
|
||||
Eye,
|
||||
Upload,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
import { CrmDrawer } from '$lib/components/ui/crm-drawer';
|
||||
import { CustomFieldsPanel } from '$lib/components/custom-fields';
|
||||
import { CommentSection } from '$lib/components/ui/comment-section';
|
||||
import ContactImportDrawer from '$lib/components/contacts/ContactImportDrawer.svelte';
|
||||
import { getCurrentUser } from '$lib/api.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
|
|
@ -99,6 +101,9 @@
|
|||
let dropdownOptionsLoaded = $state(false);
|
||||
let dropdownOptionsLoading = $state(false);
|
||||
|
||||
// CSV bulk-import drawer
|
||||
let importOpen = $state(false);
|
||||
|
||||
// Account from URL param (for quick action from account page)
|
||||
let accountFromUrl = $state(false);
|
||||
let accountName = $state('');
|
||||
|
|
@ -793,6 +798,10 @@
|
|||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Button variant="outline" size="sm" class="gap-2" onclick={() => (importOpen = true)}>
|
||||
<Upload class="h-4 w-4" />
|
||||
Import CSV
|
||||
</Button>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
New Contact
|
||||
|
|
@ -1024,3 +1033,5 @@
|
|||
>
|
||||
<input type="hidden" name="contactId" value={formState.contactId} />
|
||||
</form>
|
||||
|
||||
<ContactImportDrawer bind:open={importOpen} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue