Coverage for backend/app/schemas.py: 100%

258 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-22 15:38 +0000

1"""Schemas for the JAM database 

2Create schemas should be used to create entries in the database. 

3Out schemas should be used to return data to the user. 

4Min schemas should be used to return minimal data to the user (enough to display the entry as a badge) and should not 

5contain reference to other tables. 

6Update schemas should be used to update existing entries in the database.""" 

7 

8from datetime import datetime, UTC 

9 

10from pydantic import BaseModel, EmailStr, computed_field 

11 

12 

13class Out(BaseModel): 

14 """Base model for all output schemas""" 

15 

16 id: int 

17 created_at: datetime 

18 modified_at: datetime 

19 

20 

21class OwnedOut(Out): 

22 """Base model for all output schemas owned by a user""" 

23 

24 owner_id: int 

25 

26 

27# ------------------------------------------------------- SETTINGS ------------------------------------------------------ 

28 

29 

30class SettingCreate(BaseModel): 

31 """Setting create schema""" 

32 

33 name: str 

34 value: str 

35 description: str | None = None 

36 

37 

38class SettingOut(SettingCreate, Out): 

39 """Setting output schema""" 

40 

41 pass 

42 

43 

44class SettingUpdate(SettingCreate): 

45 """Keyword update schema""" 

46 

47 name: str | None = None 

48 value: str | None = None 

49 

50 

51# -------------------------------------------------------- USER -------------------------------------------------------- 

52 

53 

54class UserCreate(BaseModel): 

55 password: str 

56 email: EmailStr 

57 

58 

59class UserOut(Out): 

60 email: EmailStr 

61 theme: str 

62 is_admin: bool = False 

63 last_login: datetime | None = None 

64 chase_threshold: int 

65 deadline_threshold: int 

66 update_limit: int 

67 

68 

69class UserLogin(BaseModel): 

70 email: EmailStr 

71 password: str 

72 

73 

74class UserUpdate(BaseModel): 

75 current_password: str | None = None 

76 email: EmailStr | None = None 

77 theme: str | None = None 

78 password: str | None = None 

79 is_admin: bool | None = None 

80 last_login: datetime | None = None 

81 chase_threshold: int | None = None 

82 deadline_threshold: int | None = None 

83 update_limit: int | None = None 

84 

85 

86# -------------------------------------------------------- TOKEN ------------------------------------------------------- 

87 

88 

89class Token(BaseModel): 

90 access_token: str 

91 token_type: str 

92 

93 

94class TokenData(BaseModel): 

95 id: str | None = None 

96 

97 

98# ------------------------------------------------------- KEYWORD ------------------------------------------------------ 

99 

100 

101class KeywordCreate(BaseModel): 

102 """Keyword create schema""" 

103 

104 name: str 

105 

106 

107class KeywordOut(KeywordCreate, OwnedOut): 

108 """Keyword output schema with full job data""" 

109 

110 jobs: list["JobOut"] = [] 

111 

112 

113class KeywordMinOut(KeywordCreate, OwnedOut): 

114 """Bare Keyword output schema""" 

115 

116 pass 

117 

118 

119class KeywordUpdate(KeywordCreate): 

120 """Keyword update schema""" 

121 

122 name: str | None = None 

123 

124 

125# ----------------------------------------------------- AGGREGATOR ----------------------------------------------------- 

126 

127 

128class AggregatorCreate(BaseModel): 

129 """Aggregator create schema""" 

130 

131 name: str 

132 url: str | None = None 

133 

134 

135class AggregatorOut(AggregatorCreate, OwnedOut): 

136 """Aggregator output schema with full job data and job applications""" 

137 

138 jobs: list["JobOut"] = [] 

139 job_applications: list["JobOut"] = [] 

140 

141 

142class AggregatorMinOut(AggregatorCreate, OwnedOut): 

143 """Bare aggregator output schema""" 

144 

145 pass 

146 

147 

148class AggregatorUpdate(AggregatorCreate): 

149 """Aggregator update schema""" 

150 

151 name: str | None = None 

152 

153 

154# ------------------------------------------------------- COMPANY ------------------------------------------------------ 

155 

156 

157class CompanyCreate(BaseModel): 

158 """Company create schema""" 

159 

160 name: str 

161 description: str | None = None 

162 url: str | None = None 

163 

164 

165class CompanyOut(CompanyCreate, OwnedOut): 

166 """Company output schema with job data and individuals""" 

167 

168 jobs: list["JobOut"] = [] 

169 persons: list["PersonOut"] = [] 

170 

171 

172class CompanyMinOut(CompanyCreate, OwnedOut): 

173 """Bare company output schema""" 

174 

175 pass 

176 

177 

178class CompanyUpdate(CompanyCreate): 

179 """Company update schema""" 

