Skip to content

Commit 716a00f

Browse files
authored
Merge pull request #256 from crim-ca/fix-limit-integration
Fix limit integration (fixes #237)
2 parents 0093e24 + 1570224 commit 716a00f

File tree

8 files changed

+371
-65
lines changed

8 files changed

+371
-65
lines changed

CHANGES.rst

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Changes:
1313
- Add reference link to ReadTheDocs URL of `Weaver` in API landing page.
1414
- Add references to `OGC-API Processes` requirements and recommendations for eventual conformance listing
1515
(relates to `#231 <https://github.com/crim-ca/weaver/issues/231>`_).
16+
- Add ``datetime`` query parameter for job searches queries
17+
(relates to `#236 <https://github.com/crim-ca/weaver/issues/236>`_).
18+
- Add ``limit`` query parameter validation and integration for jobs in retrieve queries
19+
(relates to `#237 <https://github.com/crim-ca/weaver/issues/237>`_).
1620

1721
Fixes:
1822
------

tests/wps_restapi/test_jobs.py

+189-10
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import unittest
44
import warnings
55
from collections import OrderedDict
6+
from datetime import date
67
from distutils.version import LooseVersion
78
from typing import TYPE_CHECKING
89

910
import mock
1011
import pyramid.testing
1112
import pytest
13+
from dateutil import parser as dateparser
1214

1315
from tests.utils import (
1416
get_module_version,
@@ -35,6 +37,11 @@
3537
from weaver.visibility import VISIBILITY_PRIVATE, VISIBILITY_PUBLIC
3638
from weaver.warning import TimeZoneInfoAlreadySetWarning
3739
from weaver.wps_restapi import swagger_definitions as sd
40+
from weaver.wps_restapi.swagger_definitions import (
41+
DATETIME_INTERVAL_CLOSED_SYMBOL,
42+
DATETIME_INTERVAL_OPEN_END_SYMBOL,
43+
DATETIME_INTERVAL_OPEN_START_SYMBOL
44+
)
3845

3946
if TYPE_CHECKING:
4047
from typing import Iterable, List, Tuple, Union
@@ -51,6 +58,7 @@ def setUpClass(cls):
5158
cls.config = setup_config_with_mongodb(settings=settings)
5259
cls.app = get_test_weaver_app(config=cls.config)
5360
cls.json_headers = {"Accept": CONTENT_TYPE_APP_JSON, "Content-Type": CONTENT_TYPE_APP_JSON}
61+
cls.datetime_interval = cls.generate_test_datetimes()
5462

5563
@classmethod
5664
def tearDownClass(cls):
@@ -94,22 +102,25 @@ def setUp(self):
94102
self.make_job(task_id="4444-4444-4444-4444", process=self.process_public.identifier, service=None,
95103
user_id=self.user_admin_id, status=STATUS_FAILED, progress=55, access=VISIBILITY_PRIVATE)
96104
# job public/private service/process combinations
97-
self.make_job(task_id="5555-5555-5555-5555",
98-
process=self.process_public.identifier, service=self.service_public.name,
105+
self.make_job(task_id="5555-5555-5555-5555", process=self.process_public.identifier,
106+
service=self.service_public.name, created=self.datetime_interval[0],
99107
user_id=self.user_editor1_id, status=STATUS_FAILED, progress=99, access=VISIBILITY_PUBLIC)
100-
self.make_job(task_id="6666-6666-6666-6666",
101-
process=self.process_private.identifier, service=self.service_public.name,
108+
self.make_job(task_id="6666-6666-6666-6666", process=self.process_private.identifier,
109+
service=self.service_public.name, created=self.datetime_interval[1],
102110
user_id=self.user_editor1_id, status=STATUS_FAILED, progress=99, access=VISIBILITY_PUBLIC)
103-
self.make_job(task_id="7777-7777-7777-7777",
104-
process=self.process_public.identifier, service=self.service_private.name,
111+
self.make_job(task_id="7777-7777-7777-7777", process=self.process_public.identifier,
112+
service=self.service_private.name, created=self.datetime_interval[2],
105113
user_id=self.user_editor1_id, status=STATUS_FAILED, progress=99, access=VISIBILITY_PUBLIC)
106-
self.make_job(task_id="8888-8888-8888-8888",
107-
process=self.process_private.identifier, service=self.service_private.name,
114+
self.make_job(task_id="8888-8888-8888-8888", process=self.process_private.identifier,
115+
service=self.service_private.name, created=self.datetime_interval[3],
108116
user_id=self.user_editor1_id, status=STATUS_FAILED, progress=99, access=VISIBILITY_PUBLIC)
109117

110-
def make_job(self, task_id, process, service, user_id, status, progress, access):
118+
def make_job(self, task_id, process, service, user_id, status, progress, access, created=None):
119+
120+
created = dateparser.parse(created) if created else None
121+
111122
job = self.job_store.save_job(task_id=task_id, process=process, service=service, is_workflow=False,
112-
user_id=user_id, execute_async=True, access=access)
123+
user_id=user_id, execute_async=True, access=access, created=created)
113124
job.status = status
114125
if status in JOB_STATUS_CATEGORIES[STATUS_CATEGORY_FINISHED]:
115126
job.mark_finished()
@@ -144,6 +155,15 @@ def get_job_request_auth_mock(self, user_id):
144155
mock.patch("{}.has_permission".format(authz_policy_class), return_value=is_admin),
145156
])
146157

158+
@staticmethod
159+
def generate_test_datetimes():
160+
# type: () -> List[str]
161+
"""
162+
Generates a list of dummy datetimes for testing.
163+
"""
164+
year = date.today().year + 1
165+
return ["{}-0{}-02T03:32:38.487000+00:00".format(year, month) for month in range(1, 5)]
166+
147167
@staticmethod
148168
def check_job_format(job):
149169
assert isinstance(job, dict)
@@ -503,3 +523,162 @@ def filter_service(jobs): # type: (Iterable[Job]) -> List[Job]
503523
job_match = all(job in job_ids for job in resp.json["jobs"])
504524
test_values = dict(path=path, access=access, user_id=user_id)
505525
assert job_match, self.message_with_jobs_diffs(resp.json["jobs"], job_ids, test_values, index=i)
526+
527+
def test_jobs_list_with_limit_api(self):
528+
"""
529+
.. seealso::
530+
- `/req/collections/rc-limit-response
531+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-limit-response.adoc>`_
532+
"""
533+
limit_parameter = 20
534+
path = get_path_kvp(sd.jobs_service.path, limit=limit_parameter)
535+
resp = self.app.get(path, headers=self.json_headers)
536+
assert resp.status_code == 200
537+
assert resp.content_type == CONTENT_TYPE_APP_JSON
538+
assert "limit" in resp.json and isinstance(resp.json["limit"], int)
539+
assert resp.json["limit"] == limit_parameter
540+
assert len(resp.json["jobs"]) <= limit_parameter
541+
542+
def test_jobs_list_with_limit_openapi_schema(self):
543+
"""
544+
.. seealso::
545+
- `/req/collections/rc-limit-response
546+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-limit-response.adoc>`_
547+
"""
548+
resp = self.app.get(sd.jobs_service.path, headers=self.json_headers)
549+
assert resp.status_code == 200
550+
assert resp.content_type == CONTENT_TYPE_APP_JSON
551+
assert "limit" in resp.json and isinstance(resp.json["limit"], int)
552+
assert len(resp.json["jobs"]) <= resp.json["limit"]
553+
554+
def test_not_required_fields(self):
555+
uri = sd.openapi_json_service.path
556+
resp = self.app.get(uri, headers=self.json_headers)
557+
assert not resp.json["parameters"]["page"]["required"]
558+
assert not resp.json["parameters"]["limit"]["required"]
559+
560+
def test_jobs_datetime_before(self):
561+
"""
562+
.. seealso::
563+
- `/req/collections/rc-time-collections-response
564+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
565+
"""
566+
datetime_before = DATETIME_INTERVAL_OPEN_START_SYMBOL + self.datetime_interval[0]
567+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_before)
568+
resp = self.app.get(path, headers=self.json_headers)
569+
assert resp.status_code == 200
570+
assert resp.content_type == CONTENT_TYPE_APP_JSON
571+
assert len(resp.json["jobs"]) == 4
572+
for job in resp.json["jobs"]:
573+
base_uri = sd.jobs_service.path + "/{}".format(job)
574+
path = get_path_kvp(base_uri)
575+
resp = self.app.get(path, headers=self.json_headers)
576+
assert resp.status_code == 200
577+
assert resp.content_type == CONTENT_TYPE_APP_JSON
578+
assert dateparser.parse(resp.json["created"]) <= dateparser.parse(
579+
datetime_before.replace(DATETIME_INTERVAL_OPEN_START_SYMBOL, ""))
580+
581+
def test_jobs_datetime_after(self):
582+
"""
583+
.. seealso::
584+
- `/req/collections/rc-time-collections-response
585+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
586+
"""
587+
datetime_after = str(self.datetime_interval[2] + DATETIME_INTERVAL_OPEN_END_SYMBOL)
588+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_after)
589+
resp = self.app.get(path, headers=self.json_headers)
590+
assert resp.status_code == 200
591+
assert resp.content_type == CONTENT_TYPE_APP_JSON
592+
assert len(resp.json["jobs"]) == 2
593+
for job in resp.json["jobs"]:
594+
base_uri = sd.jobs_service.path + "/{}".format(job)
595+
path = get_path_kvp(base_uri)
596+
resp = self.app.get(path, headers=self.json_headers)
597+
assert resp.status_code == 200
598+
assert resp.content_type == CONTENT_TYPE_APP_JSON
599+
assert dateparser.parse(resp.json["created"]) >= dateparser.parse(
600+
datetime_after.replace(DATETIME_INTERVAL_OPEN_END_SYMBOL, ""))
601+
602+
def test_jobs_datetime_interval(self):
603+
"""
604+
.. seealso::
605+
- `/req/collections/rc-time-collections-response
606+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
607+
"""
608+
datetime_interval = self.datetime_interval[1] + DATETIME_INTERVAL_CLOSED_SYMBOL + self.datetime_interval[3]
609+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_interval)
610+
resp = self.app.get(path, headers=self.json_headers)
611+
assert resp.status_code == 200
612+
assert resp.content_type == CONTENT_TYPE_APP_JSON
613+
614+
datetime_after, datetime_before = datetime_interval.split(DATETIME_INTERVAL_CLOSED_SYMBOL)
615+
assert len(resp.json["jobs"]) == 3
616+
for job in resp.json["jobs"]:
617+
base_uri = sd.jobs_service.path + "/{}".format(job)
618+
path = get_path_kvp(base_uri)
619+
resp = self.app.get(path, headers=self.json_headers)
620+
assert resp.status_code == 200
621+
assert resp.content_type == CONTENT_TYPE_APP_JSON
622+
assert dateparser.parse(resp.json["created"]) >= dateparser.parse(datetime_after)
623+
assert dateparser.parse(resp.json["created"]) <= dateparser.parse(datetime_before)
624+
625+
def test_jobs_datetime_match(self):
626+
"""
627+
.. seealso::
628+
- `/req/collections/rc-time-collections-response
629+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
630+
"""
631+
datetime_match = self.datetime_interval[1]
632+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_match)
633+
resp = self.app.get(path, headers=self.json_headers)
634+
assert resp.status_code == 200
635+
assert resp.content_type == CONTENT_TYPE_APP_JSON
636+
assert len(resp.json["jobs"]) == 1
637+
for job in resp.json["jobs"]:
638+
base_uri = sd.jobs_service.path + "/{}".format(job)
639+
path = get_path_kvp(base_uri)
640+
resp = self.app.get(path, headers=self.json_headers)
641+
assert resp.status_code == 200
642+
assert resp.content_type == CONTENT_TYPE_APP_JSON
643+
assert dateparser.parse(resp.json["created"]) == dateparser.parse(datetime_match)
644+
645+
def test_jobs_datetime_invalid(self):
646+
"""
647+
.. seealso::
648+
- `/req/collections/rc-time-collections-response
649+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
650+
651+
datetime_invalid is not formated against the rfc3339 datetime format,
652+
for more details refer to https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
653+
"""
654+
datetime_invalid = "2022-31-12 23:59:59"
655+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_invalid)
656+
resp = self.app.get(path, headers=self.json_headers, expect_errors=True)
657+
assert resp.status_code == 422
658+
659+
def test_jobs_datetime_interval_invalid(self):
660+
"""
661+
.. seealso::
662+
- `/req/collections/rc-time-collections-response
663+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
664+
665+
datetime_invalid represents a datetime interval where the limit dates are inverted,
666+
the minimun is greather than the maximum datetime limit
667+
"""
668+
datetime_interval = self.datetime_interval[3] + DATETIME_INTERVAL_CLOSED_SYMBOL + self.datetime_interval[1]
669+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_interval)
670+
resp = self.app.get(path, headers=self.json_headers, expect_errors=True)
671+
assert resp.status_code == 422
672+
673+
def test_jobs_datetime_before_invalid(self):
674+
"""
675+
.. seealso::
676+
- `/req/collections/rc-time-collections-response
677+
<https://github.com/opengeospatial/ogcapi-common/blob/master/collections/requirements/collections/REQ_rc-time-collections-response.adoc>`_
678+
679+
datetime_before represents a bad open range datetime interval
680+
"""
681+
datetime_before = "./" + self.datetime_interval[3]
682+
path = get_path_kvp(sd.jobs_service.path, datetime=datetime_before)
683+
resp = self.app.get(path, headers=self.json_headers, expect_errors=True)
684+
assert resp.status_code == 422

