@@ -216,21 +216,189 @@ def to_params(self) -> Dict[str, str]:
216216 # Response shapes
217217 # ---
218218
219+ class JobCategory (BaseModel ):
220+ """Represents a job series classification associated with a posting."""
221+
222+ code : Optional [str ] = Field (default = None , alias = "Code" )
223+ name : Optional [str ] = Field (default = None , alias = "Name" )
224+
225+ class JobGrade (BaseModel ):
226+ """Represents the job grade (e.g. GS) tied to the posting."""
227+
228+ code : Optional [str ] = Field (default = None , alias = "Code" )
229+ current_grade : Optional [str ] = Field (default = None , alias = "CurrentGrade" )
230+
231+ class PositionSchedule (BaseModel ):
232+ """Represents the work schedule for the position."""
233+
234+ code : Optional [str ] = Field (default = None , alias = "Code" )
235+ name : Optional [str ] = Field (default = None , alias = "Name" )
236+
237+ class PositionOfferingType (BaseModel ):
238+ """Represents the appointment type (e.g. permanent, term)."""
239+
240+ code : Optional [str ] = Field (default = None , alias = "Code" )
241+ name : Optional [str ] = Field (default = None , alias = "Name" )
242+
243+ class PositionRemuneration (BaseModel ):
244+ """Represents a salary range entry for the position."""
245+
246+ minimum : Optional [float ] = Field (default = None , alias = "MinimumRange" )
247+ maximum : Optional [float ] = Field (default = None , alias = "MaximumRange" )
248+ rate_interval_code : Optional [str ] = Field (
249+ default = None , alias = "RateIntervalCode"
250+ )
251+ description : Optional [str ] = Field (default = None , alias = "Description" )
252+
253+ @field_validator ("minimum" , "maximum" , mode = "before" )
254+ @classmethod
255+ def _normalize_amount (cls , value : Any ) -> Optional [float ]:
256+ """Normalize remuneration amounts to floats."""
257+
258+ if value in (None , "" ):
259+ return None
260+ if isinstance (value , (int , float )):
261+ return float (value )
262+ if isinstance (value , str ):
263+ cleaned = value .replace ("," , "" ).replace ("$" , "" ).strip ()
264+ if not cleaned :
265+ return None
266+ try :
267+ return float (cleaned )
268+ except ValueError :
269+ return None
270+ return None
271+
272+ class JobLocation (BaseModel ):
273+ """Represents a structured job location entry."""
274+
275+ name : Optional [str ] = Field (default = None , alias = "LocationName" )
276+ code : Optional [str ] = Field (default = None , alias = "LocationCode" )
277+ country_code : Optional [str ] = Field (default = None , alias = "CountryCode" )
278+ state_code : Optional [str ] = Field (default = None , alias = "CountrySubDivisionCode" )
279+ city_name : Optional [str ] = Field (default = None , alias = "CityName" )
280+ latitude : Optional [float ] = Field (default = None , alias = "Latitude" )
281+ longitude : Optional [float ] = Field (default = None , alias = "Longitude" )
282+
283+ @field_validator ("latitude" , "longitude" , mode = "before" )
284+ @classmethod
285+ def _normalize_coordinate (cls , value : Any ) -> Optional [float ]:
286+ """Normalize coordinate values to floats."""
287+
288+ if value in (None , "" ):
289+ return None
290+ try :
291+ return float (value )
292+ except (TypeError , ValueError ):
293+ return None
294+
295+ class WhoMayApplyInfo (BaseModel ):
296+ """Represents the structured WhoMayApply block."""
297+
298+ name : Optional [str ] = Field (default = None , alias = "Name" )
299+ code : Optional [str ] = Field (default = None , alias = "Code" )
300+
301+ class UserAreaDetails (BaseModel ):
302+ """Represents metadata stored under the UserArea.Details field."""
303+
304+ job_summary : Optional [str ] = Field (default = None , alias = "JobSummary" )
305+ hiring_path : Optional [str ] = Field (default = None , alias = "HiringPath" )
306+ who_may_apply : Optional [SearchEndpoint .WhoMayApplyInfo ] = Field (
307+ default = None , alias = "WhoMayApply"
308+ )
309+
310+ class UserArea (BaseModel ):
311+ """Wrapper for additional USAJOBS metadata."""
312+
313+ details : Optional [SearchEndpoint .UserAreaDetails ] = Field (
314+ default = None , alias = "Details"
315+ )
316+
219317 class JobSummary (BaseModel ):
220318 """Normalized representation of a search result item."""
221319
222320 id : str = Field (alias = "MatchedObjectId" )
321+ position_id : Optional [str ] = Field (default = None , alias = "PositionID" )
223322 position_title : str = Field (alias = "PositionTitle" )
323+ position_uri : Optional [str ] = Field (default = None , alias = "PositionURI" )
324+ # URI to apply for the job offering
325+ apply_uri : List [str ] = Field (default_factory = list , alias = "ApplyURI" )
224326 organization_name : Optional [str ] = Field (default = None , alias = "OrganizationName" )
327+ department_name : Optional [str ] = Field (default = None , alias = "DepartmentName" )
225328 locations_display : Optional [str ] = Field (
226329 default = None , alias = "PositionLocationDisplay"
227330 )
331+ locations : List [SearchEndpoint .JobLocation ] = Field (
332+ default_factory = list , alias = "PositionLocation"
333+ )
334+ job_categories : List [SearchEndpoint .JobCategory ] = Field (
335+ default_factory = list , alias = "JobCategory"
336+ )
337+ job_grades : List [SearchEndpoint .JobGrade ] = Field (
338+ default_factory = list , alias = "JobGrade"
339+ )
340+ position_schedules : List [SearchEndpoint .PositionSchedule ] = Field (
341+ default_factory = list , alias = "PositionSchedule"
342+ )
343+ position_offerings : List [SearchEndpoint .PositionOfferingType ] = Field (
344+ default_factory = list , alias = "PositionOfferingType"
345+ )
346+ user_area : Optional [SearchEndpoint .UserArea ] = Field (
347+ default = None , alias = "UserArea"
348+ )
349+ qualification_summary : Optional [str ] = Field (
350+ default = None , alias = "QualificationSummary"
351+ )
228352 min_salary : Optional [float ] = Field (default = None , alias = "MinimumRange" )
229353 max_salary : Optional [float ] = Field (default = None , alias = "MaximumRange" )
354+ position_remuneration : List [SearchEndpoint .PositionRemuneration ] = Field (
355+ default_factory = list , alias = "PositionRemuneration"
356+ )
357+ publication_start_date : Optional [str ] = Field (
358+ default = None , alias = "PublicationStartDate"
359+ )
230360 application_close_date : Optional [str ] = Field (
231361 default = None , alias = "ApplicationCloseDate"
232362 )
233363
364+ def summary (self ) -> Optional [str ]:
365+ """Helper method returning the most descriptive summary for the job."""
366+
367+ if self .user_area and self .user_area .details :
368+ details = self .user_area .details
369+ if details and details .job_summary :
370+ return details .job_summary
371+ return self .qualification_summary
372+
373+ def salary_range (self ) -> tuple [Optional [float ], Optional [float ]]:
374+ """Helper method returning the salary range inferred from remuneration metadata."""
375+
376+ if self .position_remuneration :
377+ minima = [
378+ r .minimum
379+ for r in self .position_remuneration
380+ if r .minimum is not None
381+ ]
382+ maxima = [
383+ r .maximum
384+ for r in self .position_remuneration
385+ if r .maximum is not None
386+ ]
387+ min_val = min (minima ) if minima else None
388+ max_val = max (maxima ) if maxima else None
389+ if min_val is not None or max_val is not None :
390+ return min_val , max_val
391+ return self .min_salary , self .max_salary
392+
393+ def hiring_paths (self ) -> List [str ]:
394+ """Helper method returning normalized hiring path codes from the user area."""
395+
396+ if self .user_area and self .user_area .details :
397+ raw = self .user_area .details .hiring_path
398+ if raw :
399+ return [path .strip () for path in raw .split (";" ) if path .strip ()]
400+ return []
401+
234402 class SearchResult (BaseModel ):
235403 """Model of paginated search results."""
236404
0 commit comments