aboutsummaryrefslogtreecommitdiff
path: root/generalresearch/models/morning/survey.py
diff options
context:
space:
mode:
Diffstat (limited to 'generalresearch/models/morning/survey.py')
-rw-r--r--generalresearch/models/morning/survey.py102
1 files changed, 47 insertions, 55 deletions
diff --git a/generalresearch/models/morning/survey.py b/generalresearch/models/morning/survey.py
index 255a63c..8738631 100644
--- a/generalresearch/models/morning/survey.py
+++ b/generalresearch/models/morning/survey.py
@@ -2,19 +2,14 @@ from __future__ import annotations
import json
import logging
-from datetime import timezone
+from datetime import UTC
from decimal import Decimal
from functools import cached_property
from typing import (
Annotated,
Any,
- Dict,
- List,
Literal,
- Optional,
- Set,
- Tuple,
- Type,
+ Self,
)
from pydantic import (
@@ -27,14 +22,13 @@ from pydantic import (
computed_field,
model_validator,
)
-from typing_extensions import Self
from generalresearch.locales import Localelator
-from generalresearch.models import Source
from generalresearch.models.custom_types import (
AwareDatetimeISO,
UUIDStrCoerce,
)
+from generalresearch.models.definitions import Source
from generalresearch.models.morning import MorningQuestionID, MorningStatus
from generalresearch.models.morning.question import MorningQuestion
from generalresearch.models.thl.demographics import Gender
@@ -79,14 +73,14 @@ class MorningStatistics(BaseModel):
# bid bid_loi: int = Field(validation_alias="estimated_length_of_interview",
# le=120 * 60)
# If num_completes == 0 , this gets returned as 0. it should be None
- obs_median_loi: Optional[NonNegativeInt] = Field(
+ obs_median_loi: NonNegativeInt | None = Field(
validation_alias="median_length_of_interview", default=None, le=120 * 60
)
# API returns 100 until 5 completes! Should be None.
# This is calculated as the total completes divided by the total number of
# finished sessions that passed the prescreener.
- qualified_conversion: Optional[float] = Field(
+ qualified_conversion: float | None = Field(
ge=0, le=1, description="conversion rate of qualified respondents"
)
@@ -142,7 +136,7 @@ class MorningTaskStatistics(MorningStatistics):
# relevant to quotas.
# API returns 100 until 5 completes! Should be None ...
- system_conversion: Optional[float] = Field(
+ system_conversion: float | None = Field(
description="conversion rate of the system. completes divided by total number of entrants to the system",
ge=0,
le=1,
@@ -166,8 +160,8 @@ class MorningTaskStatistics(MorningStatistics):
class MorningCondition(MarketplaceCondition):
model_config = ConfigDict(populate_by_name=True, frozen=False, extra="ignore")
- question_id: Optional[MorningQuestionID] = Field(validation_alias="id")
- values: List[Annotated[str, Field(max_length=128)]] = Field(
+ question_id: MorningQuestionID | None = Field(validation_alias="id")
+ values: list[Annotated[str, Field(max_length=128)]] = Field(
validation_alias="response_ids"
)
value_type: ConditionValueType = Field(default=ConditionValueType.LIST)
@@ -184,11 +178,11 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
max_digits=5,
validation_alias="cost_per_interview",
)
- condition_hashes: List[str] = Field(min_length=1, default_factory=list)
+ condition_hashes: list[str] = Field(min_length=1, default_factory=list)
# since the Quota is the MarketplaceTask, it needs these fields, copied from the Bid
source: Literal[Source.MORNING_CONSULT] = Field(default=Source.MORNING_CONSULT)
- used_question_ids: Set[MorningQuestionID] = Field(default_factory=set)
+ used_question_ids: set[MorningQuestionID] = Field(default_factory=set)
country_iso: CountryISO = Field(frozen=True)
country_isos: CountryISOs = Field()
language_isos: LanguageISOs = Field(frozen=True)
@@ -206,7 +200,7 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
data["country_isos"] = [data["country_iso"]]
if isinstance(data["language_isos"], str):
data["language_isos"] = set(data["language_isos"].split(","))
- data["language_iso"] = sorted(data["language_isos"])[0]
+ data["language_iso"] = min(data["language_isos"])
return data
@property
@@ -219,11 +213,11 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
@computed_field
@cached_property
- def all_hashes(self) -> Set[str]:
+ def all_hashes(self) -> set[str]:
return set(self.condition_hashes)
@property
- def condition_model(self) -> Type[MarketplaceCondition]:
+ def condition_model(self) -> type[MarketplaceCondition]:
return MorningCondition
@property
@@ -233,7 +227,7 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
@property
def marketplace_genders(
self,
- ) -> Dict[Gender, Optional[MarketplaceCondition]]:
+ ) -> dict[Gender, MarketplaceCondition | None]:
return {
Gender.MALE: MorningCondition(
question_id="gender",
@@ -253,14 +247,14 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
# num_available includes in-progress (they're already deducted)
return self.num_available >= self._min_open_spots
- def passes(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool:
+ def passes(self, criteria_evaluation: dict[str, bool | None]) -> bool:
# Passes means we 1) meet all conditions (aka "match") AND 2) the quota is open.
return self.is_open and self.matches(criteria_evaluation)
# TODO: I did some speed tests. This is faster than how this is implemented
# in sago/spectrum/dynata/etc. We should generalize this logic instead of
# copying/pasting it 7 times. (matches, matches_optional and _soft)
- def matches(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool:
+ def matches(self, criteria_evaluation: dict[str, bool | None]) -> bool:
# Matches means we meet all conditions.
# In Morning, all quotas are mutually exclusive. so if it doesn't
# matter if we match a closed quota, b/c that means that we won't
@@ -268,8 +262,8 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
return self.matches_optional(criteria_evaluation) is True
def matches_optional(
- self, criteria_evaluation: Dict[str, Optional[bool]]
- ) -> Optional[bool]:
+ self, criteria_evaluation: dict[str, bool | None]
+ ) -> bool | None:
for c in self.condition_hashes:
eval_value = criteria_evaluation.get(c)
if eval_value is False:
@@ -279,14 +273,14 @@ class MorningQuota(MorningStatistics, MarketplaceTask):
return True
def matches_soft(
- self, criteria_evaluation: Dict[str, Optional[bool]]
- ) -> Tuple[Optional[bool], List[str]]:
+ self, criteria_evaluation: dict[str, bool | None]
+ ) -> tuple[bool | None, list[str]]:
# Passes back "matches" (T/F/none) and a list of unknown criterion hashes
- unknowns = list()
+ unknowns = []
for c in self.condition_hashes:
eval_value = criteria_evaluation.get(c)
if eval_value is False:
- return False, list()
+ return False, []
if eval_value is None:
unknowns.append(c)
if unknowns:
@@ -321,22 +315,22 @@ class MorningBid(MorningTaskStatistics):
timeout: PositiveInt = Field(le=24 * 60 * 60)
topic_id: str = Field(min_length=1, max_length=64)
- exclusions: List[MorningExclusion] = Field(default_factory=list)
+ exclusions: list[MorningExclusion] = Field(default_factory=list)
- quotas: List[MorningQuota] = Field(default_factory=list)
+ quotas: list[MorningQuota] = Field(default_factory=list)
source: Literal[Source.MORNING_CONSULT] = Field(default=Source.MORNING_CONSULT)
- used_question_ids: Set[MorningQuestionID] = Field(default_factory=set)
+ used_question_ids: set[MorningQuestionID] = Field(default_factory=set)
# This is a "special" key to store all conditions that are used (as
# "condition_hashes") throughout this survey. In the reduced representation
# of this task (nearly always, for db i/o, in global_vars) this field will
# be null.
- conditions: Optional[Dict[str, MorningCondition]] = Field(default=None)
+ conditions: dict[str, MorningCondition] | None = Field(default=None)
# This doesn't get stored in the db directly
- experimental_single_use_qualifications: Optional[List[MorningQuestion]] = Field(
+ experimental_single_use_qualifications: list[MorningQuestion] | None = Field(
default=None
)
@@ -345,8 +339,8 @@ class MorningBid(MorningTaskStatistics):
created_api: AwareDatetimeISO = Field(validation_alias="published_at")
# This does not come from the API. We set it when we update this in the db.
- created: Optional[AwareDatetimeISO] = Field(default=None)
- updated: Optional[AwareDatetimeISO] = Field(default=None)
+ created: AwareDatetimeISO | None = Field(default=None)
+ updated: AwareDatetimeISO | None = Field(default=None)
# ignoring from API: closed_at
@@ -365,7 +359,7 @@ class MorningBid(MorningTaskStatistics):
@property
def language_iso_any(self):
- return sorted(self.language_isos)[0]
+ return min(self.language_isos)
@property
def locale(self):
@@ -373,7 +367,7 @@ class MorningBid(MorningTaskStatistics):
@computed_field
@cached_property
- def all_hashes(self) -> Set[str]:
+ def all_hashes(self) -> set[str]:
s = set()
for q in self.quotas:
s.update(set(q.condition_hashes))
@@ -387,7 +381,7 @@ class MorningBid(MorningTaskStatistics):
@model_validator(mode="before")
@classmethod
- def setup_quota_fields(cls, data: Dict[str, Any]) -> Dict[str, Any]:
+ def setup_quota_fields(cls, data: dict[str, Any]) -> dict[str, Any]:
# These fields get "inherited" by each quota from its bid.
quota_fields = [
"country_iso",
@@ -419,11 +413,11 @@ class MorningBid(MorningTaskStatistics):
@model_validator(mode="before")
@classmethod
- def setup_conditions(cls, data: Dict[str, Any]) -> Dict[str, Any]:
+ def setup_conditions(cls, data: dict[str, Any]) -> dict[str, Any]:
if "conditions" in data:
return data
- data["conditions"] = dict()
+ data["conditions"] = {}
for quota in data["quotas"]:
if "qualifications" in quota:
quota_conditions = [
@@ -448,7 +442,7 @@ class MorningBid(MorningTaskStatistics):
@model_validator(mode="before")
@classmethod
- def clean_alias(cls, data: Dict[str, Any]) -> Dict[str, Any]:
+ def clean_alias(cls, data: dict[str, Any]) -> dict[str, Any]:
# Make sure fields are named certain ways, so we don't have to check
# aliases within other validators
if "estimated_length_of_interview" in data:
@@ -503,18 +497,16 @@ class MorningBid(MorningTaskStatistics):
return d
@classmethod
- def from_db(cls, d: Dict[str, Any]) -> Self:
- d["created"] = d["created"].replace(tzinfo=timezone.utc)
- d["updated"] = d["updated"].replace(tzinfo=timezone.utc)
- d["expected_end"] = d["expected_end"].replace(tzinfo=timezone.utc)
- d["created_api"] = d["created_api"].replace(tzinfo=timezone.utc)
+ def from_db(cls, d: dict[str, Any]) -> Self:
+ d["created"] = d["created"].replace(tzinfo=UTC)
+ d["updated"] = d["updated"].replace(tzinfo=UTC)
+ d["expected_end"] = d["expected_end"].replace(tzinfo=UTC)
+ d["created_api"] = d["created_api"].replace(tzinfo=UTC)
d["used_question_ids"] = json.loads(d["used_question_ids"])
d["exclusions"] = json.loads(d["exclusions"])
return cls.model_validate(d)
- def passes_quotas(
- self, criteria_evaluation: Dict[str, Optional[bool]]
- ) -> Optional[str]:
+ def passes_quotas(self, criteria_evaluation: dict[str, bool | None]) -> str | None:
# Quotas are mutually-exclusive. A user can only possibly match 1 quota.
# Returns the passing quota ID or None (if user doesn't pass any quota)
for q in self.quotas:
@@ -522,8 +514,8 @@ class MorningBid(MorningTaskStatistics):
return q.id
def passes_quotas_soft(
- self, criteria_evaluation: Dict[str, Optional[bool]]
- ) -> Tuple[Optional[bool], Optional[List[str]], Optional[Set[str]]]:
+ self, criteria_evaluation: dict[str, bool | None]
+ ) -> tuple[bool | None, list[str] | None, set[str] | None]:
"""
Quotas are mutually-exclusive. A user can only possibly match 1
quota. As such, all unknown questions on any quota will be
@@ -547,15 +539,15 @@ class MorningBid(MorningTaskStatistics):
return False, None, None
def determine_eligibility(
- self, criteria_evaluation: dict[str, Optional[bool]]
- ) -> Optional[str]:
+ self, criteria_evaluation: dict[str, bool | None]
+ ) -> str | None:
if not self.is_open:
return None
return self.passes_quotas(criteria_evaluation)
def determine_eligibility_soft(
- self, criteria_evaluation: dict[str, Optional[bool]]
- ) -> Tuple[Optional[bool], Optional[List[str]], Optional[Set[str]]]:
+ self, criteria_evaluation: dict[str, bool | None]
+ ) -> tuple[bool | None, list[str] | None, set[str] | None]:
if not self.is_open:
return False, None, None
return self.passes_quotas_soft(criteria_evaluation)