aboutsummaryrefslogtreecommitdiff
path: root/generalresearch/models/morning/question.py
diff options
context:
space:
mode:
Diffstat (limited to 'generalresearch/models/morning/question.py')
-rw-r--r--generalresearch/models/morning/question.py207
1 files changed, 207 insertions, 0 deletions
diff --git a/generalresearch/models/morning/question.py b/generalresearch/models/morning/question.py
new file mode 100644
index 0000000..77ea209
--- /dev/null
+++ b/generalresearch/models/morning/question.py
@@ -0,0 +1,207 @@
+import json
+from enum import Enum
+from typing import List, Optional, Dict, Literal, Any
+from uuid import UUID
+
+from pydantic import BaseModel, Field, model_validator, field_validator
+from typing_extensions import Self
+
+from generalresearch.locales import Localelator
+from generalresearch.models import Source
+from generalresearch.models.morning import MorningQuestionID
+from generalresearch.models.thl.profiling.marketplace import (
+ MarketplaceUserQuestionAnswer,
+ MarketplaceQuestion,
+)
+
+# todo: we could validate that the country_iso / language_iso exists ...
+locale_helper = Localelator()
+
+
+class MorningQuestionOption(BaseModel, frozen=True):
+ # API limit is 50, db limit is 32
+ id: str = Field(
+ min_length=1,
+ max_length=32,
+ pattern=r"^[\w\s\.\-]+$",
+ description="The unique identifier for a response to a qualification",
+ serialization_alias="option_id",
+ )
+ text: str = Field(
+ min_length=1,
+ description="The response text shown to respondents",
+ serialization_alias="option_text",
+ )
+ # Order does not come back explicitly in the API, instead they are already ordered. We're
+ # adding this for db sort purposes to explicitly order them. We use the API's order.
+ order: int = Field()
+
+
+class MorningQuestionType(str, Enum):
+ # The db stores these as a single letter
+
+ # Geographic questions represent geographic areas within a country.
+ # These behave like multiple_choice questions
+ geographic = "g"
+ # The 's' is for "single-select". Morning does not support "multi-select"
+ # multiple choice, but if they did, we would use 'm' for "multi-select".
+ multiple_choice = "s"
+ # Questions whose answers are submitted by respondents. The ID and
+ # response text are both defined as the exact text that was typed by
+ # the respondent. Text entry responses are not case-sensitive
+ text_entry = "t"
+
+
+class MorningUserQuestionAnswer(MarketplaceUserQuestionAnswer):
+ question_id: MorningQuestionID = Field()
+ question_type: Optional[MorningQuestionType] = Field(default=None)
+ # Did this answer come from us asking, or was it passed back from the
+ # marketplace? Note, morning doesn't "pass back" answers, but we can
+ # retrieve a user's profile through API, so it is possible to populate
+ # this from_thl False
+ from_thl: bool = Field(default=True)
+
+
+class MorningQuestion(MarketplaceQuestion):
+ # API limit is 100, db limit is 64
+ id: str = Field(
+ min_length=1,
+ max_length=64,
+ pattern=r"^[a-z0-9_\s\.]+$",
+ description="The unique identifier for the qualification",
+ serialization_alias="question_id",
+ frozen=True,
+ )
+ # API has no limit, db limit is 64
+ name: str = Field(
+ max_length=64,
+ min_length=1,
+ serialization_alias="question_name",
+ description="The human-readable short label for the qualification",
+ frozen=True,
+ )
+ text: str = Field(
+ min_length=1,
+ description="The text shown to respondents",
+ serialization_alias="question_text",
+ frozen=True,
+ )
+ type: MorningQuestionType = Field(
+ description="The type of question asked",
+ serialization_alias="question_type",
+ frozen=True,
+ )
+ # API calls this "responses", but I think that is a confusing name
+ options: Optional[List[MorningQuestionOption]] = Field(
+ default=None, min_length=1, frozen=True
+ )
+
+ source: Literal[Source.MORNING_CONSULT] = Source.MORNING_CONSULT
+
+ @property
+ def internal_id(self) -> str:
+ return self.id
+
+ @model_validator(mode="after")
+ def check_type_options_agreement(self) -> Self:
+ # If type == "text_entry", options is None. Otherwise, must be set.
+ if self.type == MorningQuestionType.text_entry:
+ assert self.options is None
+ else:
+ assert self.options is not None
+ return self
+
+ @field_validator("options")
+ @classmethod
+ def order_options(cls, options):
+ if options:
+ options.sort(key=lambda x: x.order)
+ return options
+
+ @classmethod
+ def from_api(cls, d: dict, country_iso: str, language_iso: str):
+ options = None
+ if d.get("responses"):
+ options = [
+ MorningQuestionOption(id=r["id"], text=r["text"], order=order)
+ for order, r in enumerate(d["responses"])
+ ]
+ return cls(
+ id=d["id"],
+ name=d["name"],
+ text=d["text"],
+ type=MorningQuestionType[d["type"]],
+ country_iso=country_iso,
+ language_iso=language_iso,
+ options=options,
+ )
+
+ @classmethod
+ def from_db(cls, d: dict):
+ options = None
+ if d["options"]:
+ options = [
+ MorningQuestionOption(
+ id=r["option_id"], text=r["option_text"], order=r["order"]
+ )
+ for r in d["options"]
+ ]
+ return cls(
+ id=d["question_id"],
+ name=d["question_name"],
+ text=d["question_text"],
+ type=d["question_type"],
+ country_iso=d["country_iso"],
+ language_iso=d["language_iso"],
+ options=options,
+ is_live=d["is_live"],
+ category_id=(
+ UUID(d.get("category_id")).hex if d.get("category_id") else None
+ ),
+ )
+
+ def to_mysql(self) -> Dict[str, Any]:
+ d = self.model_dump(mode="json", by_alias=True)
+ d["options"] = json.dumps(d["options"])
+ return d
+
+ def to_upk_question(self):
+ from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestionChoice,
+ UpkQuestionType,
+ UpkQuestionSelectorMC,
+ UpkQuestionSelectorTE,
+ UpkQuestionSelectorHIDDEN,
+ UpkQuestion,
+ )
+
+ upk_type_selector_map = {
+ # multiple select doesn't exist in morning, only single select
+ MorningQuestionType.multiple_choice: (
+ UpkQuestionType.MULTIPLE_CHOICE,
+ UpkQuestionSelectorMC.SINGLE_ANSWER,
+ ),
+ MorningQuestionType.text_entry: (
+ UpkQuestionType.TEXT_ENTRY,
+ UpkQuestionSelectorTE.SINGLE_LINE,
+ ),
+ MorningQuestionType.geographic: (
+ UpkQuestionType.HIDDEN,
+ UpkQuestionSelectorHIDDEN.HIDDEN,
+ ),
+ }
+ upk_type, upk_selector = upk_type_selector_map[self.type]
+ d = {
+ "ext_question_id": self.external_id,
+ "country_iso": self.country_iso,
+ "language_iso": self.language_iso,
+ "type": upk_type,
+ "selector": upk_selector,
+ "text": self.text,
+ }
+ if self.type == MorningQuestionType.multiple_choice:
+ d["choices"] = [
+ UpkQuestionChoice(id=c.id, text=c.text, order=c.order)
+ for c in self.options
+ ]
+ return UpkQuestion(**d)