Skip to content

Commit 6b9e7d8

Browse files
authored
Add subscriptions graphql schema (#275)
* Add subscriptions graphql schema - use processes_resolver in subscription schema to resolve processes of a subscription. - use subscriptions_resolver in processes schema to resolve subscriptions of a process. - add subscriptions db filters and sorting. - add generic filters to remove duplicated code. * Add depends_on and in_use_by queries to the subscription query * Improve date filtering to be less trict with full date * fix pipeline issues * Add graphql tests for subscriptions and update processes tests * Improve db generic range filter * Rename filter functions by column for each filter resource * Remove customerId in graphql resources and filtering. also remove it from tests. add filter_by to new variable instead of overwriting it. * add subscriptions resource with resolver to products resource fix generic_values_in_column_filter by lowercasing the column value. * Remove ye and insync from the generic bool filter * Remove customer id filter test from processes filterable endpoint * Improve range filter with use of partial functool * Remove unnecessary _range from resolvers * Rename resolve_subscription to resolve_subscriptions - improve descriptions of relation fields. - update single subscription tests to check product blocks.
1 parent 32735ec commit 6b9e7d8

28 files changed

+1518
-150
lines changed

orchestrator/.stignore

Whitespace-only changes.

orchestrator/db/filters/filters.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019-2020 SURF.
1+
# Copyright 2019-2023 SURF.
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -53,7 +53,7 @@ def _apply_filters(
5353
query: QueryType, filter_by: Iterator[Filter], handle_filter_error: CallableErrorHander
5454
) -> QueryType:
5555
for item in filter_by:
56-
field = item.field.lower()
56+
field = item.field
5757
filter_fn = valid_filter_functions_by_column[field]
5858
try:
5959
query = filter_fn(query, item.value)
@@ -63,6 +63,12 @@ def _apply_filters(
6363
field=field,
6464
value=item.value,
6565
)
66+
except ValueError as exception:
67+
handle_filter_error(
68+
str(exception),
69+
field=field,
70+
value=item.value,
71+
)
6672
return query
6773

6874
return _apply_filters
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from orchestrator.db.filters.generic_filters.bool_filter import generic_bool_filter
2+
from orchestrator.db.filters.generic_filters.is_like_filter import generic_is_like_filter
3+
from orchestrator.db.filters.generic_filters.range_filter import (
4+
RANGE_TYPES,
5+
convert_to_date,
6+
convert_to_int,
7+
generic_range_filter,
8+
generic_range_filters,
9+
get_filter_value_convert_function,
10+
)
11+
from orchestrator.db.filters.generic_filters.values_in_column_filter import generic_values_in_column_filter
12+
13+
__all__ = [
14+
"RANGE_TYPES",
15+
"convert_to_date",
16+
"convert_to_int",
17+
"get_filter_value_convert_function",
18+
"generic_range_filter",
19+
"generic_range_filters",
20+
"generic_is_like_filter",
21+
"generic_values_in_column_filter",
22+
"generic_bool_filter",
23+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2019-2023 SURF.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from typing import Callable
15+
16+
from sqlalchemy import Column
17+
18+
from orchestrator.db.database import SearchQuery
19+
20+
21+
def generic_bool_filter(field: Column) -> Callable[[SearchQuery, str], SearchQuery]:
22+
def bool_filter(query: SearchQuery, value: str) -> SearchQuery:
23+
value_as_bool = value.lower() in ("yes", "y", "true", "1", "ja", "j")
24+
return query.filter(field.is_(value_as_bool))
25+
26+
return bool_filter
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2019-2023 SURF.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
15+
from typing import Callable
16+
17+
from sqlalchemy import Column, String, cast
18+
19+
from orchestrator.db.database import SearchQuery
20+
21+
22+
def generic_is_like_filter(field: Column) -> Callable[[SearchQuery, str], SearchQuery]:
23+
def like_filter(query: SearchQuery, value: str) -> SearchQuery:
24+
return query.filter(cast(field, String).ilike("%" + value + "%"))
25+
26+
return like_filter
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright 2019-2023 SURF.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
from datetime import datetime
14+
from functools import partial
15+
from typing import Callable, Optional
16+
17+
import pytz
18+
from dateutil.parser import parse
19+
from sqlalchemy import Column
20+
21+
from orchestrator.db.database import SearchQuery
22+
23+
# from sqlalchemy.sql.expression import ColumnOperators
24+
from orchestrator.utils.helpers import to_camel
25+
26+
RANGE_TYPES = {
27+
"gt": Column.__gt__,
28+
"gte": Column.__ge__,
29+
"lt": Column.__lt__,
30+
"lte": Column.__le__,
31+
"ne": Column.__ne__,
32+
}
33+
34+
35+
def convert_to_date(value: str) -> datetime:
36+
"""Parse iso 8601 date from string to datetime.
37+
38+
Example date: "2022-07-21T03:40:48+00:00"
39+
"""
40+
try:
41+
return parse(value).replace(tzinfo=pytz.UTC)
42+
except ValueError:
43+
raise ValueError(f"{value} is not a valid date")
44+
45+
46+
def convert_to_int(value: str) -> int:
47+
try:
48+
return int(value)
49+
except ValueError:
50+
raise ValueError(f"{value} is not a valid integer")
51+
52+
53+
def get_filter_value_convert_function(field: Column) -> Callable:
54+
if field.type.python_type == datetime:
55+
return convert_to_date
56+
if field.type.python_type == int:
57+
return convert_to_int
58+
return lambda x: x
59+
60+
61+
def generic_range_filter(range_type_fn: Callable, field: Column) -> Callable[[SearchQuery, str], SearchQuery]:
62+
filter_operator = partial(range_type_fn, field)
63+
convert_filter_value = get_filter_value_convert_function(field)
64+
65+
def use_filter(query: SearchQuery, value: str) -> SearchQuery:
66+
converted_value = convert_filter_value(value)
67+
return query.filter(filter_operator(converted_value))
68+
69+
return use_filter
70+
71+
72+
def generic_range_filters(
73+
column: Column, column_alias: Optional[str] = None
74+
) -> dict[str, Callable[[SearchQuery, str], SearchQuery]]:
75+
column_name = to_camel(column_alias or column.name)
76+
77+
return {
78+
f"{column_name}{operator.capitalize()}": generic_range_filter(operator_fn, column)
79+
for operator, operator_fn in RANGE_TYPES.items()
80+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright 2019-2023 SURF.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
15+
from typing import Callable
16+
17+
from sqlalchemy import Column, func
18+
19+
from orchestrator.db.database import SearchQuery
20+
21+
22+
def generic_values_in_column_filter(field: Column) -> Callable[[SearchQuery, str], SearchQuery]:
23+
def list_filter(query: SearchQuery, value: str) -> SearchQuery:
24+
values = [s.lower() for s in value.split("-")]
25+
return query.filter(func.lower(field).in_(values))
26+
27+
return list_filter

orchestrator/db/filters/process.py

Lines changed: 22 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019-2020 SURF.
1+
# Copyright 2019-2023 SURF.
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -11,65 +11,22 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14-
from http import HTTPStatus
1514
from typing import Callable
16-
from uuid import UUID
1715

1816
import structlog
19-
from sqlalchemy import String, cast
2017

21-
from orchestrator.api.error_handling import raise_status
2218
from orchestrator.db import ProcessSubscriptionTable, ProcessTable, ProductTable, SubscriptionTable, db
2319
from orchestrator.db.database import SearchQuery
2420
from orchestrator.db.filters.filters import generic_filter
21+
from orchestrator.db.filters.generic_filters import (
22+
generic_bool_filter,
23+
generic_is_like_filter,
24+
generic_values_in_column_filter,
25+
)
2526

2627
logger = structlog.get_logger(__name__)
2728

2829

29-
def pid_filter(query: SearchQuery, value: str) -> SearchQuery:
30-
return query.filter(cast(ProcessTable.pid, String).ilike("%" + value + "%"))
31-
32-
33-
def is_task_filter(query: SearchQuery, value: str) -> SearchQuery:
34-
value_as_bool = value.lower() in ("yes", "y", "ye", "true", "1", "ja")
35-
return query.filter(ProcessTable.is_task.is_(value_as_bool))
36-
37-
38-
def assignee_filter(query: SearchQuery, value: str) -> SearchQuery:
39-
assignees = value.split("-")
40-
return query.filter(ProcessTable.assignee.in_(assignees))
41-
42-
43-
def status_filter(query: SearchQuery, value: str) -> SearchQuery:
44-
statuses = value.split("-")
45-
return query.filter(ProcessTable.last_status.in_(statuses))
46-
47-
48-
def workflow_filter(query: SearchQuery, value: str) -> SearchQuery:
49-
return query.filter(ProcessTable.workflow.ilike("%" + value + "%"))
50-
51-
52-
def creator_filter(query: SearchQuery, value: str) -> SearchQuery:
53-
return query.filter(ProcessTable.created_by.ilike("%" + value + "%"))
54-
55-
56-
def organisation_filter(query: SearchQuery, value: str) -> SearchQuery:
57-
try:
58-
value_as_uuid = UUID(value)
59-
except (ValueError, AttributeError):
60-
msg = f"Not a valid organisation, must be a UUID: '{value}'"
61-
logger.debug(msg)
62-
raise_status(HTTPStatus.BAD_REQUEST, msg)
63-
64-
process_subscriptions = (
65-
db.session.query(ProcessSubscriptionTable)
66-
.join(SubscriptionTable)
67-
.filter(SubscriptionTable.customer_id == value_as_uuid)
68-
.subquery()
69-
)
70-
return query.filter(ProcessTable.pid == process_subscriptions.c.pid)
71-
72-
7330
def product_filter(query: SearchQuery, value: str) -> SearchQuery:
7431
process_subscriptions = (
7532
db.session.query(ProcessSubscriptionTable)
@@ -101,6 +58,13 @@ def subscriptions_filter(query: SearchQuery, value: str) -> SearchQuery:
10158
return query.filter(ProcessTable.pid == process_subscriptions.c.pid)
10259

10360

61+
def subscription_id_filter(query: SearchQuery, value: str) -> SearchQuery:
62+
process_subscriptions = db.session.query(ProcessSubscriptionTable).join(SubscriptionTable)
63+
process_subscriptions = generic_is_like_filter(SubscriptionTable.subscription_id)(process_subscriptions, value)
64+
process_subscriptions = process_subscriptions.subquery()
65+
return query.filter(ProcessTable.pid == process_subscriptions.c.pid)
66+
67+
10468
def target_filter(query: SearchQuery, value: str) -> SearchQuery:
10569
targets = value.split("-")
10670
process_subscriptions = (
@@ -111,19 +75,19 @@ def target_filter(query: SearchQuery, value: str) -> SearchQuery:
11175
return query.filter(ProcessTable.pid == process_subscriptions.c.pid)
11276

11377

114-
VALID_FILTER_FUNCTIONS_BY_COLUMN: dict[str, Callable[[SearchQuery, str], SearchQuery]] = {
115-
"pid": pid_filter,
116-
"istask": is_task_filter,
117-
"assignee": assignee_filter,
118-
"status": status_filter,
119-
"workflow": workflow_filter,
120-
"creator": creator_filter,
121-
"organisation": organisation_filter,
78+
PROCESS_FILTER_FUNCTIONS_BY_COLUMN: dict[str, Callable[[SearchQuery, str], SearchQuery]] = {
79+
"pid": generic_is_like_filter(ProcessTable.pid),
80+
"istask": generic_bool_filter(ProcessTable.is_task),
81+
"assignee": generic_values_in_column_filter(ProcessTable.assignee),
82+
"status": generic_values_in_column_filter(ProcessTable.last_status),
83+
"workflow": generic_is_like_filter(ProcessTable.workflow),
84+
"creator": generic_is_like_filter(ProcessTable.created_by),
12285
"product": product_filter,
12386
"tag": tag_filter,
12487
"subscription": subscriptions_filter,
88+
"subscriptionId": subscription_id_filter,
12589
"target": target_filter,
12690
}
12791

12892

129-
filter_processes = generic_filter(VALID_FILTER_FUNCTIONS_BY_COLUMN)
93+
filter_processes = generic_filter(PROCESS_FILTER_FUNCTIONS_BY_COLUMN)

orchestrator/db/filters/product.py

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,29 @@
11
from typing import Callable
22

33
import structlog
4-
from sqlalchemy import String, cast
54

65
from orchestrator.db import ProductBlockTable, ProductTable
7-
from orchestrator.db.database import BaseModelMeta, SearchQuery
6+
from orchestrator.db.database import SearchQuery
87
from orchestrator.db.filters import generic_filter
8+
from orchestrator.db.filters.generic_filters import generic_is_like_filter, generic_values_in_column_filter
99

1010
logger = structlog.get_logger(__name__)
1111

1212

13-
def field_filter(table: BaseModelMeta, field: str) -> Callable:
14-
def _field_filter(query: SearchQuery, value: str) -> SearchQuery:
15-
logger.debug("Called _field_filter(...)", query=query, table=table, field=field, value=value)
16-
return query.filter(getattr(table, field).ilike("%" + value + "%"))
17-
18-
return _field_filter
19-
20-
21-
def list_filter(table: BaseModelMeta, field: str) -> Callable:
22-
def _list_filter(query: SearchQuery, value: str) -> SearchQuery:
23-
values = value.split("-")
24-
return query.filter(getattr(table, field).in_(values))
25-
26-
return _list_filter
27-
28-
29-
def id_filter(query: SearchQuery, value: str) -> SearchQuery:
30-
return query.filter(cast(ProductTable.product_id, String).ilike("%" + value + "%"))
31-
32-
3313
def product_block_filter(query: SearchQuery, value: str) -> SearchQuery:
3414
"""Filter ProductBlocks by '-'-separated list of Product block 'name' (column) values."""
3515
blocks = value.split("-")
3616
return query.filter(ProductTable.product_blocks.any(ProductBlockTable.name.in_(blocks)))
3717

3818

39-
# TODO
40-
# def date_filter(start: str, end: Optional[str]) -> None:
41-
# pass
42-
43-
44-
VALID_FILTER_FUNCTIONS_BY_COLUMN: dict[str, Callable[[SearchQuery, str], SearchQuery]] = {
45-
"product_id": id_filter,
46-
"name": field_filter(ProductTable, "name"),
47-
"description": field_filter(ProductTable, "description"),
48-
"product_type": field_filter(ProductTable, "product_type"),
49-
"status": list_filter(ProductTable, "status"),
50-
"tag": list_filter(ProductTable, "tag"),
19+
PRODUCT_FILTER_FUNCTIONS_BY_COLUMN: dict[str, Callable[[SearchQuery, str], SearchQuery]] = {
20+
"product_id": generic_is_like_filter(ProductTable.product_id),
21+
"name": generic_is_like_filter(ProductTable.name),
22+
"description": generic_is_like_filter(ProductTable.description),
23+
"product_type": generic_is_like_filter(ProductTable.product_type),
24+
"status": generic_values_in_column_filter(ProductTable.status),
25+
"tag": generic_values_in_column_filter(ProductTable.tag),
5126
"product_blocks": product_block_filter,
5227
}
5328

54-
filter_products = generic_filter(VALID_FILTER_FUNCTIONS_BY_COLUMN)
29+
filter_products = generic_filter(PRODUCT_FILTER_FUNCTIONS_BY_COLUMN)

0 commit comments

Comments
 (0)