11import csv
22import datetime
33import io
4+ import secrets
5+ from collections .abc import Mapping , Sequence
6+ from typing import Any
47
8+ from dateutil import parser
9+ from django import forms
10+ from django .core .cache import cache
511from django .core .files .uploadedfile import UploadedFile
12+ from django .urls import reverse
13+ from tablib import Dataset
614
715from core .views_utils import _normalize_str
816
17+ AUTO_DETECT_CHOICE : tuple [str , str ] = ("" , "Auto-detect" )
18+
19+
20+ def build_csv_header_choices (headers : Sequence [str ]) -> list [tuple [str , str ]]:
21+ return [AUTO_DETECT_CHOICE , * [(header , header ) for header in headers ]]
22+
23+
24+ def set_form_column_field_choices (
25+ * ,
26+ form : forms .Form ,
27+ field_names : Sequence [str ],
28+ headers : Sequence [str ],
29+ ) -> None :
30+ choices = build_csv_header_choices (headers )
31+ for field_name in field_names :
32+ if field_name in form .fields :
33+ form .fields [field_name ].choices = choices
34+
935
1036def norm_csv_header (value : str ) -> str :
1137 return "" .join (ch for ch in value .strip ().lower () if ch .isalnum ())
1238
1339
40+ def resolve_column_header (
41+ field_name : str ,
42+ headers : Sequence [str ],
43+ header_by_norm : Mapping [str , str ],
44+ column_overrides : Mapping [str , str ],
45+ * fallback_norms : str ,
46+ ) -> str | None :
47+ override = _normalize_str (column_overrides .get (field_name , "" ))
48+ if override :
49+ if override in headers :
50+ return override
51+
52+ override_norm = norm_csv_header (override )
53+ resolved_override = header_by_norm .get (override_norm )
54+ if resolved_override :
55+ return resolved_override
56+
57+ raise ValueError (f"Column '{ override } ' not found in CSV headers" )
58+
59+ for fallback in fallback_norms :
60+ fallback_norm = norm_csv_header (fallback )
61+ resolved = header_by_norm .get (fallback_norm )
62+ if resolved :
63+ return resolved
64+ return None
65+
66+
67+ def attach_unmatched_csv_to_result (
68+ result : Any ,
69+ dataset : Dataset ,
70+ cache_key_prefix : str ,
71+ reverse_url_name : str ,
72+ ) -> None :
73+ token = secrets .token_urlsafe (16 )
74+ cache_key = f"{ cache_key_prefix } :{ token } "
75+ csv_content = dataset .export ("csv" )
76+ cache .set (cache_key , csv_content , timeout = 60 * 60 )
77+
78+ download_url = reverse (reverse_url_name , kwargs = {"token" : token })
79+ # `Result` is a third-party import-export type with no extension hook;
80+ # dynamic attributes are used as a lightweight duck-typed contract.
81+ setattr (result , "unmatched_csv_content" , csv_content )
82+ setattr (result , "unmatched_download_url" , download_url )
83+
84+
85+ def sanitize_csv_cell (value : str ) -> str :
86+ """Prefix formula-starting characters to prevent spreadsheet formula injection."""
87+ if value and value [0 ] in ("=" , "+" , "-" , "@" , "\t " , "\r " ):
88+ return f"'{ value } "
89+ return value
90+
91+
1492def normalize_csv_email (value : object ) -> str :
1593 return _normalize_str (value ).lower ()
1694
@@ -27,24 +105,17 @@ def parse_csv_bool(value: object) -> bool:
27105 return normalized in {"1" , "y" , "yes" , "true" , "t" , "active" , "activemember" , "active member" }
28106
29107
30- def parse_csv_date (value : object ) -> datetime .datetime | None :
108+ def parse_csv_date (value : object ) -> datetime .date | None :
31109 raw = _normalize_str (value )
32110 if not raw :
33111 return None
34112
35- for fmt in ("%Y-%m-%d" , "%Y/%m/%d" , "%m/%d/%Y" , "%m/%d/%y" ):
36- try :
37- day = datetime .datetime .strptime (raw , fmt ).date ()
38- except ValueError :
39- continue
40- return datetime .datetime .combine (day , datetime .time (0 , 0 , 0 ), tzinfo = datetime .UTC )
41-
42113 try :
43- day = datetime . date . fromisoformat (raw )
44- except ValueError :
114+ parsed = parser . parse (raw , dayfirst = False , yearfirst = False )
115+ except ( parser . ParserError , TypeError , ValueError , OverflowError ) :
45116 return None
46117
47- return datetime . datetime . combine ( day , datetime . time ( 0 , 0 , 0 ), tzinfo = datetime . UTC )
118+ return parsed . date ( )
48119
49120
50121def extract_csv_headers_from_uploaded_file (uploaded : UploadedFile ) -> list [str ]:
0 commit comments