Coverage for postrfp/shared/serial/models.py: 99%

629 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 21:34 +0000

1from enum import Enum 

2from datetime import datetime 

3from typing import List, Union, Optional, Any, Annotated 

4from typing_extensions import Self 

5from decimal import Decimal 

6 

7from pydantic import ( 

8 field_validator, 

9 model_validator, 

10 StringConstraints, 

11 ConfigDict, 

12 BaseModel, 

13 EmailStr, 

14 Field, 

15 PositiveInt, 

16 AnyHttpUrl, 

17 RootModel, 

18) 

19 

20from postrfp.shared.serial.common import Id, Pagination 

21 

22from .qmodels import Question, QElement, Choice, ElGrid 

23 

24from postrfp.model import notes 

25from postrfp.shared.constants import CEL_EXPRESSION_MAX_LENGTH 

26 

27 

28CONSTRAINED_ID = Annotated[int, Field(lt=4294967295, gt=0)] 

29 

30CONSTRAINED_ID_LIST = Annotated[List[CONSTRAINED_ID], Field(max_length=1000)] 

31 

32DOMAIN_REGEX = ( 

33 r"(([\da-zA-Z])([_\w-]{0,62})\.)+(([\da-zA-Z])[_\w-]{0,61})?([\da-zA-Z]" 

34 r"\.((xn\-\-[a-zA-Z\d]+)|([a-zA-Z\d]{2,})))" 

35) 

36 

37 

38class OrgType(str, Enum): 

39 RESPONDENT = "RESPONDENT" 

40 BUYER = "BUYER" 

41 CONSULTANT = "CONSULTANT" 

42 

43 

44class Error(BaseModel): 

45 error: str 

46 description: str 

47 

48 

49class ErrorList(BaseModel): 

50 description: str 

51 errors: List[Error] 

52 

53 

54class IdList(BaseModel): 

55 ids: List[int] 

56 

57 

58class Count(BaseModel): 

59 """ 

60 Generic count model 

61 """ 

62 

63 description: str 

64 count: int 

65 

66 

67class ShortName(BaseModel): 

68 name: Annotated[str, StringConstraints(min_length=1, max_length=255)] 

69 

70 

71class AnswerAttachmentIds(BaseModel): 

72 attachment_id: CONSTRAINED_ID 

73 answer_id: CONSTRAINED_ID 

74 

75 

76class NewProjectIds(Id): 

77 section_id: int = Field( 

78 ..., description="The ID of the Root section for the newly created project" 

79 ) 

80 

81 

82class NewClient(BaseModel): 

83 org_name: Annotated[str, StringConstraints(min_length=4, max_length=50)] 

84 domain_name: Optional[Annotated[str, StringConstraints(max_length=256)]] = None 

85 administrator_email: EmailStr 

86 administrator_name: Annotated[str, StringConstraints(min_length=4, max_length=50)] 

87 

88 

89class BaseOrganisation(BaseModel): 

90 model_config = ConfigDict(from_attributes=True) 

91 

92 id: Annotated[str, StringConstraints(min_length=3, max_length=50)] 

93 name: Annotated[str, StringConstraints(min_length=3, max_length=50)] 

94 type: OrgType | None = None 

95 public: bool = False 

96 domain_name: Optional[str] = None 

97 

98 @field_validator("type", mode="before") 

99 @classmethod 

100 def set_type(cls, v): 

101 # If from ORM then v is an enum value of OrganisationType 

102 # if so, serialise to the name 

103 return getattr(v, "name", v) 

104 

105 

106class Organisation(BaseOrganisation): 

107 password_expiry: int = 0 

108 

109 

110class Participant(BaseModel): 

111 model_config = ConfigDict(from_attributes=True) 

112 organisation: Organisation 

113 role: Annotated[str, StringConstraints(max_length=255)] 

114 

115 

116class ParticipantList(RootModel): 

117 root: list[Participant] 

118 

119 

120class UpdateParticipant(BaseModel): 

121 org_id: Annotated[str, StringConstraints(max_length=50)] 

122 role: Annotated[str, StringConstraints(max_length=255)] 

123 

124 

125class UpdateParticipantList(RootModel): 

126 model_config = ConfigDict(json_schema_extra={"maxItems": 20}) 

127 root: list[UpdateParticipant] 

128 

129 

130class UserType(str, Enum): 

131 standard = "standard" 

132 restricted = "restricted" 

133 

134 

135class UserId(BaseModel): 

