Skip to content

Commit 79e2b32

Browse files
authored
31183 - Update Suspended Accounts to include filtering and sorting (#3535)
1 parent 3955ea7 commit 79e2b32

File tree

22 files changed

+951
-294
lines changed

22 files changed

+951
-294
lines changed

auth-api/pyproject.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
[tool.poetry]
1+
[project]
22
name = "auth-api"
33
version = "3.0.9"
44
description = ""
5-
authors = ["\"BC Registries and Online Services\""]
5+
authors = [{ name = "BC Registries and Online Services" }]
66
readme = "README.md"
7+
requires-python = ">=3.12,<3.13"
8+
9+
[tool.poetry]
10+
packages = [
11+
{ include = "auth_api", from = "src" }
12+
]
713

814
[tool.poetry.dependencies]
9-
python = "^3.12"
15+
python = ">=3.12,<3.13"
1016
flask-cors = "^6.0.0"
1117
flask-migrate = "^4.0.7"
1218
flask-moment = "^1.0.6"

auth-api/src/auth_api/models/custom_query.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,24 @@ def filter_conditionally(self, search_criteria, model_attribute, is_like: bool =
4747

4848
return self.filter(model_attribute == search_criteria)
4949

50-
def filter_conditional_date_range(self, start_date: date, end_date: date, model_attribute):
50+
def filter_conditional_date_range(self, start_date: date, end_date: date, model_attribute, cast_to_date=True):
5151
"""Add query filter for a date range if present."""
5252
# Dates in DB are stored as UTC, you may need to take into account timezones and adjust the input dates
5353
# depending on the needs
5454
query = self
5555

5656
if start_date and end_date:
57-
return query.filter(and_(func.DATE(model_attribute) >= start_date, func.DATE(model_attribute) <= end_date))
57+
return query.filter(
58+
and_(
59+
func.DATE(model_attribute) if cast_to_date else model_attribute >= start_date,
60+
func.DATE(model_attribute) if cast_to_date else model_attribute <= end_date,
61+
)
62+
)
5863

5964
if start_date:
60-
query = query.filter(func.DATE(model_attribute) >= start_date)
65+
query = query.filter(func.DATE(model_attribute) if cast_to_date else model_attribute >= start_date)
6166

6267
if end_date:
63-
query = query.filter(func.DATE(model_attribute) <= end_date)
68+
query = query.filter(func.DATE(model_attribute) if cast_to_date else model_attribute <= end_date)
6469

6570
return query

auth-api/src/auth_api/models/dataclass.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ class OrgSearch: # pylint: disable=too-many-instance-attributes
147147
org_type: str
148148
include_members: bool
149149
member_search_text: str
150+
suspended_date_from: str
151+
suspended_date_to: str
152+
suspension_reason_code: str
150153
page: int
151154
limit: int
152155

auth-api/src/auth_api/models/org.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from auth_api.exceptions.errors import Error
2929
from auth_api.models.affiliation import Affiliation
3030
from auth_api.models.dataclass import OrgSearch
31+
from auth_api.utils.date import str_to_utc_dt
3132
from auth_api.utils.enums import AccessType, InvitationStatus, InvitationType
3233
from auth_api.utils.enums import OrgStatus as OrgStatusEnum
3334
from auth_api.utils.roles import EXCLUDED_FIELDS, INVALID_ORG_CREATE_TYPE_CODES, VALID_STATUSES
@@ -193,6 +194,17 @@ def search_org(cls, search: OrgSearch):
193194
query = cls._search_by_business_identifier(query, search.business_identifier)
194195
query = cls._search_for_statuses(query, search.statuses)
195196

197+
start_date = None
198+
end_date = None
199+
if search.suspended_date_from:
200+
start_date = str_to_utc_dt(search.suspended_date_from, False)
201+
if search.suspended_date_to:
202+
end_date = str_to_utc_dt(search.suspended_date_to, True)
203+
if start_date or end_date:
204+
query = query.filter_conditional_date_range(start_date, end_date, Org.suspended_on, cast_to_date=False)
205+
if search.suspension_reason_code:
206+
query = query.filter(Org.suspension_reason_code == search.suspension_reason_code)
207+
196208
query = cls.get_order_by(search, query)
197209
pagination = query.paginate(per_page=search.limit, page=search.page)
198210

@@ -205,6 +217,11 @@ def get_order_by(cls, search, query):
205217
if search.id:
206218
return query.order_by(desc(Org.id == int(search.id or -1)), Org.created.desc())
207219

220+
if search.statuses and (
221+
OrgStatusEnum.SUSPENDED.value in search.statuses or OrgStatusEnum.NSF_SUSPENDED.value in search.statuses
222+
):
223+
return query.order_by(desc(Org.suspended_on), Org.created.desc())
224+
208225
return query.order_by(Org.created.desc())
209226

210227
@classmethod

auth-api/src/auth_api/models/task.py

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,14 @@
1313
# limitations under the License.
1414
"""This model manages a Task item in the Auth Service."""
1515

16-
import datetime as dt
1716
from typing import Self
1817

19-
import pytz
2018
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, text
2119
from sqlalchemy.dialects.postgresql import ARRAY
2220
from sqlalchemy.orm import relationship
2321

2422
from auth_api.models.dataclass import TaskSearch
23+
from auth_api.utils.date import str_to_utc_dt
2524
from auth_api.utils.enums import TaskRelationshipStatus, TaskRelationshipType, TaskStatus
2625

2726
from .base_model import BaseModel
@@ -67,14 +66,14 @@ def fetch_tasks(cls, task_search: TaskSearch):
6766
query = query.filter(Task.type == task_search.type)
6867
if task_search.status:
6968
query = query.filter(Task.status.in_(task_search.status))
69+
start_date = None
70+
end_date = None
7071
if task_search.start_date:
71-
# convert PST start_date to UTC then filter
72-
start_date_utc = cls._str_to_utc_dt(task_search.start_date, False)
73-
query = query.filter(Task.date_submitted >= start_date_utc)
72+
start_date = str_to_utc_dt(task_search.start_date, False)
7473
if task_search.end_date:
75-
# convert PST end_date to UTC then set time to end of day then filter
76-
end_date_utc = cls._str_to_utc_dt(task_search.end_date, True)
77-
query = query.filter(Task.date_submitted <= end_date_utc)
74+
end_date = str_to_utc_dt(task_search.end_date, True)
75+
if start_date or end_date:
76+
query = query.filter_conditional_date_range(start_date, end_date, Task.date_submitted, cast_to_date=False)
7877
if task_search.relationship_status:
7978
query = query.filter(Task.relationship_status == task_search.relationship_status)
8079
if task_search.modified_by:
@@ -149,15 +148,3 @@ def find_by_user_and_status(cls, org_id: int, status):
149148
.filter_by(account_id=int(org_id or -1), relationship_type=TaskRelationshipType.USER.value, status=status)
150149
.first()
151150
)
152-
153-
@classmethod
154-
def _str_to_utc_dt(cls, date: str, add_time: bool):
155-
"""Convert ISO formatted dates into dateTime objects in UTC."""
156-
time_zone = pytz.timezone("Canada/Pacific")
157-
naive_dt = dt.datetime.strptime(date, "%Y-%m-%d")
158-
local_dt = time_zone.localize(naive_dt, is_dst=None)
159-
if add_time:
160-
local_dt = dt.datetime(local_dt.year, local_dt.month, local_dt.day, 23, 59, 59)
161-
utc_dt = local_dt.astimezone(pytz.utc)
162-
163-
return utc_dt

auth-api/src/auth_api/resources/v1/org.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def search_organizations():
7777
request.args.get("orgType", None),
7878
string_to_bool(request.args.get("includeMembers", "False")),
7979
request.args.get("members", None),
80+
request.args.get("suspendedDateFrom", None),
81+
request.args.get("suspendedDateTo", None),
82+
request.args.get("suspensionReasonCode", None),
8083
int(request.args.get("page", 1)),
8184
int(request.args.get("limit", 10)),
8285
)