180 

181 name: str | None = None 

182 

183 

184# ------------------------------------------------------ LOCATION ------------------------------------------------------ 

185 

186 

187class LocationCreate(BaseModel): 

188 """Location create schema""" 

189 

190 postcode: str | None = None 

191 city: str | None = None 

192 country: str | None = None 

193 

194 

195class LocationOut(LocationCreate, OwnedOut): 

196 """Location output schema with job and interview data""" 

197 

198 name: str | None = None 

199 jobs: list["JobOut"] = [] 

200 interviews: list["InterviewOut"] = [] 

201 

202 

203class LocationMinOut(LocationCreate, OwnedOut): 

204 """Bare location output schema""" 

205 

206 name: str | None = None 

207 

208 

209class LocationUpdate(LocationCreate): 

210 """Location update schema""" 

211 

212 pass 

213 

214 

215# -------------------------------------------------------- FILES ------------------------------------------------------- 

216 

217 

218class FileCreate(BaseModel): 

219 """File create schema""" 

220 

221 filename: str 

222 type: str 

223 content: str 

224 size: int 

225 

226 

227class FileOut(FileCreate, OwnedOut): 

228 """File output schema""" 

229 

230 pass 

231 

232 

233class FileUpdate(FileCreate): 

234 """File update schema""" 

235 

236 filename: str | None = None 

237 type: str | None = None 

238 content: str | None = None 

239 size: int | None = None 

240 

241 

242# ------------------------------------------------------- PERSON ------------------------------------------------------- 

243 

244 

245class PersonCreate(BaseModel): 

246 """Person create schema""" 

247 

248 first_name: str 

249 last_name: str 

250 email: EmailStr | None = None 

251 phone: str | None = None 

252 linkedin_url: str | None = None 

253 role: str | None = None 

254 

255 # Foreign keys 

256 company_id: int | None = None 

257 

258 

259class PersonOut(PersonCreate, OwnedOut): 

260 """Person out schema with job data and bare interview data""" 

261 

262 company: CompanyMinOut | None = None 

263 interviews: list["InterviewMinOut"] = [] 

264 jobs: list["JobOut"] = [] 

265 name: str | None = None 

266 name_company: str | None = None 

267 

268 

269class PersonMinOut(PersonCreate, OwnedOut): 

270 """Bare person output schema""" 

271 

272 name: str | None = None 

273 name_company: str | None = None 

274 

275 

276class PersonUpdate(PersonCreate): 

277 """Person update schema""" 

278 

279 first_name: str | None = None 

280 last_name: str | None = None 

281 

282 

283# --------------------------------------------------------- JOB -------------------------------------------------------- 

284 

285 

286class JobCreate(BaseModel): 

287 """Job create schema""" 

288 

289 title: str 

290 description: str | None = None 

291 salary_min: float | None = None 

292 salary_max: float | None = None 

293 personal_rating: int | None = None 

294 url: str | None = None 

295 deadline: datetime | None = None 

296 note: str | None = None 

297 attendance_type: str | None = None 

298 application_date: datetime | None = None 

299 application_url: str | None = None 

300 application_status: str | None = None 

301 application_note: str | None = None 

302 applied_via: str | None = None 

303 

304 # Foreign keys 

305 company_id: int | None = None 

306 location_id: int | None = None 

307 duplicate_id: int | None = None 

308 source_id: int | None = None 

309 application_aggregator_id: int | None = None 

310 cv_id: int | None = None 

311 cover_letter_id: int | None = None 

312 keywords: list[int] = [] 

313 contacts: list[int] = [] 

314 

315 

316class JobOut(JobCreate, OwnedOut): 

317 """Job output schema with bare company, location, aggregator, keywords, contacts data and semi-full interview and update data""" 

318 

319 company: CompanyMinOut | None = None 

320 location: LocationMinOut | None = None 

321 source: AggregatorMinOut | None = None 

322 keywords: list[KeywordMinOut] = [] 

323 contacts: list[PersonMinOut] = [] 

324 application_aggregator: AggregatorMinOut | None = None 

325 interviews: list["InterviewAppOut"] = [] # get the full interviews 

326 updates: list["JobApplicationUpdateAppOut"] = [] # get the full updates 

327 name: str 

328 

329 @computed_field 

330 @property 

331 def last_update_date(self) -> datetime | None: 

332 """Computed property that returns the most recent activity date from application date, interviews, or updates""" 

333 

334 if self.application_date is None: 

335 return None 

336 

337 dates = [self.application_date] 

338 

339 # Add interview dates 

340 if self.interviews: 

341 dates.extend([interview.date for interview in self.interviews]) 

342 

343 # Add update dates 

344 if self.updates: 

345 dates.extend([update.date for update in self.updates]) 

346 

347 # Filter out None values and return the maximum date 