136 id: Annotated[str, StringConstraints(max_length=50)] 

137 

138 

139class EditableUser(UserId): 

140 model_config = ConfigDict(from_attributes=True) 

141 id: Annotated[str, StringConstraints(min_length=5, max_length=50)] 

142 org_id: Annotated[str, StringConstraints(min_length=4, max_length=50)] 

143 fullname: Annotated[str, StringConstraints(min_length=5, max_length=50)] 

144 email: EmailStr 

145 type: UserType = UserType.standard 

146 roles: Annotated[ 

147 List[Annotated[str, StringConstraints(min_length=3, max_length=255)]], 

148 Field(max_length=20), 

149 ] 

150 

151 

152class BaseUser(BaseModel): 

153 model_config = ConfigDict(from_attributes=True, populate_by_name=True) 

154 id: str 

155 org_id: str 

156 type: str 

157 fullname: str 

158 email: str 

159 previous_login_date: Optional[datetime] = None 

160 

161 

162class UserList(RootModel): 

163 root: list[BaseUser] 

164 

165 

166class User(BaseUser): 

167 organisation: Organisation 

168 type: UserType = UserType.standard 

169 roles: List[str] 

170 

171 @field_validator("roles", mode="before") 

172 def get_roles(cls, val_list): 

173 if len(val_list) and isinstance(val_list[0], str): 

174 return val_list 

175 else: 

176 return [role.role_id for role in val_list] 

177 

178 

179class FullUser(User): 

180 """ 

181 Don't use for incoming data - permissions are derived from user's roles 

182 thus should not be set directly. 

183 """ 

184 

185 permissions: set[str] = Field( 

186 validation_alias="sorted_permissions", 

187 description="List of this user's permissions, based on their roles", 

188 ) 

189 

190 # Note - if using a validation_alias then json_schema_mode_override must 

191 # be set to "serialization" or the json schema for this model will use 

192 # the validation alias instead of the field name. 

193 model_config = ConfigDict( 

194 from_attributes=True, json_schema_mode_override="serialization" 

195 ) 

196 

197 

198class ProjectUser(User): 

199 effectivePermissions: list[str] = Field(default_factory=list) 

200 userPermissions: list[str] = Field(default_factory=list) 

201 participantPermissions: list[str] = Field(default_factory=list) 

202 participantRole: Optional[str] = None 

203 

204 

205class OrgWithUsers(BaseOrganisation): 

206 model_config = ConfigDict(from_attributes=True) 

207 users: List[BaseUser] 

208 

209 

210class BaseIssue(BaseModel): 

211 label: Annotated[str, StringConstraints(max_length=250)] | None = None 

212 deadline: datetime | None = None 

213 

214 

215class NewIssue(BaseIssue): 

216 respondent_id: Optional[Annotated[str, StringConstraints(max_length=50)]] = None 

217 respondent_email: Optional[EmailStr] = None 

218 

219 @model_validator(mode="after") 

220 def either_email_or_respondent(self) -> Self: 

221 if not self.respondent_email and not self.respondent_id: 

222 raise ValueError("Either respondent_id or respondent_email must be set") 

223 elif self.respondent_id and self.respondent_email: 

224 raise ValueError("Not permitted to set both respondent or respondent_email") 

225 return self 

226 

227 @field_validator("respondent_email", "respondent_id") 

228 def nonify(cls, value): 

229 if value is not None and value.strip() == "": 

230 return None 

231 return value 

232 

233 

234class ListIssue(NewIssue): 

235 model_config = ConfigDict(from_attributes=True) 

236 issue_id: int 

237 winloss_exposed: bool = False 

238 winloss_expiry: Optional[datetime] = None 

239 project_title: str 

240 project_id: int 

241 status: str 

242 issue_date: Optional[datetime] = None 

243 submitted_date: Optional[datetime] = None 

244 

245 

246class IssuesList(BaseModel): 

247 data: List[ListIssue] 

248 pagination: Pagination 

249 

250 

251class UpdateableIssue(BaseIssue): 

252 award_status: Optional[int] = None 

253 internal_comments: Optional[str] = None 

254 feedback: Optional[str] = None 

255 winloss_exposed: bool = False 

256 winloss_expiry: Optional[datetime] = None 

257 

258 

259class IssueStatuses(str, Enum): 

260 NOT_SENT = ("Not Sent",) 

261 OPPORTUNITY = ("Opportunity",) 

262 ACCEPTED = ("Accepted",) 

263 UPDATEABLE = ("Updateable",) 

