aboutsummaryrefslogtreecommitdiff
path: root/jb/models
diff options
context:
space:
mode:
authorMax Nanis2026-09-13 19:03:55 +0000
committerMax Nanis2026-09-13 19:03:55 +0000
commit8fbe8d439b418796932aaa88297e006c714e4d13 (patch)
tree6c800476edc1e771fc570b559d483df8d59d4324 /jb/models
parent12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff)
parent2c94f248d2438071a918fa9a30bf114ef9aa29b4 (diff)
downloadamt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.tar.gz
amt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.zip
Merges pull request #3
Off of Amazon!!!
Diffstat (limited to 'jb/models')
-rw-r--r--jb/models/__init__.py40
-rw-r--r--jb/models/amt.py19
-rw-r--r--jb/models/assignment.py32
-rw-r--r--jb/models/auth.py110
-rw-r--r--jb/models/bonus.py15
-rw-r--r--jb/models/custom_types.py99
-rw-r--r--jb/models/errors.py4
-rw-r--r--jb/models/event.py7
-rw-r--r--jb/models/hit.py153
-rw-r--r--jb/models/response.py21
10 files changed, 263 insertions, 237 deletions
diff --git a/jb/models/__init__.py b/jb/models/__init__.py
index 0aeae14..e69de29 100644
--- a/jb/models/__init__.py
+++ b/jb/models/__init__.py
@@ -1,40 +0,0 @@
-from decimal import Decimal
-from typing import Optional
-
-from pydantic import BaseModel, Field, ConfigDict
-
-
-class HTTPHeaders(BaseModel):
- request_id: str = Field(alias="x-amzn-requestid", min_length=36, max_length=36)
- content_type: str = Field(alias="content-type", min_length=26, max_length=26)
- # 'content-length': '1255',
- content_length: str = Field(alias="content-length", min_length=2)
- # 'Mon, 15 Jan 2024 23:40:32 GMT'
- date: str = Field()
-
- connection: Optional[str] = Field(default=None) # 'close'
-
-
-class ResponseMetadata(BaseModel):
- model_config = ConfigDict(extra="forbid", validate_assignment=True)
-
- request_id: str = Field(alias="RequestId", min_length=36, max_length=36)
- status_code: int = Field(alias="HTTPStatusCode", ge=200, le=599)
- headers: HTTPHeaders = Field(alias="HTTPHeaders")
- retry_attempts: int = Field(alias="RetryAttempts", ge=0)
-
-
-class AMTAccount(BaseModel):
- model_config = ConfigDict(extra="ignore", validate_assignment=True)
-
- # Remaining available AWS Billing usage if you have enabled AWS Billing.
- available_balance: Decimal = Field()
- onhold_balance: Decimal = Field(default=Decimal(0))
-
- # --- Properties ---
-
- @property
- def is_healthy(self) -> bool:
- # A healthy account is one with at least $2,500 worth of
- # credit available to it
- return self.available_balance >= 2_500
diff --git a/jb/models/amt.py b/jb/models/amt.py
new file mode 100644
index 0000000..e012741
--- /dev/null
+++ b/jb/models/amt.py
@@ -0,0 +1,19 @@
+from decimal import Decimal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class AMTAccount(BaseModel):
+ model_config = ConfigDict(extra="ignore", validate_assignment=True)
+
+ # Remaining available AWS Billing usage if you have enabled AWS Billing.
+ available_balance: Decimal = Field()
+ onhold_balance: Decimal = Field(default=Decimal(0))
+
+ # --- Properties ---
+
+ @property
+ def is_healthy(self) -> bool:
+ # A healthy account is one with at least $2,500 worth of
+ # credit available to it
+ return self.available_balance >= 2_500
diff --git a/jb/models/assignment.py b/jb/models/assignment.py
index 92e5a89..fa6ccd5 100644
--- a/jb/models/assignment.py
+++ b/jb/models/assignment.py
@@ -1,23 +1,26 @@
import logging
from datetime import datetime, timezone
-from typing import Optional, TypedDict, Any
+from typing import Any, TypedDict
from xml.etree import ElementTree
+from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr
from mypy_boto3_mturk.type_defs import AssignmentTypeDef
from pydantic import (
BaseModel,
- Field,
ConfigDict,
- model_validator,
+ Field,
PositiveInt,
TypeAdapter,
ValidationError,
+ model_validator,
)
from typing_extensions import Self
-from jb.models.custom_types import AMTBoto3ID, AwareDatetimeISO, UUIDStr
+from jb.models.custom_types import AMTBoto3ID
from jb.models.definitions import AssignmentStatus
+logger = logging.getLogger("amtjb")
+
class AnswerDict(TypedDict):
amt_assignment_id: str
@@ -36,8 +39,8 @@ class AssignmentStub(BaseModel):
validate_assignment=True,
)
- id: Optional[PositiveInt] = Field(default=None)
- hit_id: Optional[PositiveInt] = Field(default=None)
+ id: PositiveInt | None = Field(default=None)
+ hit_id: PositiveInt | None = Field(default=None)
amt_assignment_id: AMTBoto3ID = Field()
amt_hit_id: AMTBoto3ID = Field()
amt_worker_id: str = Field(min_length=3, max_length=50)
@@ -50,7 +53,7 @@ class AssignmentStub(BaseModel):
description="When this record was saved in the database",
)
- modified_at: Optional[AwareDatetimeISO] = Field(
+ modified_at: AwareDatetimeISO | None = Field(
default_factory=lambda: datetime.now(tz=timezone.utc),
description="When this record was updated / modified in the database",
)
@@ -96,18 +99,18 @@ class Assignment(AssignmentStub):
"submitted results.",
)
- approval_time: Optional[AwareDatetimeISO] = Field(
+ approval_time: AwareDatetimeISO | None = Field(
default=None,
description="The date and time the Requester approved the results. This "
"value is omitted from the assignment if the Requester has "
"not yet approved the results.",
)
- rejection_time: Optional[AwareDatetimeISO] = Field(
+ rejection_time: AwareDatetimeISO | None = Field(
default=None,
description="The date and time the Requester rejected the results.",
)
- requester_feedback: Optional[str] = Field(
+ requester_feedback: str | None = Field(
# Default: None. This field isn't returned with assignment data by
# default. To request this field, specify a response group of
# AssignmentFeedback. For information about response groups, see
@@ -123,11 +126,11 @@ class Assignment(AssignmentStub):
},
)
- answer_xml: Optional[str] = Field(default=None, exclude=True)
+ answer_xml: str | None = Field(default=None, exclude=True)
# GRL Specific
- tsid: Optional[UUIDStr] = Field(default=None)
+ tsid: UUIDStr | None = Field(default=None)
# --- Validators ---
@@ -140,7 +143,8 @@ class Assignment(AssignmentStub):
values["tsid"] = TypeAdapter(UUIDStr).validate_python(tsid)
except ValidationError as e:
# Don't break the model validation if a baddie messes with the tsid in the answer.
- logging.warning(e)
+ logger.warning(e)
+
values["tsid"] = None
return values
@@ -173,7 +177,7 @@ class Assignment(AssignmentStub):
# --- Properties ---
@property
- def answers_dict(self) -> Optional[AnswerDict]:
+ def answers_dict(self) -> AnswerDict | None:
# See https://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_AssignmentDataStructureArticle.html
# https://docs.aws.amazon.com/AWSMechTurk/latest/AWSMechanicalTurkRequester/Concepts_NotificationsArticle.html
if self.answer_xml is None:
diff --git a/jb/models/auth.py b/jb/models/auth.py
new file mode 100644
index 0000000..886a607
--- /dev/null
+++ b/jb/models/auth.py
@@ -0,0 +1,110 @@
+import hashlib
+import hmac
+from typing import Any
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ EmailStr,
+ Field,
+ TypeAdapter,
+ computed_field,
+ field_validator,
+ model_validator,
+)
+
+from jb.config import settings
+
+
+def email_to_product_user_id(email: str) -> str:
+ """Return a deterministic, non-reversible product user ID for an email.
+
+ The same normalized email and secret salt always produce the same ID. Keep
+ the salt private and stable; changing it changes every generated ID.
+ """
+ assert settings.magic_token_salt
+ salt_bytes = settings.magic_token_salt.get_secret_value().encode("utf-8")
+ if len(salt_bytes) < 32:
+ raise ValueError("salt must be at least 32 bytes")
+
+ if not email.isascii():
+ raise ValueError("email must contain ASCII characters only")
+
+ normalized_email = str(TypeAdapter(EmailStr).validate_python(email)).lower()
+ return hmac.new(
+ key=salt_bytes,
+ msg=normalized_email.encode("utf-8"),
+ digestmod=hashlib.sha1,
+ ).hexdigest()
+
+
+class User(BaseModel):
+ """A user that has been authenticated and exists in THL"""
+
+ email: EmailStr = Field()
+
+ display_name: str | None = Field(default=None, max_length=255)
+
+ blocked: bool = Field(default=False)
+
+ @field_validator("email", mode="before")
+ @classmethod
+ def normalize_email(cls, value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ if not value.isascii():
+ raise ValueError("email must contain ASCII characters only")
+ return value.lower()
+
+ @model_validator(mode="before")
+ @classmethod
+ def validate_product_user_id(cls, data: Any) -> Any:
+ if not isinstance(data, dict) or "product_user_id" not in data:
+ return data
+
+ provided_id = data["product_user_id"]
+ email = data.get("email")
+ if email is None:
+ return data
+
+ expected_id = email_to_product_user_id(str(email))
+ if not isinstance(provided_id, str) or not hmac.compare_digest(
+ provided_id, expected_id
+ ):
+ raise ValueError(
+ f"product_user_id {provided_id} does not match email {email}"
+ )
+
+ # The computed field is authoritative; do not retain the input value.
+ validated_data = dict(data)
+ validated_data.pop("product_user_id")
+ return validated_data
+
+ @computed_field
+ @property
+ def product_user_id(self) -> str:
+ return email_to_product_user_id(self.email)
+
+
+class AccountLogin(BaseModel):
+ # There is no practical difference between an Account "login" and "create"
+ email: EmailStr = Field()
+
+
+class MagicLinkExchangeRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ token: str = Field(min_length=1)
+
+
+class AmtAccountLink(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ email: EmailStr
+ amt_worker_id: str = Field(min_length=3, max_length=50)
+
+
+class SessionResponse(BaseModel):
+ session_token: str
+ token_type: str = "bearer"
+ expires_in: int
diff --git a/jb/models/bonus.py b/jb/models/bonus.py
index a536dd1..c6da3c4 100644
--- a/jb/models/bonus.py
+++ b/jb/models/bonus.py
@@ -1,10 +1,11 @@
-from typing import Optional, Dict, Any
+from typing import Any
-from pydantic import BaseModel, Field, ConfigDict, PositiveInt
+from generalresearch.currency import USDCent
+from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr
+from pydantic import BaseModel, ConfigDict, Field, PositiveInt
from typing_extensions import Self
-from generalresearchutils.currency import USDCent
-from jb.models.custom_types import AMTBoto3ID, AwareDatetimeISO, UUIDStr
+from jb.models.custom_types import AMTBoto3ID
class Bonus(BaseModel):
@@ -20,8 +21,8 @@ class Bonus(BaseModel):
extra="forbid",
validate_assignment=True,
)
- id: Optional[PositiveInt] = Field(default=None)
- assignment_id: Optional[PositiveInt] = Field(default=None)
+ id: PositiveInt | None = Field(default=None)
+ assignment_id: PositiveInt | None = Field(default=None)
amt_worker_id: str = Field(min_length=3, max_length=50)
amt_assignment_id: AMTBoto3ID = Field()
@@ -40,7 +41,7 @@ class Bonus(BaseModel):
return d
@classmethod
- def from_postgres(cls, data: Dict[str, Any]) -> Self:
+ def from_postgres(cls, data: dict[str, Any]) -> Self:
data["amount"] = USDCent(round(data["amount"] * 100))
fields = set(cls.model_fields.keys())
data = {k: v for k, v in data.items() if k in fields}
diff --git a/jb/models/custom_types.py b/jb/models/custom_types.py
index 10bc9d1..385c1ba 100644
--- a/jb/models/custom_types.py
+++ b/jb/models/custom_types.py
@@ -1,101 +1,8 @@
import re
-from datetime import datetime, timezone
-from typing import Any, Optional
-from uuid import UUID
+from typing import Annotated
-from pydantic import (
- AwareDatetime,
- StringConstraints,
- TypeAdapter,
- HttpUrl,
-)
-from pydantic.functional_serializers import PlainSerializer
-from pydantic.functional_validators import AfterValidator, BeforeValidator
-from pydantic.networks import UrlConstraints
-from pydantic_core import Url
-from typing_extensions import Annotated
-
-
-def convert_datetime_to_iso_8601_with_z_suffix(dt: datetime) -> str:
- # By default, datetimes are serialized with the %f optional. We don't want that because
- # then the deserialization fails if the datetime didn't have microseconds.
- return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
-
-
-def convert_str_dt(v: Any) -> Optional[AwareDatetime]:
- # By default, pydantic is unable to handle tz-aware isoformat str. Attempt to parse a str
- # that was dumped using the iso8601 format with Z suffix.
- if v is not None and type(v) is str:
- assert v.endswith("Z") and "T" in v, "invalid format"
- return datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.%fZ").replace(
- tzinfo=timezone.utc
- )
- return v
-
-
-def assert_utc(v: AwareDatetime) -> AwareDatetime:
- assert v.tzinfo == timezone.utc, "Timezone is not UTC"
- return v
-
-
-# Our custom AwareDatetime that correctly serializes and deserializes
-# to an ISO8601 str with timezone
-AwareDatetimeISO = Annotated[
- AwareDatetime,
- BeforeValidator(convert_str_dt),
- AfterValidator(assert_utc),
- PlainSerializer(
- lambda x: x.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- when_used="json-unless-none",
- ),
-]
-
-# ISO 3166-1 alpha-2 (two-letter codes, lowercase)
-# "Like" b/c it matches the format, but we're not explicitly checking
-# it is one of our supported values. See models.thl.locales for that.
-CountryISOLike = Annotated[
- str, StringConstraints(max_length=2, min_length=2, pattern=r"^[a-z]{2}$")
-]
-# 3-char ISO 639-2/B, lowercase
-LanguageISOLike = Annotated[
- str, StringConstraints(max_length=3, min_length=3, pattern=r"^[a-z]{3}$")
-]
-
-
-def check_valid_uuid(v: str) -> str:
- try:
- assert UUID(v).hex == v
- except Exception:
- raise ValueError("Invalid UUID")
- return v
-
-
-# Our custom field that stores a UUID4 as the .hex string representation
-UUIDStr = Annotated[
- str,
- StringConstraints(min_length=32, max_length=32),
- AfterValidator(check_valid_uuid),
-]
-# Accepts the non-hex representation and coerces
-UUIDStrCoerce = Annotated[
- str,
- StringConstraints(min_length=32, max_length=32),
- BeforeValidator(lambda value: TypeAdapter(UUID).validate_python(value).hex),
- AfterValidator(check_valid_uuid),
-]
-
-# Same thing as UUIDStr with HttpUrl field. It is confusing that this
-# is not a str https://github.com/pydantic/pydantic/discussions/6395
-HttpUrlStr = Annotated[
- str,
- BeforeValidator(lambda value: str(TypeAdapter(HttpUrl).validate_python(value))),
-]
-
-HttpsUrl = Annotated[Url, UrlConstraints(max_length=2083, allowed_schemes=["https"])]
-HttpsUrlStr = Annotated[
- str,
- BeforeValidator(lambda value: str(TypeAdapter(HttpsUrl).validate_python(value))),
-]
+from pydantic import StringConstraints
+from pydantic.functional_validators import AfterValidator
def check_valid_amt_boto3_id(v: str) -> str:
diff --git a/jb/models/errors.py b/jb/models/errors.py
index 94f5fbb..1fe71df 100644
--- a/jb/models/errors.py
+++ b/jb/models/errors.py
@@ -1,9 +1,9 @@
import re
from enum import Enum
-from pydantic import BaseModel, Field, ConfigDict, model_validator
+from pydantic import BaseModel, ConfigDict, Field, model_validator
-from jb.models import ResponseMetadata
+from jb.models.response import ResponseMetadata
class BotoRequestErrorOperation(str, Enum):
diff --git a/jb/models/event.py b/jb/models/event.py
index f8867c0..fb5735b 100644
--- a/jb/models/event.py
+++ b/jb/models/event.py
@@ -1,9 +1,10 @@
-from typing import Dict, Any
+from typing import Any
+from generalresearch.models.custom_types import AwareDatetimeISO
from mypy_boto3_mturk.literals import EventTypeType
from pydantic import BaseModel, Field
-from jb.models.custom_types import AwareDatetimeISO, AMTBoto3ID
+from jb.models.custom_types import AMTBoto3ID
class MTurkEvent(BaseModel):
@@ -29,7 +30,7 @@ class MTurkEvent(BaseModel):
)
@classmethod
- def from_sns(cls, data: Dict[str, Any]):
+ def from_sns(cls, data: dict[str, Any]):
return cls.model_validate(
{
"event_type": data["EventType"],
diff --git a/jb/models/hit.py b/jb/models/hit.py
index fba2ecf..a550943 100644
--- a/jb/models/hit.py
+++ b/jb/models/hit.py
@@ -1,25 +1,26 @@
-from datetime import datetime, timezone, timedelta
-from typing import Optional, List, Dict, Any
+from datetime import datetime, timedelta, timezone
+from typing import Any
from uuid import uuid4
from xml.etree import ElementTree
+from generalresearch.currency import USDCent
+from generalresearch.models.custom_types import AwareDatetimeISO, HttpsUrlStr
from mypy_boto3_mturk.type_defs import HITTypeDef
from pydantic import (
BaseModel,
- Field,
- PositiveInt,
ConfigDict,
+ Field,
NonNegativeInt,
+ PositiveInt,
)
from typing_extensions import Self
-from generalresearchutils.currency import USDCent
-from jb.models.custom_types import AMTBoto3ID, HttpsUrlStr, AwareDatetimeISO
-from jb.models.definitions import HitStatus, HitReviewStatus
+from jb.models.custom_types import AMTBoto3ID
+from jb.models.definitions import HitReviewStatus, HitStatus
class HitQuestion(BaseModel):
- id: Optional[PositiveInt] = Field(default=None)
+ id: PositiveInt | None = Field(default=None)
url: HttpsUrlStr = Field()
height: PositiveInt = Field(default=1_200, ge=100, le=4_000)
@@ -33,7 +34,7 @@ class HitQuestion(BaseModel):
def xml(self) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<ExternalQuestion xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2006-07-14/ExternalQuestion.xsd">
- <ExternalURL>{str(self.url)}</ExternalURL>
+ <ExternalURL>{self.url!s}</ExternalURL>
<FrameHeight>{self.height}</FrameHeight>
</ExternalQuestion>"""
@@ -82,21 +83,25 @@ class HitType(HitTypeCommon):
https://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_CreateHITTypeOperation.html
"""
- id: Optional[PositiveInt] = Field(default=None)
- amt_hit_type_id: Optional[AMTBoto3ID] = Field(default=None)
+ id: PositiveInt | None = Field(default=None)
+ amt_hit_type_id: AMTBoto3ID | None = Field(default=None)
# --- GRL Specific ---
min_active: NonNegativeInt = Field(default=0, le=100_000)
- def to_api_request_body(self):
- return dict(
- AutoApprovalDelayInSeconds=round(self.auto_approval_delay.total_seconds()),
- AssignmentDurationInSeconds=round(self.assignment_duration.total_seconds()),
- Reward=str(self.reward.to_usd()),
- Title=self.title,
- Keywords=self.keywords,
- Description=self.description,
- )
+ def to_api_request_body(self) -> dict[str, Any]:
+ return {
+ "AutoApprovalDelayInSeconds": round(
+ self.auto_approval_delay.total_seconds()
+ ),
+ "AssignmentDurationInSeconds": round(
+ self.assignment_duration.total_seconds()
+ ),
+ "Reward": str(self.reward.to_usd()),
+ "Title": self.title,
+ "Keywords": self.keywords,
+ "Description": self.description,
+ }
def to_postgres(self):
d = self.model_dump(mode="json")
@@ -104,12 +109,12 @@ class HitType(HitTypeCommon):
return d
@classmethod
- def from_postgres(cls, data: Dict[str, Any]) -> Self:
+ def from_postgres(cls, data: dict[str, Any]) -> Self:
data["reward"] = USDCent(round(data["reward"] * 100))
return cls.model_validate(data)
- def generate_hit_amt_request(self, question: HitQuestion) -> Dict[str, Any]:
- d = dict()
+ def generate_hit_amt_request(self, question: HitQuestion) -> dict[str, Any]:
+ d = {}
d["HITTypeId"] = self.amt_hit_type_id
d["MaxAssignments"] = 1
d["LifetimeInSeconds"] = round(timedelta(days=14).total_seconds())
@@ -124,9 +129,9 @@ class Hit(HitTypeCommon):
validate_assignment=True,
)
- id: Optional[PositiveInt] = Field(default=None)
- hit_type_id: Optional[PositiveInt] = Field(default=None)
- question_id: Optional[PositiveInt] = Field(default=None)
+ id: PositiveInt | None = Field(default=None)
+ hit_type_id: PositiveInt | None = Field(default=None)
+ question_id: PositiveInt | None = Field(default=None)
amt_hit_id: AMTBoto3ID = Field()
amt_hit_type_id: AMTBoto3ID = Field()
@@ -138,10 +143,8 @@ class Hit(HitTypeCommon):
# TODO: Check if this is actually ever going to be None. I type fixed it,
# but I don't have anything to suggest it isn't requred. -- Max 2026-02-24
- creation_time: Optional[AwareDatetimeISO] = Field(
- default=None, description="From aws"
- )
- expiration: Optional[AwareDatetimeISO] = Field(default=None)
+ creation_time: AwareDatetimeISO | None = Field(default=None, description="From aws")
+ expiration: AwareDatetimeISO | None = Field(default=None)
# GRL Specific
created_at: AwareDatetimeISO = Field(
@@ -155,7 +158,7 @@ class Hit(HitTypeCommon):
# -- Hit specific
- qualification_requirements: Optional[List[Dict[str, Any]]] = Field(default=None)
+ qualification_requirements: list[dict[str, Any]] | None = Field(default=None)
max_assignments: int = Field()
# # this comes back as expiration. only for the request
@@ -177,27 +180,27 @@ class Hit(HitTypeCommon):
assert hit_type.amt_hit_type_id is not None
h = cls.model_validate(
- dict(
- amt_hit_id=data["HITId"],
- amt_hit_type_id=data["HITTypeId"],
- amt_group_id=data["HITGroupId"],
- status=HitStatus[data["HITStatus"]],
- review_status=HitReviewStatus[data["HITReviewStatus"]],
- creation_time=data["CreationTime"].astimezone(tz=timezone.utc),
- expiration=data["Expiration"].astimezone(tz=timezone.utc),
- hit_question_xml=data["Question"],
- qualification_requirements=data["QualificationRequirements"],
- max_assignments=data["MaxAssignments"],
- assignment_pending_count=data["NumberOfAssignmentsPending"],
- assignment_available_count=data["NumberOfAssignmentsAvailable"],
- assignment_completed_count=data["NumberOfAssignmentsCompleted"],
- description=data["Description"],
- keywords=data["Keywords"],
- reward=USDCent(round(float(data["Reward"]) * 100)),
- title=data["Title"],
- question_id=question.id,
- hit_type_id=hit_type.id,
- )
+ {
+ "amt_hit_id": data["HITId"],
+ "amt_hit_type_id": data["HITTypeId"],
+ "amt_group_id": data["HITGroupId"],
+ "status": HitStatus[data["HITStatus"]],
+ "review_status": HitReviewStatus[data["HITReviewStatus"]],
+ "creation_time": data["CreationTime"].astimezone(tz=timezone.utc),
+ "expiration": data["Expiration"].astimezone(tz=timezone.utc),
+ "hit_question_xml": data["Question"],
+ "qualification_requirements": data["QualificationRequirements"],
+ "max_assignments": data["MaxAssignments"],
+ "assignment_pending_count": data["NumberOfAssignmentsPending"],
+ "assignment_available_count": data["NumberOfAssignmentsAvailable"],
+ "assignment_completed_count": data["NumberOfAssignmentsCompleted"],
+ "description": data["Description"],
+ "keywords": data["Keywords"],
+ "reward": USDCent(round(float(data["Reward"]) * 100)),
+ "title": data["Title"],
+ "question_id": question.id,
+ "hit_type_id": hit_type.id,
+ }
)
return h
@@ -205,27 +208,27 @@ class Hit(HitTypeCommon):
@classmethod
def from_amt_get_hit(cls, data: HITTypeDef) -> Self:
h = cls.model_validate(
- dict(
- amt_hit_id=data["HITId"],
- amt_hit_type_id=data["HITTypeId"],
- amt_group_id=data["HITGroupId"],
- status=HitStatus[data["HITStatus"]],
- review_status=HitReviewStatus[data["HITReviewStatus"]],
- creation_time=data["CreationTime"].astimezone(tz=timezone.utc),
- expiration=data["Expiration"].astimezone(tz=timezone.utc),
- hit_question_xml=data["Question"],
- qualification_requirements=data["QualificationRequirements"],
- max_assignments=data["MaxAssignments"],
- assignment_pending_count=data["NumberOfAssignmentsPending"],
- assignment_available_count=data["NumberOfAssignmentsAvailable"],
- assignment_completed_count=data["NumberOfAssignmentsCompleted"],
- description=data["Description"],
- keywords=data["Keywords"],
- reward=USDCent(round(float(data["Reward"]) * 100)),
- title=data["Title"],
- question_id=None,
- hit_type_id=None,
- )
+ {
+ "amt_hit_id": data["HITId"],
+ "amt_hit_type_id": data["HITTypeId"],
+ "amt_group_id": data["HITGroupId"],
+ "status": HitStatus[data["HITStatus"]],
+ "review_status": HitReviewStatus[data["HITReviewStatus"]],
+ "creation_time": data["CreationTime"].astimezone(tz=timezone.utc),
+ "expiration": data["Expiration"].astimezone(tz=timezone.utc),
+ "hit_question_xml": data["Question"],
+ "qualification_requirements": data["QualificationRequirements"],
+ "max_assignments": data["MaxAssignments"],
+ "assignment_pending_count": data["NumberOfAssignmentsPending"],
+ "assignment_available_count": data["NumberOfAssignmentsAvailable"],
+ "assignment_completed_count": data["NumberOfAssignmentsCompleted"],
+ "description": data["Description"],
+ "keywords": data["Keywords"],
+ "reward": USDCent(round(float(data["Reward"]) * 100)),
+ "title": data["Title"],
+ "question_id": None,
+ "hit_type_id": None,
+ }
)
return h
@@ -235,7 +238,7 @@ class Hit(HitTypeCommon):
return d
@classmethod
- def from_postgres(cls, data: Dict[str, Any]) -> Self:
+ def from_postgres(cls, data: dict[str, Any]) -> Self:
data["reward"] = USDCent(round(data["reward"] * 100))
return cls.model_validate(data)
@@ -248,7 +251,7 @@ class Hit(HitTypeCommon):
}
res = {}
- lookup_table = dict(ExternalURL="url", FrameHeight="height")
+ lookup_table = {"ExternalURL": "url", "FrameHeight": "height"}
for a in root.findall("mt:*", ns):
key = lookup_table[a.tag.split("}")[1]]
val = a.text
diff --git a/jb/models/response.py b/jb/models/response.py
new file mode 100644
index 0000000..22985af
--- /dev/null
+++ b/jb/models/response.py
@@ -0,0 +1,21 @@
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class HTTPHeaders(BaseModel):
+ request_id: str = Field(alias="x-amzn-requestid", min_length=36, max_length=36)
+ content_type: str = Field(alias="content-type", min_length=26, max_length=26)
+ # 'content-length': '1255',
+ content_length: str = Field(alias="content-length", min_length=2)
+ # 'Mon, 15 Jan 2024 23:40:32 GMT'
+ date: str = Field()
+
+ connection: str | None = Field(default=None) # 'close'
+
+
+class ResponseMetadata(BaseModel):
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
+
+ request_id: str = Field(alias="RequestId", min_length=36, max_length=36)
+ status_code: int = Field(alias="HTTPStatusCode", ge=200, le=599)
+ headers: HTTPHeaders = Field(alias="HTTPHeaders")
+ retry_attempts: int = Field(alias="RetryAttempts", ge=0)