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
« 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
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)
20from postrfp.shared.serial.common import Id, Pagination
22from .qmodels import Question, QElement, Choice, ElGrid
24from postrfp.model import notes
25from postrfp.shared.constants import CEL_EXPRESSION_MAX_LENGTH
28CONSTRAINED_ID = Annotated[int, Field(lt=4294967295, gt=0)]
30CONSTRAINED_ID_LIST = Annotated[List[CONSTRAINED_ID], Field(max_length=1000)]
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)
38class OrgType(str, Enum):
39 RESPONDENT = "RESPONDENT"
40 BUYER = "BUYER"
41 CONSULTANT = "CONSULTANT"
44class Error(BaseModel):
45 error: str
46 description: str
49class ErrorList(BaseModel):
50 description: str
51 errors: List[Error]
54class IdList(BaseModel):
55 ids: List[int]
58class Count(BaseModel):
59 """
60 Generic count model
61 """
63 description: str
64 count: int
67class ShortName(BaseModel):
68 name: Annotated[str, StringConstraints(min_length=1, max_length=255)]
71class AnswerAttachmentIds(BaseModel):
72 attachment_id: CONSTRAINED_ID
73 answer_id: CONSTRAINED_ID
76class NewProjectIds(Id):
77 section_id: int = Field(
78 ..., description="The ID of the Root section for the newly created project"
79 )
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)]
89class BaseOrganisation(BaseModel):
90 model_config = ConfigDict(from_attributes=True)
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
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)
106class Organisation(BaseOrganisation):
107 password_expiry: int = 0
110class Participant(BaseModel):
111 model_config = ConfigDict(from_attributes=True)
112 organisation: Organisation
113 role: Annotated[str, StringConstraints(max_length=255)]
116class ParticipantList(RootModel):
117 root: list[Participant]
120class UpdateParticipant(BaseModel):
121 org_id: Annotated[str, StringConstraints(max_length=50)]
122 role: Annotated[str, StringConstraints(max_length=255)]
125class UpdateParticipantList(RootModel):
126 model_config = ConfigDict(json_schema_extra={"maxItems": 20})
127 root: list[UpdateParticipant]
130class UserType(str, Enum):
131 standard = "standard"
132 restricted = "restricted"
135class UserId(BaseModel):
136 id: Annotated[str, StringConstraints(max_length=50)]
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 ]
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
162class UserList(RootModel):
163 root: list[BaseUser]
166class User(BaseUser):
167 organisation: Organisation
168 type: UserType = UserType.standard
169 roles: List[str]
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]
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 """
185 permissions: set[str] = Field(
186 validation_alias="sorted_permissions",
187 description="List of this user's permissions, based on their roles",
188 )
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 )
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
205class OrgWithUsers(BaseOrganisation):
206 model_config = ConfigDict(from_attributes=True)
207 users: List[BaseUser]
210class BaseIssue(BaseModel):
211 label: Annotated[str, StringConstraints(max_length=250)] | None = None
212 deadline: datetime | None = None
215class NewIssue(BaseIssue):
216 respondent_id: Optional[Annotated[str, StringConstraints(max_length=50)]] = None
217 respondent_email: Optional[EmailStr] = None
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
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
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
246class IssuesList(BaseModel):
247 data: List[ListIssue]
248 pagination: Pagination
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
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",)
269class Issue(UpdateableIssue):
270 """
271 Represents the directly editable fields of an Issue
273 Either respondent or respondent_email must be set, but not both
274 """
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 )
289class Issues(RootModel):
290 root: list[Issue]
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 )
303class IssueStatus(BaseModel):
304 new_status: IssueStatuses
307class IssueUseWorkflow(BaseModel):
308 use_workflow: bool
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 )
320target_docs = "ID of the Organisation to who this Note is addressed"
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 )
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)
343class ReadNotes(RootModel):
344 root: list[ReadNote]
347copy_ws_docs = "Copy weightings from an existing Weighting Set with this ID"
348initial_weight_docs = "Initial weighting value for each question and section"
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.
356 Only one of initial_value and source_weightset_id can be set
357 """
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 )
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
384class WeightSet(ShortName):
385 id: Optional[int] = None # default weightset
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)]
394class SecWeight(BaseModel):
395 section_id: CONSTRAINED_ID
396 weight: Annotated[float, Field(ge=0.0)]
399class Weightings(BaseModel):
400 questions: List[QWeight]
401 sections: List[SecWeight]
404class WeightingsDoc(Weightings):
405 weightset_id: Optional[CONSTRAINED_ID] = None
408class ProjectWeightings(BaseModel):
409 weightset: WeightSet
410 total: Weightings
411 instance: Weightings
414class ParentedWeighting(Weightings):
415 parent_absolute_weight: float
418class Score(BaseModel):
419 issue_id: CONSTRAINED_ID
420 score_value: Optional[Decimal] = None
421 scoreset_id: str = ""
422 comment: Optional[str] = None
425class SectionScore(BaseModel):
426 issue_id: CONSTRAINED_ID
427 score_value: Decimal
430class SectionScoreDoc(BaseModel):
431 question_id: CONSTRAINED_ID
432 scores: List[SectionScore]
435class SectionScoreDocs(RootModel):
436 root: Annotated[List[SectionScoreDoc], Field(min_length=1, max_length=100)]
439class ScoreSet(BaseModel):
440 model_config = ConfigDict(from_attributes=True)
441 scoreset_id: str
442 fullname: str
445class ScoringData(BaseModel):
446 scoreset_id: str
447 scores: List[dict]
450class ProjectPermission(BaseModel):
451 user: str
452 permissions: List[int]
455class TargetUser(BaseModel):
456 targetUser: Optional[str] = Field(None, min_length=3)
459class TargetUserList(RootModel):
460 root: List[TargetUser]
463class TreeNode(Id):
464 parent_id: Optional[int]
465 number: str
466 title: str
469class SummaryEvent(Id):
470 timestamp: datetime
471 user_id: str
472 event_type: str
475class FullEvent(SummaryEvent):
476 model_config = ConfigDict(from_attributes=True)
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]
488class EvIssue(BaseModel):
489 respondent_id: str
490 label: Optional[str]
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
501class AnsweredQElement(QElement):
502 answer: Annotated[str, StringConstraints(max_length=65536)]
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)]
512class ElementAnswerList(RootModel):
513 root: Annotated[List[ElementAnswer], Field(min_length=0, max_length=100)]
516class Answer(ElementAnswer):
517 issue_id: int = Field(..., description="ID of the associated Issue for this answer")
518 question_id: int
521ldoc = "Mapping of Issue ID to Question Element ID to answer"
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)
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
552class AllocatedTo(BaseModel):
553 allocated_to: str
554 question_instance_id: int
557class AllocatedToList(RootModel):
558 root: List[AllocatedTo]
560 def __iter__(self):
561 return iter(self.root)
564class AnswerStats(BaseModel):
565 model_config = ConfigDict(from_attributes=True)
566 allocated_to: Optional[str] = None
567 status: str
568 question_count: int
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
580class ImportableAnswersList(RootModel):
581 root: List[ImportableAnswers]
584class AnsweredQuestion(Question):
585 response_state: List[AnswerResponseState]
586 elements: ElGrid
589class SingleRespondentQuestion(Question):
590 model_config = ConfigDict(extra="ignore")
591 elements: ElGrid
592 respondent: Organisation
595class Node(Id):
596 model_config = ConfigDict(from_attributes=True)
597 number: str
598 title: str
601class NodeTypeEnum(str, Enum):
602 section = "section"
603 question = "question"
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
618class QI(BaseModel):
619 model_config = ConfigDict(from_attributes=True)
620 id: int
621 project_id: int
622 number: str
625class QuestionInstance(QI):
626 section_id: int
627 project_title: Optional[str] = None
630class ScoreGaps(BaseModel):
631 question_id: int
632 number: str
633 title: str
634 node_type: str
635 score_gap: float
636 weight: float
639class EditableSection(BaseModel):
640 title: Annotated[str, StringConstraints(min_length=1, max_length=255)]
641 description: Optional[str] = Field(None, max_length=1024)
644class Section(EditableSection):
645 model_config = ConfigDict(from_attributes=True)
647 id: Optional[PositiveInt] = None
648 parent_id: Optional[PositiveInt] = Field(...)
649 number: Optional[str] = None
652class FullSection(Section):
653 id: int
654 subsections: List[TreeNode]
655 questions: List[Question]
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)
664class ParentId(BaseModel):
665 new_parent_id: CONSTRAINED_ID
668class MoveSection(ParentId):
669 section_id: CONSTRAINED_ID
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 )
680 delete_orphans: bool = False
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
702class WorkflowSection(BaseModel):
703 section: Section
704 questions: List[AnswerResponseState]
705 count: int
708class Nodes(Node):
709 type: str
710 subsections: Optional[list["Nodes"]] = None
711 questions: Optional[list["Nodes"]] = None
714class NodesList(RootModel):
715 root: List[Nodes]
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 )
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")
738class ProjectField(BaseModel):
739 """
740 ProjectFields allow the user to associate custom data fields with their project.
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 """
746 model_config = ConfigDict(from_attributes=True)
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 )
755class NewCategory(BaseModel):
756 """Represents a category which can be assigned to a Project for classification and filtering"""
758 name: Annotated[str, StringConstraints(max_length=50, min_length=3)]
759 description: Annotated[str, StringConstraints(max_length=250)]
762class Category(NewCategory):
763 model_config = ConfigDict(from_attributes=True)
765 id: Optional[int] = None
768class UpdateableProject(BaseModel):
769 model_config = ConfigDict(from_attributes=True, populate_by_name=True)
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 )
781 maximum_score: Annotated[int, Field(gt=0, lt=2147483648)] = 10
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] = []
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] = []
805class FullProject(UpdateableProject):
806 model_config = ConfigDict(from_attributes=True)
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
820 def set_permissions(self, user):
821 self.permissions = user.sorted_permissions
823 @field_validator("permissions", mode="before")
824 def perms(cls, v):
825 return []
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
842class ProjectList(BaseModel):
843 data: List[ListProject]
844 pagination: Pagination
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
856class Supplier(BaseModel):
857 issues: List[Issue]
858 organisation: Organisation
859 users: List[User]
862class AnswerAttachment(Id):
863 model_config = ConfigDict(from_attributes=True)
864 filename: str
865 mimetype: str
866 size: str
867 size_bytes: int
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
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)]
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
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
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
904 model_config = ConfigDict(from_attributes=True)
907class IssueWatchList(BaseModel):
908 issue_id: int
909 watchlist: List[Watcher]
912class AnswerImportResult(BaseModel):
913 imported: List[str]
914 errors: List[str]
915 unchanged: List[str]
918class ImportAnswers(BaseModel):
919 source_issue_id: int
920 section_number: str
923class SectionImportDoc(BaseModel):
924 project_id: int
925 section_ids: List[int] = []
926 question_ids: List[int] = []
927 clone: bool = True
930class SectionImportResult(BaseModel):
931 section_count: int = 0
932 question_count: int = 0
935class TextReplace(BaseModel):
936 search_term: str
937 replace_term: str
938 dry_run: bool = True
941class HitTypes(str, Enum):
942 questions = "questions"
943 choices = "choices"
944 answers = "answers"
945 notes = "notes"
946 scoreComments = "scoreComments"
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
958class RelationshipType(BaseModel):
959 model_config = ConfigDict(from_attributes=True)
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)
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)]
972class NetworkRelationship(BaseModel):
973 model_config = ConfigDict(from_attributes=True, populate_by_name=True)
975 # relationship: str
976 relationship: RelationshipType = Field(..., alias="relationship_type")
977 from_org: BaseOrganisation
978 to_org: BaseOrganisation
981class NewTag(BaseModel):
982 """
983 A Tag is a keyword used to categorize questions
984 """
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"])
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()
1000class Tag(NewTag):
1001 model_config = ConfigDict(from_attributes=True)
1003 id: Optional[Annotated[int, Field(gt=0, lt=2147483648)]] = None
1006class TagAssigns(BaseModel):
1007 question_instance_ids: CONSTRAINED_ID_LIST
1008 section_ids: CONSTRAINED_ID_LIST
1009 recursive: bool = False
1012class TagGroup(TagAssigns):
1013 tag_id: Annotated[int, Field(gt=0, lt=2147483648)]
1016class MatchedElement(BaseModel):
1017 title: str
1018 number: str
1019 el_type: str
1020 label: str
1021 choices: List[Choice]
1024class QSearchResult(BaseModel):
1025 distinct_question_count: int = Field(
1026 ..., description="Number of questions that match the query"
1027 )
1028 matched_elements: List[MatchedElement]
1031class AnswerSearch(BaseModel):
1032 number: str
1033 question_id: int
1036class AnswerSearchList(BaseModel):
1037 matches: list[AnswerSearch]
1040class ReplacedItem(BaseModel):
1041 change_type: str
1042 question_number: str
1043 old: str
1044 new: str
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 ]
1054class PublishResult(BaseModel):
1055 issue_id: int
1056 respondent_id: str
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)]
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 )
1084 @field_validator("event_type")
1085 @classmethod
1086 def validate_event_type(cls, event_type):
1087 from postrfp.model.audit import evt_types
1089 if not hasattr(evt_types, event_type):
1090 raise ValueError(f"{event_type} is not a valid Event Type")
1091 return event_type
1094class Webhook(NewWebhook):
1095 model_config = ConfigDict(
1096 from_attributes=True, populate_by_name=True, use_enum_values=True
1097 )
1099 delivery_status: Optional[str] = None
1100 last_delivery: Optional[datetime] = None
1101 error_message: Optional[str] = None
1102 retries: int = 0