264 DECLINED = ("Declined",) 

265 SUBMITTED = ("Submitted",) 

266 RETRACTED = ("Retracted",) 

267 

268 

269class Issue(UpdateableIssue): 

270 """ 

271 Represents the directly editable fields of an Issue 

272 

273 Either respondent or respondent_email must be set, but not both 

274 """ 

275 

276 model_config = ConfigDict(from_attributes=True) 

277 id: int 

278 project_id: int 

279 respondent: Optional[Organisation] = None 

280 respondent_email: Optional[EmailStr] = None 

281 status: str 

282 accepted_date: Optional[datetime] = None 

283 submitted_date: Optional[datetime] = None 

284 issue_date: Optional[datetime] = Field( 

285 None, description="Date the Issue was published" 

286 ) 

287 

288 

289class Issues(RootModel): 

290 root: list[Issue] 

291 

292 

293class VendorIssue(Issue): 

294 title: str = Field(..., description="Title of the parent Project") 

295 owner_org: Organisation = Field( 

296 ..., description="Org that created the Project - the Buyer" 

297 ) 

298 is_watched: bool = Field( 

299 False, description="Shows if the current user is watching this issue" 

300 ) 

301 

302 

303class IssueStatus(BaseModel): 

304 new_status: IssueStatuses 

305 

306 

307class IssueUseWorkflow(BaseModel): 

308 use_workflow: bool 

309 

310 

311class RespondentNote(BaseModel): 

312 note_text: str 

313 private: bool = Field( 

314 False, 

315 alias="internal", 

316 description="If true, visible only to the respondent organisation", 

317 ) 

318 

319 

320target_docs = "ID of the Organisation to who this Note is addressed" 

321 

322 

323class ProjectNote(BaseModel): 

324 target_org_id: Optional[str] = Field(None, description=target_docs) 

325 note_text: Annotated[str, StringConstraints(max_length=16384, min_length=1)] 

326 private: bool = Field( 

327 False, 

328 alias="internal", 

329 description="If true, visible only to buyer (participant) organisations", 

330 ) 

331 

332 

333class ReadNote(ProjectNote): 

334 id: Optional[int] = Field(None, alias="note_id") 

335 note_time: datetime 

336 org_id: Annotated[str, StringConstraints(max_length=50)] = Field( 

337 ..., alias="organisation_id" 

338 ) 

339 distribution: notes.Distribution 

340 model_config = ConfigDict(from_attributes=True, populate_by_name=True) 

341 

342 

343class ReadNotes(RootModel): 

344 root: list[ReadNote] 

345 

346 

347copy_ws_docs = "Copy weightings from an existing Weighting Set with this ID" 

348initial_weight_docs = "Initial weighting value for each question and section" 

349 

350 

351class NewWeightSet(ShortName): 

352 """ 

353 Create a new weighting set. This can be populated with either an initial value 

354 for each section and question (initial_value field) or by copying an existing weightset. 

355 

356 Only one of initial_value and source_weightset_id can be set 

357 """ 

358 

359 initial_value: Optional[int] = Field( 

360 ge=0, le=100, description=initial_weight_docs, default=None 

361 ) 

362 source_weightset_id: Optional[int] = Field( 

363 ge=0, lt=2147483648, description=copy_ws_docs, default=None 

364 ) 

365 

366 @model_validator(mode="before") 

367 @classmethod 

368 def check_src_weightset(cls, values): 

369 initial_value = values.get("initial_value", None) 

370 source_weightset_id = values.get("source_weightset_id", None) 

371 if source_weightset_id is None: 

372 if initial_value is None: 

373 raise ValueError( 

374 "Both of initial_value & source_weightset_id cannot be null" 

375 ) 

376 else: 

377 if initial_value is not None: 

378 raise ValueError( 

379 "initial_value must be null if source_weightset_id is defined" 

380 ) 

381 return values 

382 

383 

384class WeightSet(ShortName): 

385 id: Optional[int] = None # default weightset 

386 

387 

388class QWeight(BaseModel): 

389 model_config = ConfigDict(populate_by_name=True) 

390 question_instance_id: CONSTRAINED_ID = Field(alias="question_id") 

391 weight: Annotated[float, Field(ge=0.0)] 

392 

393 

394class SecWeight(BaseModel): 

395 section_id: CONSTRAINED_ID 

396 weight: Annotated[float, Field(ge=0.0)] 

397 

398 

399class Weightings(BaseModel): 