348 valid_dates = [d for d in dates if d is not None] 

349 return max(valid_dates) if valid_dates else self.created_at 

350 

351 @computed_field 

352 @property 

353 def last_update_type(self) -> str | None: 

354 """Computed property that returns the type of the most recent activity""" 

355 

356 if self.application_date is None: 

357 return None 

358 

359 most_recent_date = self.application_date 

360 most_recent_type = "Application" 

361 

362 # Check interviews 

363 if self.interviews: 

364 latest_interview = max(self.interviews, key=lambda x: x.date, default=None) 

365 if latest_interview and latest_interview.date > most_recent_date: 

366 most_recent_date = latest_interview.date 

367 most_recent_type = f"Interview ({len(self.interviews)})" 

368 

369 # Check updates 

370 if self.updates: 

371 latest_update = max(self.updates, key=lambda x: x.date, default=None) 

372 if latest_update and latest_update.date > most_recent_date: 

373 most_recent_type = f"Update ({len(self.updates)})" 

374 

375 return most_recent_type 

376 

377 @computed_field 

378 @property 

379 def days_since_last_update(self) -> int | None: 

380 """Calculate days since the last update""" 

381 

382 if self.application_date is None: 

383 return None 

384 now = datetime.now(UTC) 

385 return (now - self.last_update_date).days 

386 

387 @computed_field 

388 @property 

389 def days_until_deadline(self) -> int | None: 

390 """Calculate the number of days before the deadline""" 

391 

392 if self.deadline is None: 

393 return None 

394 now = datetime.now(UTC) 

395 return (self.deadline - now).days 

396 

397 

398class JobMinOut(OwnedOut): 

399 """Bare job output schema""" 

400 

401 title: str 

402 description: str | None 

403 salary_min: float | None 

404 salary_max: float | None 

405 personal_rating: int | None 

406 url: str | None 

407 deadline: datetime | None 

408 note: str | None 

409 attendance_type: str | None 

410 application_date: datetime 

411 application_url: str | None = None 

412 application_status: str 

413 application_note: str | None = None 

414 applied_via: str | None = None 

415 name: str 

416 

417 # Foreign keys 

418 company_id: int | None = None 

419 location_id: int | None = None 

420 duplicate_id: int | None = None 

421 source_id: int | None = None 

422 application_aggregator_id: int | None = None 

423 

424 

425class JobUpdate(JobCreate): 

426 """Job update schema""" 

427 

428 title: str | None = None 

429 

430 

431# ------------------------------------------------------ INTERVIEW ----------------------------------------------------- 

432 

433 

434class InterviewCreate(BaseModel): 

435 """Interview create schema""" 

436 

437 date: datetime 

438 type: str 

439 job_id: int 

440 attendance_type: str | None = None 

441 location_id: int | None = None 

442 note: str | None = None 

443 interviewers: list[int] | None = None 

444 

445 

446class InterviewOut(InterviewCreate, OwnedOut): 

447 """Interview output with bare location and person data, and job data""" 

448 

449 location: LocationMinOut | None = None 

450 interviewers: list[PersonMinOut] = [] 

451 job: JobOut | None = None 

452 

453 

454class InterviewAppOut(InterviewCreate, OwnedOut): 

455 """Interview output with bare location and person data""" 

456 

457 location: LocationMinOut | None = None 

458 interviewers: list[PersonMinOut] = [] 

459 

460 

461class InterviewMinOut(OwnedOut): 

462 """Bare interview output schema""" 

463 

464 date: datetime 

465 type: str 

466 location_id: int | None 

467 job_id: int 

468 note: str | None 

469 attendance_type: str | None 

470 

471 

472class InterviewUpdate(InterviewCreate): 

473 """Interview update schema""" 

474 

475 date: datetime | None = None 

476 type: str | None = None 

477 job_id: int | None = None 

478 

479 

480# ----------------------------------------------- JOB APPLICATION UPDATE ----------------------------------------------- 

481 

482 

483class JobApplicationUpdateCreate(BaseModel): 

484 """Job Application Update create schema""" 

485 

486 date: datetime 

487 type: str 

488 job_id: int 

489 note: str | None = None 

490 

491 

492class JobApplicationUpdateOut(JobApplicationUpdateCreate, OwnedOut): 

493 """Job Application Update output schema with job data""" 

494 

495 job: JobOut | None = None 

496 

497 

498class JobApplicationUpdateAppOut(JobApplicationUpdateCreate, OwnedOut): 

499 """Job Application Update output""" 

500 

501 pass 

502 

503 

504class JobApplicationUpdateUpdate(JobApplicationUpdateCreate): 

505 """Job Application Update update schema""" 

506 

507 date: datetime | None = None 

508 type: str | None = None 

509 job_id: int | None = None