feat: implement CSV contact import with two-step preview and validation flow

This commit is contained in:
Ashwin 2026-05-25 17:16:37 +05:30
parent c0d5f5892f
commit e0127e26c5
8 changed files with 1857 additions and 1 deletions

View 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)

View file

View 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

View 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()

View file

@ -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()),

View 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>

View file

@ -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 };
}
};

View file

@ -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} />