400 questions: List[QWeight] 

401 sections: List[SecWeight] 

402 

403 

404class WeightingsDoc(Weightings): 

405 weightset_id: Optional[CONSTRAINED_ID] = None 

406 

407 

408class ProjectWeightings(BaseModel): 

409 weightset: WeightSet 

410 total: Weightings 

411 instance: Weightings 

412 

413 

414class ParentedWeighting(Weightings): 

415 parent_absolute_weight: float 

416 

417 

418class Score(BaseModel): 

419 issue_id: CONSTRAINED_ID 

420 score_value: Optional[Decimal] = None 

421 scoreset_id: str = "" 

422 comment: Optional[str] = None 

423 

424 

425class SectionScore(BaseModel): 

426 issue_id: CONSTRAINED_ID 

427 score_value: Decimal 

428 

429 

430class SectionScoreDoc(BaseModel): 

431 question_id: CONSTRAINED_ID 

432 scores: List[SectionScore] 

433 

434 

435class SectionScoreDocs(RootModel): 

436 root: Annotated[List[SectionScoreDoc], Field(min_length=1, max_length=100)] 

437 

438 

439class ScoreSet(BaseModel): 

440 model_config = ConfigDict(from_attributes=True) 

441 scoreset_id: str 

442 fullname: str 

443 

444 

445class ScoringData(BaseModel): 

446 scoreset_id: str 

447 scores: List[dict] 

448 

449 

450class ProjectPermission(BaseModel): 

451 user: str 

452 permissions: List[int] 

453 

454 

455class TargetUser(BaseModel): 

456 targetUser: Optional[str] = Field(None, min_length=3) 

457 

458 

459class TargetUserList(RootModel): 

460 root: List[TargetUser] 

461 

462 

463class TreeNode(Id): 

464 parent_id: Optional[int] 

465 number: str 

466 title: str 

467 

468 

469class SummaryEvent(Id): 

470 timestamp: datetime 

471 user_id: str 

472 event_type: str 

473 

474 

475class FullEvent(SummaryEvent): 

476 model_config = ConfigDict(from_attributes=True) 

477 

478 id: int 

479 event_class: str 

480 question_id: Optional[int] 

481 project_id: Optional[int] 

482 issue_id: Optional[int] 

483 org_id: str 

484 object_id: Any 

485 changes: List[dict] 

486 

487 

488class EvIssue(BaseModel): 

489 respondent_id: str 

490 label: Optional[str] 

491 

492 

493class AuditEvent(FullEvent): 

494 issue: Optional[EvIssue] = None 

495 user: Optional[User] 

496 question_number: Optional[str] = None 

497 section_id: Optional[int] = None 

498 project_title: Optional[str] = None 

499 

500 

501class AnsweredQElement(QElement): 

502 answer: Annotated[str, StringConstraints(max_length=65536)] 

503 

504 

505class ElementAnswer(BaseModel): 

506 element_id: int = Field( 

507 ..., description="ID of the question Element for this answer" 

508 ) 

509 answer: Annotated[str, StringConstraints(max_length=65536)] 

510 

511 

512class ElementAnswerList(RootModel): 

513 root: Annotated[List[ElementAnswer], Field(min_length=0, max_length=100)] 

514 

515 

516class Answer(ElementAnswer): 

517 issue_id: int = Field(..., description="ID of the associated Issue for this answer") 

518 question_id: int 

519 

520 

521ldoc = "Mapping of Issue ID to Question Element ID to answer" 

522 

523 

524class AnswerLookup(RootModel): 

525 model_config = ConfigDict( 

526 json_schema_extra={ 

527 "example": { 

528 "523": { 

529 "234872": "Answer for Issue 523, Element 234872", 

530 "234875": "Answer for Issue 523, Element 234875", 

531 }, 

532 "529": { 

533 "234872": "Answer for Issue 529, Element 234872", 

534 "234875": "Answer for Issue 529, Element 234875", 

535 }, 

536 } 

537 } 

538 ) 

539 root: dict[str, dict[str, str]] = Field(..., description=ldoc) 

540 

541 

542class AnswerResponseState(BaseModel): 

543 status: str 

544 allocated_by: Optional[str] 

545 allocated_to: Optional[str] 

546 approved_by: Optional[str] 

547 updated_by: Optional[str] 

548 date_updated: Optional[datetime] 

549 question_instance_id: int 

550 

551 

552class AllocatedTo(BaseModel): 

553 allocated_to: str 

554 question_instance_id: int 