auth-api/src/auth_api/services/simple_org.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,5 @@ def get_order_by(cls, search, query):
9393
# If searching by id, surface the perfect matches to the top
9494
if search.id:
9595
return query.order_by(desc(OrgModel.id == int(search.id)), OrgModel.created.desc())
96+
9697
return query.order_by(OrgModel.name)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright © 2025 Province of British Columbia
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Date utility functions."""
15+
16+
import datetime as dt
17+
18+
import pytz
19+
20+
21+
def str_to_utc_dt(date: str, add_time: bool):
22+
"""Convert ISO formatted dates into dateTime objects in UTC."""
23+
time_zone = pytz.timezone("Canada/Pacific")
24+
naive_dt = dt.datetime.strptime(date, "%Y-%m-%d")
25+
local_dt = time_zone.localize(naive_dt, is_dst=None)
26+
if add_time:
27+
local_dt = dt.datetime(local_dt.year, local_dt.month, local_dt.day, 23, 59, 59)
28+
utc_dt = local_dt.astimezone(pytz.utc)
29+
30+
return utc_dt

auth-api/tests/unit/api/test_org.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import json
2121
import uuid
22-
from datetime import datetime
22+
from datetime import datetime, timedelta
2323
from http import HTTPStatus
2424
from unittest.mock import patch
2525

@@ -3005,3 +3005,102 @@ def test_search_org_members(client, jwt, session, keycloak_mock): # pylint:disa
30053005
)
30063006
dictionary = json.loads(rv.data)
30073007
assert not dictionary["orgs"]
3008+
3009+
3010+
def test_search_org_suspended_filters(client, jwt, session, keycloak_mock): # pylint:disable=unused-argument
3011+
"""Assert that orgs can be searched by suspended date and suspension reason code."""
3012+
headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_manage_accounts_role)
3013+
3014+
today = datetime.now()
3015+
yesterday = today - timedelta(days=1)
3016+
last_week = today - timedelta(days=7)
3017+
3018+
org1 = factory_org_model(org_info=TestOrgInfo.org1)
3019+
org1.status_code = OrgStatus.SUSPENDED.value
3020+
org1.suspended_on = yesterday
3021+
org1.suspension_reason_code = SuspensionReasonCode.OWNER_CHANGE.name
3022+
org1.save()
3023+
3024+
org2 = factory_org_model(org_info=TestOrgInfo.org2)
3025+
org2.status_code = OrgStatus.SUSPENDED.value
3026+
org2.suspended_on = last_week
3027+
org2.suspension_reason_code = SuspensionReasonCode.DISPUTE.name
3028+
org2.save()
3029+
3030+
org3 = factory_org_model(org_info=TestOrgInfo.org_with_mailing_address())
3031+
org3.status_code = OrgStatus.SUSPENDED.value
3032+
org3.suspended_on = today
3033+
org3.suspension_reason_code = SuspensionReasonCode.OWNER_CHANGE.name
3034+
org3.save()
3035+
3036+
rv = client.get(
3037+
f"/api/v1/orgs?status=SUSPENDED&suspensionReasonCode={SuspensionReasonCode.OWNER_CHANGE.name}",
3038+
headers=headers,
3039+
content_type="application/json",
3040+
)
3041+
assert rv.status_code == HTTPStatus.OK
3042+
orgs = json.loads(rv.data)
3043+
assert orgs.get("total") == 2
3044+
org_ids = [org.get("id") for org in orgs.get("orgs")]
3045+
assert org1.id in org_ids
3046+
assert org3.id in org_ids
3047+
assert org2.id not in org_ids
3048+
3049+
rv = client.get(
3050+
f"/api/v1/orgs?status=SUSPENDED&suspensionReasonCode={SuspensionReasonCode.DISPUTE.name}",
3051+
headers=headers,
3052+
content_type="application/json",
3053+
)
3054+
assert rv.status_code == HTTPStatus.OK
3055+
orgs = json.loads(rv.data)
3056+
assert orgs.get("total") == 1
3057+
assert orgs.get("orgs")[0].get("id") == org2.id
3058+
3059+
rv = client.get(
3060+
f"/api/v1/orgs?status=SUSPENDED&suspendedDateFrom={yesterday.strftime('%Y-%m-%d')}",
3061+
headers=headers,
3062+
content_type="application/json",
3063+
)
3064+
assert rv.status_code == HTTPStatus.OK
3065+
orgs = json.loads(rv.data)
3066+
assert orgs.get("total") == 2
3067+
org_ids = [org.get("id") for org in orgs.get("orgs")]
3068+
assert org1.id in org_ids
3069+
assert org3.id in org_ids
3070+
assert org2.id not in org_ids
3071+
3072+
rv = client.get(
3073+
f"/api/v1/orgs?status=SUSPENDED&suspendedDateTo={yesterday.strftime('%Y-%m-%d')}",
3074+
headers=headers,
3075+
content_type="application/json",
3076+
)
3077+
assert rv.status_code == HTTPStatus.OK
3078+
orgs = json.loads(rv.data)
3079+
assert orgs.get("total") == 2
3080+
org_ids = [org.get("id") for org in orgs.get("orgs")]
3081+
assert org1.id in org_ids
3082+
assert org2.id in org_ids
3083+
assert org3.id not in org_ids
3084+
3085+
rv = client.get(
3086+
f"/api/v1/orgs?status=SUSPENDED&suspendedDateFrom={last_week.strftime('%Y-%m-%d')}&suspendedDateTo={yesterday.strftime('%Y-%m-%d')}",
3087+
headers=headers,
3088+
content_type="application/json",
3089+
)
3090+
assert rv.status_code == HTTPStatus.OK
3091+
orgs = json.loads(rv.data)
3092+
assert orgs.get("total") == 2
3093+
org_ids = [org.get("id") for org in orgs.get("orgs")]
3094+
assert org1.id in org_ids
3095+
assert org2.id in org_ids
3096+
assert org3.id not in org_ids
3097+
3098+
rv = client.get(
3099+
f"/api/v1/orgs?status=SUSPENDED&suspendedDateFrom={last_week.strftime('%Y-%m-%d')}&suspendedDateTo={yesterday.strftime('%Y-%m-%d')}&suspensionReasonCode={SuspensionReasonCode.OWNER_CHANGE.name}",
3100+
headers=headers,
3101+
content_type="application/json",
3102+
)
3103+
assert rv.status_code == HTTPStatus.OK
3104+
orgs = json.loads(rv.data)
3105+
assert orgs.get("total") == 1
3106+
assert orgs.get("orgs")[0].get("id") == org1.id

auth-web/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)