Skip to content

Commit d8422f5

Browse files
authored
Merge pull request #692 from MicroPyramid/dev
Dev
2 parents 571d242 + 9504048 commit d8422f5

36 files changed

Lines changed: 4854 additions & 2336 deletions

backend/common/management/commands/seed_data.py

Lines changed: 401 additions & 53 deletions
Large diffs are not rendered by default.

backend/contacts/import_views.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""CSV import endpoints for contacts.
2+
3+
Preview reads the uploaded file and returns row-level validation results
4+
without touching the DB. Commit re-runs validation and writes inside a single
5+
transaction. Both endpoints are gated to ADMIN or sales-access users so
6+
non-privileged members cannot mass-create contacts through this surface.
7+
"""
8+
9+
from drf_spectacular.utils import extend_schema, inline_serializer
10+
from rest_framework import serializers, status
11+
from rest_framework.parsers import MultiPartParser
12+
from rest_framework.permissions import IsAuthenticated
13+
from rest_framework.response import Response
14+
from rest_framework.views import APIView
15+
16+
from common.permissions import HasOrgContext
17+
from contacts.services.csv_import import commit_rows, parse_and_validate
18+
19+
20+
MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB; matches the UI hint
21+
22+
23+
def _can_import(profile) -> bool:
24+
"""Mass-create requires admin or explicit sales-access permission."""
25+
if profile is None:
26+
return False
27+
if getattr(profile, "role", None) == "ADMIN":
28+
return True
29+
if getattr(profile, "is_admin", False):
30+
return True
31+
return bool(getattr(profile, "has_sales_access", False))
32+
33+
34+
def _read_upload(request):
35+
"""Return (file_bytes, error_response). One of the two will be None.
36+
37+
The size cap is enforced against the actual bytes read, not against
38+
`upload.size`. `upload.size` is derived from a client-supplied
39+
Content-Length header for in-memory uploads and can be zero or absent
40+
even when the body is large, so trusting it for the limit check creates
41+
a bypass.
42+
"""
43+
upload = request.FILES.get("file")
44+
if not upload:
45+
return None, Response(
46+
{"error": True, "message": "No file uploaded (expected field 'file')"},
47+
status=status.HTTP_400_BAD_REQUEST,
48+
)
49+
name = (upload.name or "").lower()
50+
if not name.endswith(".csv"):
51+
return None, Response(
52+
{"error": True, "message": "File must have a .csv extension"},
53+
status=status.HTTP_400_BAD_REQUEST,
54+
)
55+
file_bytes = upload.read()
56+
if len(file_bytes) > MAX_UPLOAD_BYTES:
57+
return None, Response(
58+
{"error": True, "message": "File exceeds the 5 MB upload limit"},
59+
status=status.HTTP_400_BAD_REQUEST,
60+
)
61+
return file_bytes, None
62+
63+
64+
class ContactImportPreviewView(APIView):
65+
permission_classes = (IsAuthenticated, HasOrgContext)
66+
parser_classes = (MultiPartParser,)
67+
68+
@extend_schema(
69+
tags=["contacts"],
70+
request=inline_serializer(
71+
name="ContactImportPreviewRequest",
72+
fields={"file": serializers.FileField()},
73+
),
74+
responses={
75+
200: inline_serializer(
76+
name="ContactImportPreviewResponse",
77+
fields={
78+
"header_error": serializers.CharField(allow_null=True),
79+
"valid": serializers.ListField(child=serializers.DictField()),
80+
"errors": serializers.ListField(child=serializers.DictField()),
81+
"summary": serializers.DictField(),
82+
},
83+
)
84+
},
85+
)
86+
def post(self, request, *args, **kwargs):
87+
if not _can_import(request.profile):
88+
return Response(
89+
{"error": True, "message": "Permission denied"},
90+
status=status.HTTP_403_FORBIDDEN,
91+
)
92+
file_bytes, err = _read_upload(request)
93+
if err is not None:
94+
return err
95+
result = parse_and_validate(file_bytes, request.profile.org)
96+
return Response(result.to_dict(), status=status.HTTP_200_OK)
97+
98+
99+
class ContactImportCommitView(APIView):
100+
permission_classes = (IsAuthenticated, HasOrgContext)
101+
parser_classes = (MultiPartParser,)
102+
103+
@extend_schema(
104+
tags=["contacts"],
105+
request=inline_serializer(
106+
name="ContactImportCommitRequest",
107+
fields={"file": serializers.FileField()},
108+
),
109+
responses={
110+
200: inline_serializer(
111+
name="ContactImportCommitResponse",
112+
fields={
113+
"error": serializers.BooleanField(),
114+
"created": serializers.IntegerField(),
115+
"ids": serializers.ListField(child=serializers.CharField()),
116+
},
117+
)
118+
},
119+
)
120+
def post(self, request, *args, **kwargs):
121+
if not _can_import(request.profile):
122+
return Response(
123+
{"error": True, "message": "Permission denied"},
124+
status=status.HTTP_403_FORBIDDEN,
125+
)
126+
file_bytes, err = _read_upload(request)
127+
if err is not None:
128+
return err
129+
result = commit_rows(file_bytes, request.profile.org, request.profile)
130+
http_status = (
131+
status.HTTP_400_BAD_REQUEST if result.get("error") else status.HTTP_200_OK
132+
)
133+
return Response(result, status=http_status)

backend/contacts/services/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)