Skip to content

Commit d77a2e4

Browse files
[TS-1719] Feasibility active record class (#750)
1 parent e89f15b commit d77a2e4

File tree

5 files changed

+210
-4
lines changed

5 files changed

+210
-4
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ You can check your current version with the following command:
3030

3131
For more information, see [UP42 Python package description](https://pypi.org/project/up42-py/).
3232

33+
### 2.4.0a1
34+
**July 02, 2025**
35+
- Added `FeasibilityStudy` active record class to `tasking` module.
36+
- Exported `FeasibilityStudy` and `FeasibilityStudySorting` to `up42` namespace.
37+
3338
## 2.3.1
3439
**June 26, 2025**
3540

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "up42-py"
3-
version = "2.3.1"
3+
version = "2.4.0a1"
44
description = "Python SDK for UP42, the geospatial marketplace and developer platform."
55
authors = ["UP42 GmbH <[email protected]>"]
66
license = "https://github.com/up42/up42-py/blob/master/LICENSE"

tests/test_tasking.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,118 @@ def test_should_get_all(
345345
sort_by=sort_by,
346346
)
347347
assert list(quotations) == [self.quotation] * 4
348+
349+
350+
class TestFeasibilityStudy:
351+
FEASIBILITY_ID: str = FEASIBILITY_ID
352+
ACCOUNT_ID: str = ACCOUNT_ID
353+
WORKSPACE_ID: str = "workspace-id"
354+
ORDER_ID: str = "test-order-id"
355+
OPTION_ID: str = "option-123"
356+
357+
metadata: dict = {
358+
"id": FEASIBILITY_ID,
359+
"createdAt": "created-at",
360+
"updatedAt": "updated-at",
361+
"accountId": ACCOUNT_ID,
362+
"workspaceId": WORKSPACE_ID,
363+
"orderId": ORDER_ID,
364+
"decision": "NOT_DECIDED",
365+
"options": [{"id": OPTION_ID}],
366+
"decisionAt": "decided-at",
367+
}
368+
feasibility_study = tasking.FeasibilityStudy(
369+
id=FEASIBILITY_ID,
370+
created_at="created-at",
371+
updated_at="updated-at",
372+
account_id=ACCOUNT_ID,
373+
workspace_id=WORKSPACE_ID,
374+
order_id=ORDER_ID,
375+
decision="NOT_DECIDED",
376+
options=[{"id": OPTION_ID}],
377+
decided_at="decided-at",
378+
decision_option=None,
379+
)
380+
381+
@pytest.mark.parametrize("feasibility_study_id", [None, FEASIBILITY_ID])
382+
@pytest.mark.parametrize("workspace_id", [None, WORKSPACE_ID])
383+
@pytest.mark.parametrize("order_id", [None, ORDER_ID])
384+
@pytest.mark.parametrize("decision", [None, ["NOT_DECIDED", "ACCEPTED"], ["ACCEPTED"]])
385+
@pytest.mark.parametrize(
386+
"sort_by",
387+
[
388+
None,
389+
tasking.FeasibilityStudySorting.created_at.asc,
390+
tasking.FeasibilityStudySorting.updated_at.asc,
391+
tasking.FeasibilityStudySorting.decided_at.asc,
392+
],
393+
ids=str,
394+
)
395+
def test_should_get_all(
396+
self,
397+
requests_mock: req_mock.Mocker,
398+
feasibility_study_id: Optional[str],
399+
workspace_id: Optional[str],
400+
order_id: Optional[str],
401+
decision: Optional[List[tasking.FeasibilityStatus]],
402+
sort_by: Optional[utils.SortingField],
403+
):
404+
query_params: dict[str, Any] = {}
405+
if feasibility_study_id:
406+
query_params["id"] = feasibility_study_id
407+
if workspace_id:
408+
query_params["workspaceId"] = workspace_id
409+
if order_id:
410+
query_params["orderId"] = order_id
411+
if decision:
412+
query_params["decision"] = decision
413+
if sort_by:
414+
query_params["sort"] = str(sort_by)
415+
base_url = f"{constants.API_HOST}/v2/tasking/feasibility-studies"
416+
expected = [self.metadata] * 4
417+
for page in [0, 1]:
418+
query_params["page"] = page
419+
query = urllib.parse.urlencode(query_params, doseq=True, safe="")
420+
url = base_url + (query and f"?{query}")
421+
offset = page * 2
422+
response = {
423+
"content": expected[offset : offset + 2], # noqa: E203
424+
"totalPages": 2,
425+
}
426+
requests_mock.get(url=url, json=response)
427+
feasibility_studies = tasking.FeasibilityStudy.all(
428+
feasibility_study_id=feasibility_study_id,
429+
workspace_id=workspace_id,
430+
order_id=order_id,
431+
decision=decision,
432+
sort_by=sort_by,
433+
)
434+
assert list(feasibility_studies) == [self.feasibility_study] * 4
435+
436+
def test_should_accept(self):
437+
feasibility_study = dataclasses.replace(self.feasibility_study, decision_option=None)
438+
feasibility_study.accept(self.OPTION_ID)
439+
assert feasibility_study.decision_option.id == self.OPTION_ID # type: ignore[union-attr]
440+
441+
def test_should_save(self, requests_mock: req_mock.Mocker):
442+
feasibility_study = dataclasses.replace(self.feasibility_study, decision_option=None)
443+
feasibility_study.accept(self.OPTION_ID)
444+
patch = {"acceptedOptionId": self.OPTION_ID}
445+
url = f"{constants.API_HOST}/v2/tasking/feasibility-studies/{self.FEASIBILITY_ID}"
446+
expected_description = "description"
447+
expected_json = self.metadata | {"decisionOption": {"id": self.OPTION_ID, "description": expected_description}}
448+
requests_mock.patch(
449+
url=url,
450+
json=expected_json,
451+
additional_matcher=helpers.match_request_body(patch),
452+
)
453+
feasibility_study.save()
454+
assert feasibility_study.decision_option.id == self.OPTION_ID # type: ignore[union-attr]
455+
assert feasibility_study.decision_option.description == expected_description # type: ignore[union-attr]
456+
457+
def test_should_raise_if_no_decision_option_on_save(self):
458+
feasibility_study = dataclasses.replace(self.feasibility_study, decision_option=None)
459+
with pytest.raises(
460+
feasibility_study.NoDecisionOptionChosen, match="No decision option chosen for this feasibility study."
461+
):
462+
feasibility_study.save()

up42/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from up42.processing import Job, JobSorting, JobStatus
3737
from up42.stac import extend as stac_extend
3838
from up42.storage import Storage
39-
from up42.tasking import Quotation, QuotationSorting, Tasking
39+
from up42.tasking import FeasibilityStudy, FeasibilityStudySorting, Quotation, QuotationSorting, Tasking
4040
from up42.tools import get_example_aoi, read_vector_file
4141
from up42.utils import get_up42_py_version
4242
from up42.webhooks import Webhook
@@ -77,5 +77,7 @@
7777
BatchOrderTemplate,
7878
Quotation,
7979
QuotationSorting,
80+
FeasibilityStudy,
81+
FeasibilityStudySorting,
8082
]
8183
]