weaver/store/base.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
from typing import TYPE_CHECKING
33

44
if TYPE_CHECKING:
5+
import datetime
56
from typing import Any, Dict, List, Optional, Tuple, Union
67
from pyramid.request import Request
78
from pywps import Process as ProcessWPS
89
from weaver.datatype import Bill, Job, Process, Quote, Service
9-
from weaver.typedefs import AnyValue
10+
from weaver.typedefs import AnyValue, DatetimeIntervalType
1011

1112
JobListAndCount = Tuple[List[Job], int]
1213
JobCategory = Dict[str, Union[AnyValue, Job]]
@@ -111,6 +112,7 @@ def save_job(self,
111112
access=None, # type: Optional[str]
112113
notification_email=None, # type: Optional[str]
113114
accept_language=None, # type: Optional[str]
115+
created=None, # type: Optional[datetime.datetime]
114116
): # type: (...) -> Job
115117
raise NotImplementedError
116118

@@ -145,6 +147,7 @@ def find_jobs(self,
145147
sort=None, # type: Optional[str]
146148
page=0, # type: int
147149
limit=10, # type: int
150+
datetime=None, # type: Optional[DatetimeIntervalType]
148151
group_by=None, # type: Optional[Union[str, List[str]]]
149152
request=None, # type: Optional[Request]
150153
): # type: (...) -> Union[JobListAndCount, JobCategoriesAndCount]