555 

556 

557class AllocatedToList(RootModel): 

558 root: List[AllocatedTo] 

559 

560 def __iter__(self): 

561 return iter(self.root) 

562 

563 

564class AnswerStats(BaseModel): 

565 model_config = ConfigDict(from_attributes=True) 

566 allocated_to: Optional[str] = None 

567 status: str 

568 question_count: int 

569 

570 

571class ImportableAnswers(BaseModel): 

572 model_config = ConfigDict(from_attributes=True) 

573 title: str 

574 issue_date: Optional[datetime] 

575 submitted_date: Optional[datetime] 

576 question_count: int 

577 issue_id: int 

578 

579 

580class ImportableAnswersList(RootModel): 

581 root: List[ImportableAnswers] 

582 

583 

584class AnsweredQuestion(Question): 

585 response_state: List[AnswerResponseState] 

586 elements: ElGrid 

587 

588 

589class SingleRespondentQuestion(Question): 

590 model_config = ConfigDict(extra="ignore") 

591 elements: ElGrid 

592 respondent: Organisation 

593 

594 

595class Node(Id): 

596 model_config = ConfigDict(from_attributes=True) 

597 number: str 

598 title: str 

599 

600 

601class NodeTypeEnum(str, Enum): 

602 section = "section" 

603 question = "question" 

604 

605 

606class ProjectNode(Node): 

607 type: NodeTypeEnum 

608 parent_id: Optional[int] = Field( 

609 ..., description="ID of this node's parent section" 

610 ) 

611 position: int = Field(..., description="Position of the node within the parent") 

612 depth: int = Field( 

613 ..., description="The nested depth of this node within the questionnaire" 

614 ) 

615 description: Optional[str] = None 

616 

617 

618class QI(BaseModel): 

619 model_config = ConfigDict(from_attributes=True) 

620 id: int 

621 project_id: int 

622 number: str 

623 

624 

625class QuestionInstance(QI): 

626 section_id: int 

627 project_title: Optional[str] = None 

628 

629 

630class ScoreGaps(BaseModel): 

631 question_id: int 

632 number: str 

633 title: str 

634 node_type: str 

635 score_gap: float 

636 weight: float 

637 

638 

639class EditableSection(BaseModel): 

640 title: Annotated[str, StringConstraints(min_length=1, max_length=255)] 

641 description: Optional[str] = Field(None, max_length=1024) 

642 

643 

644class Section(EditableSection): 

645 model_config = ConfigDict(from_attributes=True) 

646 

647 id: Optional[PositiveInt] = None 

648 parent_id: Optional[PositiveInt] = Field(...) 

649 number: Optional[str] = None 

650 

651 

652class FullSection(Section): 

653 id: int 

654 subsections: List[TreeNode] 

655 questions: List[Question] 

656 

657 

658class SummarySection(Section): 

659 model_config = ConfigDict(from_attributes=True) 

660 questions: List[Node] = Field(default_factory=list) 

661 subsections: List[Node] = Field(default_factory=list) 

662 

663 

664class ParentId(BaseModel): 

665 new_parent_id: CONSTRAINED_ID 

666 

667 

668class MoveSection(ParentId): 

669 section_id: CONSTRAINED_ID 

670 

671 

672class SectionChildNodes(BaseModel): 

673 question_ids: Annotated[List[CONSTRAINED_ID], Field(max_length=100)] = Field( 

674 default_factory=list 

675 ) 

676 section_ids: Annotated[List[CONSTRAINED_ID], Field(max_length=100)] = Field( 

677 default_factory=list 

678 ) 

679 

680 delete_orphans: bool = False 

681 

682 @model_validator(mode="before") 

683 def either_or(cls, values): 

684 sec_ids, qids = ( 

685 values.get("section_ids", None), 

686 values.get("question_ids", None), 

687 ) 

688 if sec_ids is None and qids is None: 

689 raise ValueError("Either section_ids or question_ids must be set") 

690 if ( 

691 sec_ids is not None 

692 and qids is not None 

693 and len(sec_ids) > 0 

694 and len(qids) > 0 

695 ): 

696 raise ValueError( 

697 "Cannot assign values to both question_ids and section_ids" 

698 ) 

699 return values 

700 

701 

702class WorkflowSection(BaseModel): 

703 section: Section 

704 questions: List[AnswerResponseState] 

705 count: int 

706 

707 

708class Nodes(Node): 

709 type: str 

710 subsections: Optional[list["Nodes"]] = None 