up42/tasking.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def decide_quotation(self, quotation_id: str, decision: QuotationDecision) -> di
155155
url = host.endpoint(f"/v2/tasking/quotation/{quotation_id}")
156156
return self.session.patch(url, json={"decision": decision}).json()
157157

158-
@utils.deprecation(None, "3.0.0")
158+
@utils.deprecation("FeasibilityStudy::all", "3.0.0")
159159
def get_feasibility(
160160
self,
161161
feasibility_id: Optional[str] = None,
@@ -190,7 +190,7 @@ def get_feasibility(
190190
}
191191
return list(utils.paged_query(params, "/v2/tasking/feasibility-studies", self.session))
192192

193-
@utils.deprecation(None, "3.0.0")
193+
@utils.deprecation("FeasibilityStudy", "3.0.0")
194194
def choose_feasibility(self, feasibility_id: str, accepted_option_id: str) -> dict:
195195
"""Accept one of the proposed feasibility study options.
196196
This operation is only allowed on feasibility studies with the NOT_DECIDED status.
@@ -273,3 +273,87 @@ def all(
273273
cls._from_metadata,
274274
utils.paged_query(params, "/v2/tasking/quotation", cls.session),
275275
)
276+
277+
278+
class FeasibilityStudySorting:
279+
created_at = utils.SortingField(name="createdAt")
280+
updated_at = utils.SortingField(name="updatedAt")
281+
decided_at = utils.SortingField(name="decisionAt")
282+
283+
284+
@dataclasses.dataclass
285+
class FeasibilityStudyDecisionOption:
286+
id: str
287+
description: Optional[str] = None
288+
289+
290+
@dataclasses.dataclass
291+
class FeasibilityStudy:
292+
session = base.Session()
293+
id: str
294+
created_at: str
295+
updated_at: str
296+
account_id: str
297+
workspace_id: str
298+
order_id: str
299+
decision: FeasibilityStatus
300+
options: List[dict]
301+
decided_at: Optional[str] = None
302+
decision_option: Optional[FeasibilityStudyDecisionOption] = None
303+
304+
class NoDecisionOptionChosen(Exception):
305+
"""Raised when trying to save a FeasibilityStudy without a chosen decision option."""
306+
307+
@classmethod
308+
def all(
309+
cls,
310+
feasibility_study_id: Optional[str] = None,
311+
workspace_id: Optional[str] = None,
312+
order_id: Optional[str] = None,
313+
decision: Optional[List[FeasibilityStatus]] = None,
314+
sort_by: Optional[utils.SortingField] = None,
315+
) -> Iterator["FeasibilityStudy"]:
316+
params = {
317+
"id": feasibility_study_id,
318+
"workspaceId": workspace_id,
319+
"orderId": order_id,
320+
"decision": decision,
321+
"sort": sort_by,
322+
}
323+
return map(
324+
cls._from_metadata,
325+
utils.paged_query(params, "/v2/tasking/feasibility-studies", cls.session),
326+
)
327+
328+
@staticmethod
329+
def _from_metadata(metadata: dict) -> "FeasibilityStudy":
330+
decision_option = metadata.get("decisionOption")
331+
if decision_option is not None:
332+
decision_option = FeasibilityStudyDecisionOption(decision_option["id"], decision_option["description"])
333+
return FeasibilityStudy(
334+
id=metadata["id"],
335+
created_at=metadata["createdAt"],
336+
updated_at=metadata["updatedAt"],
337+
account_id=metadata["accountId"],
338+
workspace_id=metadata["workspaceId"],
339+
order_id=metadata["orderId"],
340+
decision=metadata["decision"],
341+
options=metadata.get("options", []),
342+
decided_at=metadata.get("decisionAt"),
343+
decision_option=decision_option,
344+
)
345+
346+
def accept(self, option_id: str):
347+
self.decision_option = FeasibilityStudyDecisionOption(option_id)
348+
349+
def save(self):
350+
url = host.endpoint(f"/v2/tasking/feasibility-studies/{self.id}")
351+
if self.decision_option is None:
352+
raise FeasibilityStudy.NoDecisionOptionChosen(
353+
"No decision option chosen for this feasibility study. "
354+
"Please call 'accept' with a valid option ID before saving."
355+
)
356+
metadata = self.session.patch(url, json={"acceptedOptionId": self.decision_option.id}).json()
357+
feasibility_study = self._from_metadata(metadata)
358+
for field in dataclasses.fields(feasibility_study):
359+
setattr(self, field.name, getattr(feasibility_study, field.name))

0 commit comments

Comments
 (0)