2121"""
2222
2323import os
24+ import re
2425import traceback
2526from typing import Dict , List , Optional
2627from uuid import UUID
3839 PostgresqlExtDatabase ,
3940 UUIDField ,
4041)
41- from pydantic import BaseModel , ConfigDict
42+ from pydantic import BaseModel , ConfigDict , field_validator , model_validator
4243from starlette .status import HTTP_403_FORBIDDEN
4344
4445from config import Configuration
@@ -278,6 +279,79 @@ async def get_queue_depth(
278279 raise HTTPException (status_code = 500 , detail = "Failed to get queue depth" )
279280
280281
282+ # --- vCon field validation constants & helpers ---
283+
284+ _VALID_ALG = frozenset ({
285+ "SHA-256" , "SHA-384" , "SHA-512" ,
286+ "HS256" , "HS384" , "HS512" ,
287+ "RS256" , "RS384" , "RS512" ,
288+ "ES256" , "ES384" , "ES512" ,
289+ "PS256" , "PS384" , "PS512" ,
290+ })
291+ _MIME_RE = re .compile (
292+ r'^[a-zA-Z0-9][a-zA-Z0-9!\#$&\-^_]*/[a-zA-Z0-9][a-zA-Z0-9!\#$&\-^_.+]*$'
293+ )
294+ _URL_RE = re .compile (r'^[a-zA-Z][a-zA-Z0-9+\-.]*://.+' )
295+ _TEL_RE = re .compile (r'^[+\d(][\d\s\-().+xX#*]{4,}$' )
296+
297+
298+ class DialogEntry (BaseModel ):
299+ """A single dialog entry within a vCon."""
300+ model_config = ConfigDict (extra = 'allow' )
301+
302+ duration : Optional [float ] = None
303+ start : Optional [str ] = None
304+ parties : Optional [List [int ]] = None
305+ url : Optional [str ] = None
306+ mimetype : Optional [str ] = None
307+ alg : Optional [str ] = None
308+
309+ @field_validator ("duration" )
310+ @classmethod
311+ def duration_non_negative (cls , v ):
312+ if v is not None and v < 0 :
313+ raise ValueError ("duration must be >= 0" )
314+ return v
315+
316+ @field_validator ("url" )
317+ @classmethod
318+ def url_valid (cls , v ):
319+ if v is not None and not _URL_RE .match (v ):
320+ raise ValueError (f"url does not look like a valid URL: { v !r} " )
321+ return v
322+
323+ @field_validator ("mimetype" )
324+ @classmethod
325+ def mimetype_valid (cls , v ):
326+ if v is not None and not _MIME_RE .match (v ):
327+ raise ValueError (f"mimetype has invalid format: { v !r} " )
328+ return v
329+
330+ @field_validator ("alg" )
331+ @classmethod
332+ def alg_known (cls , v ):
333+ if v is not None and v not in _VALID_ALG :
334+ raise ValueError (f"alg { v !r} is not a recognised algorithm" )
335+ return v
336+
337+
338+ class PartyEntry (BaseModel ):
339+ """A single party entry within a vCon."""
340+ model_config = ConfigDict (extra = 'allow' )
341+
342+ tel : Optional [str ] = None
343+
344+ @field_validator ("tel" )
345+ @classmethod
346+ def tel_valid (cls , v ):
347+ if v is not None and not _TEL_RE .match (v ):
348+ raise ValueError (f"tel has invalid format: { v !r} " )
349+ return v
350+
351+
352+ # --- end vCon field validation ---
353+
354+
281355class Vcon (BaseModel ):
282356 """Pydantic model representing a vCon (Voice Conversation) record.
283357
@@ -302,11 +376,25 @@ class Vcon(BaseModel):
302376 redacted : dict = {}
303377 appended : Optional [dict ] = None
304378 group : List [Dict ] = []
305- parties : List [Dict ] = []
306- dialog : List [Dict ] = []
379+ parties : List [PartyEntry ] = []
380+ dialog : List [DialogEntry ] = []
307381 analysis : List [Dict ] = []
308382 attachments : List [Dict ] = []
309383
384+ @model_validator (mode = 'after' )
385+ def check_party_refs (self ) -> 'Vcon' :
386+ """Ensure every dialog.parties index references an existing party."""
387+ n = len (self .parties )
388+ for i , d in enumerate (self .dialog ):
389+ if d .parties :
390+ for ref in d .parties :
391+ if ref < 0 or ref >= n :
392+ raise ValueError (
393+ f"dialog[{ i } ].parties contains index { ref } "
394+ f"which is out of range (parties has { n } entries)"
395+ )
396+ return self
397+
310398
311399if VCON_STORAGE :
312400 class VConPeeWee (Model ):
0 commit comments