711 questions: Optional[list["Nodes"]] = None 

712 

713 

714class NodesList(RootModel): 

715 root: List[Nodes] 

716 

717 

718class QElementStats(BaseModel): 

719 TX: int = Field(..., description="Count of Text Input elements") 

720 CR: int = Field(..., description="Count of Radio Choices elements") 

721 CC: int = Field(..., description="Count of Combon/Select elements") 

722 CB: int = Field(..., description="Count of Checkbox elements") 

723 LB: int = Field(..., description="Count of Label elements") 

724 QA: int = Field(..., description="Count of Question Attachment elements") 

725 AT: int = Field(..., description="Count of Upload Field Attachment elements") 

726 MD: int = Field(..., description="Count of Media elements") 

727 answerable_elements: int = Field( 

728 ..., description="Total number of answerable elements" 

729 ) 

730 

731 

732class QuestionnaireStats(BaseModel): 

733 questions: int = Field(..., description="Count of Questions in the Project") 

734 sections: int = Field(..., description="Count of Sections in the Project") 

735 elements: QElementStats = Field(..., description="Statistics for Question Elements") 

736 

737 

738class ProjectField(BaseModel): 

739 """ 

740 ProjectFields allow the user to associate custom data fields with their project. 

741 

742 By default such fields are invisible to respondent users - behaviour dictated by the 'private' 

743 property. ProjectFields are ordered when displayed according to the 'position' property. 

744 """ 

745 

746 model_config = ConfigDict(from_attributes=True) 

747 

748 key: Annotated[str, StringConstraints(min_length=1, max_length=64)] 

749 value: Annotated[str, StringConstraints(max_length=8192)] 

750 private: bool = Field( 

751 True, description="Indicates whether this field is visible to Respondents" 

752 ) 

753 

754 

755class NewCategory(BaseModel): 

756 """Represents a category which can be assigned to a Project for classification and filtering""" 

757 

758 name: Annotated[str, StringConstraints(max_length=50, min_length=3)] 

759 description: Annotated[str, StringConstraints(max_length=250)] 

760 

761 

762class Category(NewCategory): 

763 model_config = ConfigDict(from_attributes=True) 

764 

765 id: Optional[int] = None 

766 

767 

768class UpdateableProject(BaseModel): 

769 model_config = ConfigDict(from_attributes=True, populate_by_name=True) 

770 

771 title: str = Field("Untitled Project", min_length=5, max_length=256) 

772 # Default workflow_id is 1 - the standard project workflow 

773 workflow_id: int = 1 

774 description: Optional[str] = Field(None, max_length=20000) 

775 deadline: Optional[datetime] = None 

776 multiscored: bool = Field( 

777 False, 

778 description="Indicates whether Multiple Score Sets are enabled for this project", 

779 ) 

780 

781 maximum_score: Annotated[int, Field(gt=0, lt=2147483648)] = 10 

782 

783 email: Optional[EmailStr] = Field( 

784 None, 

785 alias="from_email_address", 

786 description=( 

787 "Send notification emails pertaining to the current project from this email " 

788 "address" 

789 ), 

790 ) 

791 project_fields: List[ProjectField] = [] 

792 

793 

794class NewProject(UpdateableProject): 

795 # title duplicates that of UpdateableProject but here it is a required field 

796 title: Annotated[ 

797 str, StringConstraints(strip_whitespace=True, min_length=5, max_length=256) 

798 ] 

799 questionnaire_title: Annotated[ 

800 str, StringConstraints(strip_whitespace=True, min_length=5, max_length=64) 

801 ] = "Questionnaire" 

802 category_ids: List[int] = [] 

803 

804 

805class FullProject(UpdateableProject): 

806 model_config = ConfigDict(from_attributes=True) 

807 

808 id: int 

809 status_name: str = Field( 

810 ..., serialization_alias="status", validation_alias="status" 

811 ) 

812 author_id: str 

813 owner_org: Optional[Organisation] = Field(None, alias="owner_organisation") 

814 date_published: Optional[datetime] = None 

815 date_created: datetime 

816 permissions: List[str] = [] 

817 categories: List[Category] 

818 section_id: Optional[int] = None 

819 

820 def set_permissions(self, user): 

821 self.permissions = user.sorted_permissions 

822 

823 @field_validator("permissions", mode="before") 

824 def perms(cls, v): 

825 return [] 

826 

827 

828class ListProject(BaseModel): 

829 model_config = ConfigDict(from_attributes=True) 