weaver/store/mongodb.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@
4848
from weaver.wps.utils import get_wps_url
4949

5050
if TYPE_CHECKING:
51+
import datetime
5152
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
5253
from pymongo.collection import Collection
5354

54-
from weaver.store.base import JobCategoriesAndCount, JobListAndCount
55+
from weaver.store.base import DatetimeIntervalType, JobCategoriesAndCount, JobListAndCount
5556
from weaver.typedefs import AnyProcess, AnyProcessType
5657

5758
LOGGER = logging.getLogger(__name__)
@@ -405,6 +406,7 @@ def save_job(self,
405406
access=None, # type: Optional[str]
406407
notification_email=None, # type: Optional[str]
407408
accept_language=None, # type: Optional[str]
409+
created=None, # type: Optional[datetime.datetime]
408410
): # type: (...) -> Job
409411
"""
410412
Creates a new :class:`Job` and stores it in mongodb.
@@ -422,6 +424,7 @@ def save_job(self,
422424
tags.append(EXECUTE_MODE_SYNC)
423425
if not access:
424426
access = VISIBILITY_PRIVATE
427+
425428
new_job = Job({
426429
"task_id": task_id,
427430
"user_id": user_id,
@@ -432,7 +435,7 @@ def save_job(self,
432435
"execute_async": execute_async,
433436
"is_workflow": is_workflow,
434437
"is_local": is_local,
435-
"created": now(),
438+
"created": created if created else now(),
436439
"tags": list(set(tags)), # remove duplicates
437440
"access": access,
438441
"notification_email": notification_email,
@@ -499,6 +502,7 @@ def find_jobs(self,
499502
sort=None, # type: Optional[str]
500503
page=0, # type: int
501504
limit=10, # type: int
505+
datetime=None, # type: Optional[DatetimeIntervalType]
502506
group_by=None, # type: Optional[Union[str, List[str]]]
503507
request=None, # type: Optional[Request]
504508
): # type: (...) -> Union[JobListAndCount, JobCategoriesAndCount]
@@ -515,6 +519,7 @@ def find_jobs(self,
515519
:param sort: field which is used for sorting results (default: creation date, descending).
516520
:param page: page number to return when using result paging (only when not using ``group_by``).
517521
:param limit: number of jobs per page when using result paging (only when not using ``group_by``).
522+
:param datetime: field used for filtering data by creation date with a given date or interval of date.
518523
:param group_by: one or many fields specifying categories to form matching groups of jobs (paging disabled).
519524
520525
:returns: (list of jobs matching paging OR list of {categories, list of jobs, count}) AND total of matched job
@@ -582,6 +587,20 @@ def find_jobs(self,
582587
if service is not None:
583588
search_filters["service"] = service
584589

590+
if datetime is not None:
591+
query = {}
592+
593+
if datetime.get("after", False):
594+
query["$gte"] = datetime["after"]
595+
596+
if datetime.get("before", False):
597+
query["$lte"] = datetime["before"]
598+
599+
if datetime.get("match", False):
600+
query = datetime["match"]
601+
602+
search_filters["created"] = query
603+
585604
if sort is None:
586605
sort = SORT_CREATED
587606
elif sort == SORT_USER:

weaver/typedefs.py

+5
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,8 @@
114114

115115
# update_status(provider, message, progress, status)
116116
UpdateStatusPartialFunction = Callable[[str, str, int, AnyStatusType], None]
117+
118+
# others
119+
DatetimeIntervalType = TypedDict("DatetimeIntervalType",
120+
{"before": str, "after": str,
121+
"match": str, }, total=False)

0 commit comments

Comments
 (0)