@@ -136,6 +136,79 @@ def clean(self, data, initial=None):
136136 raise forms .ValidationError (self .error_messages ["required" ])
137137
138138
139+ # ---------------------------------------------------------------------------
140+ # Phone number normalisation
141+ # ---------------------------------------------------------------------------
142+
143+
144+ def _normalize_phone_number (raw : str ) -> str :
145+ """Parse *raw* and return a consistently formatted phone string.
146+
147+ Accepted inputs (all produce the same normalised output):
148+ 2065551234 → (206) 555-1234
149+ 206 555 1234 → (206) 555-1234
150+ 206-555-1234 → (206) 555-1234
151+ 206.555.1234 → (206) 555-1234
152+ (206) 555-1234 → (206) 555-1234 (pass-through)
153+ 12065551234 → +1 (206) 555-1234
154+ +12065551234 → +1 (206) 555-1234
155+ +1 206 555 1234 → +1 (206) 555-1234
156+ +1 (206) 555-1234 → +1 (206) 555-1234 (pass-through)
157+ +44 7911 123456 → +44 (791) 111-2345 (best-effort for non-NANP)
158+
159+ Raises ``forms.ValidationError`` when the input cannot be normalised.
160+ """
161+ raw = raw .strip ()
162+
163+ has_plus = raw .startswith ("+" )
164+ digits = re .sub (r"\D" , "" , raw )
165+
166+ country_code : str | None = None
167+ local_digits : str
168+
169+ if has_plus and len (digits ) > 10 :
170+ # Everything before the last 10 digits is the country code.
171+ cc_len = len (digits ) - 10
172+ country_code = digits [:cc_len ]
173+ local_digits = digits [cc_len :]
174+ elif len (digits ) == 11 and digits [0 ] == "1" :
175+ # NANP with implicit country code: 1XXXXXXXXXX
176+ country_code = "1"
177+ local_digits = digits [1 :]
178+ elif len (digits ) == 10 :
179+ local_digits = digits
180+ else :
181+ raise forms .ValidationError (
182+ "Enter a valid phone number. "
183+ "Examples: 2065551234, (206) 555-1234, or +1 (206) 555-1234."
184+ )
185+
186+ if len (local_digits ) != 10 :
187+ raise forms .ValidationError (
188+ "Phone number must contain 10 local digits. "
189+ "Examples: 2065551234 or +1 (206) 555-1234."
190+ )
191+
192+ local_fmt = f"({ local_digits [0 :3 ]} ) { local_digits [3 :6 ]} -{ local_digits [6 :10 ]} "
193+ return f"+{ country_code } { local_fmt } " if country_code else local_fmt
194+
195+
196+ class PhoneFormField (forms .CharField ):
197+ """CharField that auto-normalises phone numbers via ``_normalize_phone_number``.
198+
199+ Users may type in any common format (digits only, dashes, dots, spaces,
200+ parentheses, with or without a country code). The value stored in the
201+ submission is always the canonical ``(NXX) NXX-XXXX`` or
202+ ``+CC (NXX) NXX-XXXX`` form.
203+ """
204+
205+ def clean (self , value ):
206+ value = super ().clean (value )
207+ if not value :
208+ return value
209+ return _normalize_phone_number (value )
210+
211+
139212class DynamicForm (forms .Form ):
140213 """
141214 Dynamically generated form based on FormDefinition.
@@ -200,7 +273,7 @@ def __init__(self, form_definition, user=None, initial_data=None, *args, **kwarg
200273 Field (next_field .field_name ),
201274 css_class = f"col-md-6 field-wrapper field-{ next_field .field_name } " ,
202275 ),
203- css_class = "align-items-end " ,
276+ css_class = "align-items-start " ,
204277 )
205278 )
206279 i += 2
@@ -235,7 +308,7 @@ def __init__(self, form_definition, user=None, initial_data=None, *args, **kwarg
235308 )
236309 for f in group
237310 ],
238- css_class = "align-items-end " ,
311+ css_class = "align-items-start " ,
239312 )
240313 )
241314 else :
@@ -353,32 +426,21 @@ def add_field(self, field_def, initial_data):
353426 )
354427
355428 elif field_def .field_type == "phone" :
356- # Format: optional country code (+## ) then (###) ###-####
357- # Examples: (555) 867-5309 | +1 (555) 867-5309 | +44 (555) 867-5309
358- _phone_pattern = r"(\+[0-9]{1,3} )?\([0-9]{3}\) [0-9]{3}-[0-9]{4}"
429+ # Accepts any common format; normalised to (NXX) NXX-XXXX on clean().
359430 widget_attrs .update (
360431 {
361432 "type" : "tel" ,
362433 "inputmode" : "tel" ,
363- "pattern" : _phone_pattern ,
364- "placeholder" : widget_attrs .get ("placeholder" , "(555) 867-5309" ),
434+ "placeholder" : widget_attrs .get (
435+ "placeholder" , "e.g. 2065551234 or (206) 555-1234"
436+ ),
365437 }
366438 )
367- field = forms . CharField (
368- max_length = 20 ,
439+ self . fields [ field_def . field_name ] = PhoneFormField (
440+ max_length = 25 ,
369441 widget = forms .TextInput (attrs = widget_attrs ),
370442 ** field_args ,
371443 )
372- field .validators .append (
373- RegexValidator (
374- regex = r"^(\+[0-9]{1,3} )?\([0-9]{3}\) [0-9]{3}-[0-9]{4}$" ,
375- message = (
376- "Enter a phone number in the format (555) 867-5309 "
377- "or +1 (555) 867-5309 for international numbers."
378- ),
379- )
380- )
381- self .fields [field_def .field_name ] = field
382444
383445 elif field_def .field_type == "textarea" :
384446 widget_attrs ["rows" ] = 4
@@ -1159,31 +1221,21 @@ def _create_field(self, field_def, field_args, widget_attrs, is_editable):
11591221 )
11601222
11611223 elif field_def .field_type == "phone" :
1162- # Format: optional country code (+## ) then (###) ###-####
1163- _phone_pattern = r"(\+[0-9]{1,3} )?\([0-9]{3}\) [0-9]{3}-[0-9]{4}"
1224+ # Accepts any common format; normalised to (NXX) NXX-XXXX on clean().
11641225 widget_attrs .update (
11651226 {
11661227 "type" : "tel" ,
11671228 "inputmode" : "tel" ,
1168- "pattern" : _phone_pattern ,
1169- "placeholder" : widget_attrs .get ("placeholder" , "(555) 867-5309" ),
1229+ "placeholder" : widget_attrs .get (
1230+ "placeholder" , "e.g. 2065551234 or (206) 555-1234"
1231+ ),
11701232 }
11711233 )
1172- field = forms . CharField (
1173- max_length = 20 ,
1234+ self . fields [ field_def . field_name ] = PhoneFormField (
1235+ max_length = 25 ,
11741236 widget = forms .TextInput (attrs = widget_attrs ),
11751237 ** field_args ,
11761238 )
1177- field .validators .append (
1178- RegexValidator (
1179- regex = r"^(\+[0-9]{1,3} )?\([0-9]{3}\) [0-9]{3}-[0-9]{4}$" ,
1180- message = (
1181- "Enter a phone number in the format (555) 867-5309 "
1182- "or +1 (555) 867-5309 for international numbers."
1183- ),
1184- )
1185- )
1186- self .fields [field_def .field_name ] = field
11871239
11881240 elif field_def .field_type == "textarea" :
11891241 widget_attrs ["rows" ] = 4
0 commit comments