830 id: int 

831 workflow_id: int 

832 title: str 

833 owner_org_name: str 

834 owner_org_id: str 

835 status: str 

836 deadline: Optional[datetime] = None 

837 date_created: datetime 

838 watching_since: Optional[datetime] = None 

839 is_watched: bool = False 

840 

841 

842class ProjectList(BaseModel): 

843 data: List[ListProject] 

844 pagination: Pagination 

845 

846 

847class ProjectApproval(BaseModel): 

848 model_config = ConfigDict(from_attributes=True) 

849 id: Optional[int] = None 

850 project_id: int 

851 user_id: str 

852 project_status: str 

853 date_approved: datetime 

854 

855 

856class Supplier(BaseModel): 

857 issues: List[Issue] 

858 organisation: Organisation 

859 users: List[User] 

860 

861 

862class AnswerAttachment(Id): 

863 model_config = ConfigDict(from_attributes=True) 

864 filename: str 

865 mimetype: str 

866 size: str 

867 size_bytes: int 

868 

869 

870class Attachment(AnswerAttachment): 

871 model_config = ConfigDict(from_attributes=True) 

872 private: bool 

873 description: Annotated[str, StringConstraints(min_length=1, max_length=255)] 

874 date_uploaded: datetime 

875 author_id: str 

876 org_id: str 

877 

878 

879class IssueAttachment(Attachment): 

880 model_config = ConfigDict(from_attributes=True) 

881 issue_id: int 

882 description: Annotated[str, StringConstraints(min_length=1, max_length=1024)] 

883 

884 

885class Watcher(BaseModel): 

886 user_id: str 

887 fullname: str 

888 email: Optional[EmailStr] = None 

889 watching_since: Optional[datetime] = None 

890 is_watching: bool = False 

891 

892 @model_validator(mode="after") 

893 def set_is_watching(self, values) -> Self: 

894 self.is_watching = self.watching_since is not None 

895 return self 

896 

897 @field_validator("email", mode="before") 

898 @classmethod 

899 def none_if_zerolength(cls, email): 

900 if type(email) is str and len(email.strip()) == 0: 

901 return None 

902 return email 

903 

904 model_config = ConfigDict(from_attributes=True) 

905 

906 

907class IssueWatchList(BaseModel): 

908 issue_id: int 

909 watchlist: List[Watcher] 

910 

911 

912class AnswerImportResult(BaseModel): 

913 imported: List[str] 

914 errors: List[str] 

915 unchanged: List[str] 

916 

917 

918class ImportAnswers(BaseModel): 

919 source_issue_id: int 

920 section_number: str 

921 

922 

923class SectionImportDoc(BaseModel): 

924 project_id: int 

925 section_ids: List[int] = [] 

926 question_ids: List[int] = [] 

927 clone: bool = True 

928 

929 

930class SectionImportResult(BaseModel): 

931 section_count: int = 0 

932 question_count: int = 0 

933 

934 

935class TextReplace(BaseModel): 

936 search_term: str 

937 replace_term: str 

938 dry_run: bool = True 

939 

940 

941class HitTypes(str, Enum): 

942 questions = "questions" 

943 choices = "choices" 

944 answers = "answers" 

945 notes = "notes" 

946 scoreComments = "scoreComments" 

947 

948 

949class SearchResult(BaseModel): 

950 klass: HitTypes 

951 project_title: str 

952 project_id: int 

953 object_id: Union[str, int] 

954 object_ref: Union[str, None] = None 

955 snippet: str 

956 

957 

958class RelationshipType(BaseModel): 

959 model_config = ConfigDict(from_attributes=True) 

960 

961 id: Optional[int] = Field(None, gt=0, lt=2147483648) 

962 name: Annotated[str, StringConstraints(min_length=2, max_length=128)] 

963 description: Optional[str] = Field(None, max_length=256) 

964 

965 

966class Relationship(BaseModel): 

967 reltype_id: Annotated[int, Field(gt=0, lt=2147483648)] 

968 from_org_id: Annotated[str, StringConstraints(min_length=2, max_length=50)] 

969 to_org_id: Annotated[str, StringConstraints(min_length=2, max_length=50)] 

970 

971 

972class NetworkRelationship(BaseModel): 

973 model_config = ConfigDict(from_attributes=True, populate_by_name=True) 

974 

975 # relationship: str 

976 relationship: RelationshipType = Field(..., alias="relationship_type") 

977 from_org: BaseOrganisation 

978 to_org: BaseOrganisation 

979 

980 

981class NewTag(BaseModel): 

982 """ 

983 A Tag is a keyword used to categorize questions 

984 """ 

985 

986 name: Annotated[str, StringConstraints(min_length=2, max_length=128)] 

987 description: Optional[Annotated[str, StringConstraints(max_length=256)]] = None 

988 colour: Optional[ 

989 Annotated[str, StringConstraints(max_length=7, pattern=r"^#[0-9A-Fa-f]{6}$")] 

990 ] = Field(None, description="Hex color code (#RRGGBB)", examples=["#1E90FF"]) 

991 

992 @field_validator("colour", mode="before") 

993 @classmethod 

994 def normalise_colour(cls, v): 

995 if v is None: 

996 return v 

997 return v.upper() 

998 

999 

1000class Tag(NewTag): 

1001 model_config = ConfigDict(from_attributes=True) 

1002 

1003 id: Optional[Annotated[int, Field(gt=0, lt=2147483648)]] = None 

1004 

1005 

1006class TagAssigns(BaseModel): 

1007 question_instance_ids: CONSTRAINED_ID_LIST 

1008 section_ids: CONSTRAINED_ID_LIST 

1009 recursive: bool = False 

1010 

1011 

1012class TagGroup(TagAssigns): 

1013 tag_id: Annotated[int, Field(gt=0, lt=2147483648)] 

1014 

1015 

1016class MatchedElement(BaseModel): 

1017 title: str 

1018 number: str 

1019 el_type: str 

1020 label: str 

1021 choices: List[Choice] 

1022 

1023 

1024class QSearchResult(BaseModel): 

1025 distinct_question_count: int = Field( 

1026 ..., description="Number of questions that match the query" 

1027 ) 

1028 matched_elements: List[MatchedElement] 

1029 

1030 

1031class AnswerSearch(BaseModel): 

1032 number: str 

1033 question_id: int 

1034 

1035 

1036class AnswerSearchList(BaseModel): 

1037 matches: list[AnswerSearch] 

1038 

1039 

1040class ReplacedItem(BaseModel): 

1041 change_type: str 

1042 question_number: str 

1043 old: str 

1044 new: str 

1045 

1046 

1047class PublishProject(BaseModel): 

1048 release_issue_ids: Annotated[ 

1049 List[Annotated[int, Field(gt=0, lt=2147483648)]], 

1050 Field(min_length=0, max_length=50), 

1051 ] 

1052 

1053 

1054class PublishResult(BaseModel): 

1055 issue_id: int 

1056 respondent_id: str 

1057 

1058 

1059class HttpHeader(BaseModel): 

1060 header: Annotated[ 

1061 str, StringConstraints(max_length=255, pattern=r"^[a-zA-Z0-9-]+$") 

1062 ] 

1063 value: Annotated[str, StringConstraints(max_length=2048)] 

1064 

1065 

1066class NewWebhook(BaseModel): 

1067 org_id: Annotated[str, StringConstraints(max_length=50)] 

1068 event_type: Annotated[str, StringConstraints(max_length=50)] 

1069 remote_url: AnyHttpUrl = Field(..., alias="url") 

1070 guard_policy: ( 

1071 Annotated[str, StringConstraints(max_length=CEL_EXPRESSION_MAX_LENGTH)] | None 

1072 ) = None 

1073 transform_expression: ( 

1074 Annotated[str, StringConstraints(max_length=CEL_EXPRESSION_MAX_LENGTH)] | None 

1075 ) = None 

1076 http_headers: Optional[List[HttpHeader]] = Field( 

1077 default_factory=list, 

1078 description=( 

1079 "Optional list of HTTP headers to include in the webhook POST request" 

1080 ), 

1081 max_length=5, 

1082 ) 

1083 

1084 @field_validator("event_type") 

1085 @classmethod 

1086 def validate_event_type(cls, event_type): 

1087 from postrfp.model.audit import evt_types 

1088 

1089 if not hasattr(evt_types, event_type): 

1090 raise ValueError(f"{event_type} is not a valid Event Type") 

1091 return event_type 

1092 

1093 

1094class Webhook(NewWebhook): 

1095 model_config = ConfigDict( 

1096 from_attributes=True, populate_by_name=True, use_enum_values=True 

1097 ) 

1098 

1099 delivery_status: Optional[str] = None 

1100 last_delivery: Optional[datetime] = None 

1101 error_message: Optional[str] = None 

1102 retries: int = 0