Skip to content

Commit 269a2a4

Browse files
Merge branch 'main' into mikehgrantsgov/3536-add-saved-notifications-to-job
2 parents 7e1fd70 + 73b32a7 commit 269a2a4

File tree

71 files changed

+6869
-9761
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+6869
-9761
lines changed

.github/workflows/ci-frontend-e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
- name: Start API Server for e2e tests
4747
run: |
4848
cd ../api
49+
echo "ENABLE_OPPORTUNITY_ATTACHMENT_PIPELINE=false" >> override.env
4950
make init db-seed-local populate-search-opportunities start &
5051
cd ../frontend
5152
# Ensure the API wait script is executable

api/openapi.generated.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,6 +1327,18 @@ components:
13271327
- string
13281328
- 'null'
13291329
format: date
1330+
start_date_relative:
1331+
type:
1332+
- integer
1333+
- 'null'
1334+
minimum: -1000000
1335+
maximum: 1000000
1336+
end_date_relative:
1337+
type:
1338+
- integer
1339+
- 'null'
1340+
minimum: -1000000
1341+
maximum: 1000000
13301342
CloseDateFilterV1:
13311343
type: object
13321344
properties:
@@ -1340,6 +1352,18 @@ components:
13401352
- string
13411353
- 'null'
13421354
format: date
1355+
start_date_relative:
1356+
type:
1357+
- integer
1358+
- 'null'
1359+
minimum: -1000000
1360+
maximum: 1000000
1361+
end_date_relative:
1362+
type:
1363+
- integer
1364+
- 'null'
1365+
minimum: -1000000
1366+
maximum: 1000000
13431367
OpportunitySearchFilterV1:
13441368
type: object
13451369
properties:

api/src/adapters/search/opensearch_query_builder.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,19 @@ def filter_int_range(
194194
self.filters.append({"range": {field: range_filter}})
195195
return self
196196

197+
def adjust_date_format(self, in_date: datetime.date | int | None) -> str | None:
198+
if in_date is None:
199+
return None
200+
if isinstance(in_date, int):
201+
return f"now{in_date:+}d"
202+
203+
return in_date.isoformat()
204+
197205
def filter_date_range(
198-
self, field: str, start_date: datetime.date | None, end_date: datetime.date | None
206+
self,
207+
field: str,
208+
start_date: datetime.date | int | None,
209+
end_date: datetime.date | int | None,
199210
) -> typing.Self:
200211
"""
201212
For a given field, filter results to a range of dates.
@@ -207,13 +218,16 @@ def filter_date_range(
207218
a binary filter on the overall results.
208219
"""
209220
if start_date is None and end_date is None:
210-
raise ValueError("Cannot use date range filter if both start and end are None")
221+
raise ValueError("Cannot use date range filter if both start and end dates are None")
222+
223+
start_date_str = self.adjust_date_format(start_date)
224+
end_date_str = self.adjust_date_format(end_date)
211225

212226
range_filter = {}
213-
if start_date is not None:
214-
range_filter["gte"] = start_date.isoformat()
215-
if end_date is not None:
216-
range_filter["lte"] = end_date.isoformat()
227+
if start_date_str is not None:
228+
range_filter["gte"] = start_date_str
229+
if end_date_str is not None:
230+
range_filter["lte"] = end_date_str
217231

218232
self.filters.append({"range": {field: range_filter}})
219233
return self

api/src/api/schemas/search_schema.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,14 @@ class DateSearchSchemaBuilder(BaseSearchSchemaBuilder):
293293
def with_date_range(self) -> "DateSearchSchemaBuilder":
294294
self.schema_fields["start_date"] = fields.Date(allow_none=True)
295295
self.schema_fields["end_date"] = fields.Date(allow_none=True)
296+
297+
self.schema_fields["start_date_relative"] = fields.Integer(
298+
allow_none=True, validate=[validators.Range(min=-1000000, max=1000000)]
299+
)
300+
self.schema_fields["end_date_relative"] = fields.Integer(
301+
allow_none=True, validate=[validators.Range(min=-1000000, max=1000000)]
302+
)
303+
296304
self._with_date_range_validator()
297305

298306
return self
@@ -302,16 +310,38 @@ def _with_date_range_validator(self) -> "DateSearchSchemaBuilder":
302310
# rules that go across fields in the validation
303311
@validates_schema
304312
def validate_date_range(_: Any, data: dict, **kwargs: Any) -> None:
313+
305314
start_date = data.get("start_date", None)
306315
end_date = data.get("end_date", None)
307316

308-
# Error if start and end date are None (either explicitly set, or because they are missing)
309-
if start_date is None and end_date is None:
317+
start_date_relative = data.get("start_date_relative", None)
318+
end_date_relative = data.get("end_date_relative", None)
319+
320+
# Error if both relative date and absolute date provided for either start or end date
321+
if ("start_date" in data and "start_date_relative" in data) or (
322+
"end_date" in data and "end_date_relative" in data
323+
):
324+
raise ValidationError(
325+
[
326+
MarshmallowErrorContainer(
327+
ValidationErrorType.INVALID,
328+
"Cannot have both absolute and relative start/end date.",
329+
)
330+
]
331+
)
332+
333+
# Error if both start and end date for either relative or absolute date are None (either explicitly set, or because they are missing)
334+
if (
335+
start_date is None
336+
and end_date is None
337+
and start_date_relative is None
338+
and end_date_relative is None
339+
):
310340
raise ValidationError(
311341
[
312342
MarshmallowErrorContainer(
313343
ValidationErrorType.REQUIRED,
314-
"At least one of start_date or end_date must be provided.",
344+
"At least one of start_date/start_date_relative or end_date/end_date_relative must be provided.",
315345
)
316346
]
317347
)

api/src/data_migration/transformation/subtask/transform_agency.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
# These fields were only found in the test environment
7474
"ASSISTCompatible",
7575
"SAMValidation",
76+
# This was added in Jan 2025 in Grants.gov, we aren't using it yet
77+
"AllowSubmitWithExpSAM",
7678
}
7779

7880
REQUIRED_FIELDS = {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""add searched_opportunity_ids_and_last_notified_at to user_saved_opportunity
2+
3+
Revision ID: 9e7fc937646a
4+
Revises: dc04ce955a9a
5+
Create Date: 2025-01-28 19:06:19.397240
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "9e7fc937646a"
15+
down_revision = "dc04ce955a9a"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.add_column(
23+
"user_saved_search",
24+
sa.Column(
25+
"last_notified_at",
26+
sa.TIMESTAMP(timezone=True),
27+
server_default=sa.text("now()"),
28+
nullable=False,
29+
),
30+
schema="api",
31+
)
32+
op.add_column(
33+
"user_saved_search",
34+
sa.Column("searched_opportunity_ids", postgresql.ARRAY(sa.BigInteger()), nullable=False),
35+
schema="api",
36+
)
37+
# ### end Alembic commands ###
38+
39+
40+
def downgrade():
41+
# ### commands auto generated by Alembic - please adjust! ###
42+
op.drop_column("user_saved_search", "searched_opportunity_ids", schema="api")
43+
op.drop_column("user_saved_search", "last_notified_at", schema="api")
44+
# ### end Alembic commands ###

api/src/db/models/user_models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
from datetime import datetime
33

44
from sqlalchemy import BigInteger, ForeignKey
5-
from sqlalchemy.dialects.postgresql import JSONB, UUID
5+
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
66
from sqlalchemy.orm import Mapped, mapped_column, relationship
7+
from sqlalchemy.sql.functions import now as sqlnow
78

89
from src.adapters.db.type_decorators.postgres_type_decorators import LookupColumn
910
from src.constants.lookup_constants import ExternalUserType
1011
from src.db.models.base import ApiSchemaTable, TimestampMixin
1112
from src.db.models.lookup_models import LkExternalUserType
1213
from src.db.models.opportunity_models import Opportunity
14+
from src.util import datetime_util
1315

1416

1517
class User(ApiSchemaTable, TimestampMixin):
@@ -106,6 +108,13 @@ class UserSavedSearch(ApiSchemaTable, TimestampMixin):
106108

107109
name: Mapped[str]
108110

111+
last_notified_at: Mapped[datetime] = mapped_column(
112+
nullable=False,
113+
default=datetime_util.utcnow,
114+
server_default=sqlnow(),
115+
)
116+
searched_opportunity_ids: Mapped[list[int]] = mapped_column(ARRAY(BigInteger))
117+
109118

110119
class UserNotificationLog(ApiSchemaTable, TimestampMixin):
111120
__tablename__ = "user_notification_log"

api/src/search/search_models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ class IntSearchFilter(BaseModel):
1919
class DateSearchFilter(BaseModel):
2020
start_date: date | None = None
2121
end_date: date | None = None
22+
start_date_relative: int | None = None
23+
end_date_relative: int | None = None

api/src/services/opportunities_v1/search_opportunities.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,21 @@ def _add_search_filters(
127127
builder.filter_int_range(field_name, field_filters.min, field_filters.max)
128128

129129
elif isinstance(field_filters, DateSearchFilter):
130-
builder.filter_date_range(field_name, field_filters.start_date, field_filters.end_date)
130+
start_date = (
131+
field_filters.start_date
132+
if field_filters.start_date
133+
else field_filters.start_date_relative
134+
)
135+
end_date = (
136+
field_filters.end_date
137+
if field_filters.end_date
138+
else field_filters.end_date_relative
139+
)
140+
builder.filter_date_range(
141+
field_name,
142+
start_date,
143+
end_date,
144+
)
131145

132146

133147
def _add_aggregations(builder: search.SearchQueryBuilder) -> None:

api/src/services/users/create_saved_search.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99

1010
def create_saved_search(db_session: db.Session, user_id: UUID, json_data: dict) -> UserSavedSearch:
1111
saved_search = UserSavedSearch(
12-
user_id=user_id, name=json_data["name"], search_query=json_data["search_query"]
12+
user_id=user_id,
13+
name=json_data["name"],
14+
search_query=json_data["search_query"],
15+
searched_opportunity_ids=[],
1316
)
1417

1518
db_session.add(saved_search)

api/tests/src/adapters/search/test_opensearch_query_builder.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -390,19 +390,32 @@ def test_query_builder_filter_terms(
390390
"start_date,end_date,expected_results",
391391
[
392392
# Date range that will include all results
393+
# Absolute
393394
(date(1900, 1, 1), date(2050, 1, 1), FULL_DATA),
395+
# Relative
396+
(-45656, 9131, FULL_DATA),
394397
# Start only date range that will get all results
398+
# Absolute
395399
(date(1950, 1, 1), None, FULL_DATA),
400+
# Relative
401+
(-45656, None, FULL_DATA),
396402
# End only date range that will get all results
403+
# Absolute
397404
(None, date(2025, 1, 1), FULL_DATA),
405+
# Relative
406+
(None, 9131, FULL_DATA),
398407
# Range that filters to just oldest
399408
(
400409
date(1950, 1, 1),
401410
date(1960, 1, 1),
402411
[FELLOWSHIP_OF_THE_RING, TWO_TOWERS, RETURN_OF_THE_KING],
403412
),
404413
# Unbounded range for oldest few
405-
(None, date(1990, 1, 1), [FELLOWSHIP_OF_THE_RING, TWO_TOWERS, RETURN_OF_THE_KING]),
414+
(
415+
None,
416+
date(1990, 1, 1),
417+
[FELLOWSHIP_OF_THE_RING, TWO_TOWERS, RETURN_OF_THE_KING],
418+
),
406419
# Unbounded range for newest few
407420
(date(2011, 8, 1), None, [WORDS_OF_RADIANCE, OATHBRINGER, RHYTHM_OF_WAR]),
408421
# Selecting a few in the middle
@@ -412,13 +425,24 @@ def test_query_builder_filter_terms(
412425
[WAY_OF_KINGS, FEAST_FOR_CROWS, DANCE_WITH_DRAGONS],
413426
),
414427
# Exact date
428+
# Absolute
415429
(date(1954, 7, 29), date(1954, 7, 29), [FELLOWSHIP_OF_THE_RING]),
430+
# Relative
431+
(-25747, -25747, []),
416432
# None fetched in range
433+
# Absolute
417434
(date(1981, 1, 1), date(1989, 1, 1), []),
435+
# Relative
436+
(-16093, -13171, []),
418437
],
419438
)
420439
def test_query_builder_filter_date_range(
421-
self, search_client, search_index, start_date, end_date, expected_results
440+
self,
441+
search_client,
442+
search_index,
443+
start_date,
444+
end_date,
445+
expected_results,
422446
):
423447
builder = (
424448
SearchQueryBuilder()
@@ -428,9 +452,13 @@ def test_query_builder_filter_date_range(
428452

429453
expected_ranges = {}
430454
if start_date is not None:
431-
expected_ranges["gte"] = start_date.isoformat()
455+
expected_ranges["gte"] = (
456+
f"now{start_date:+}d" if isinstance(start_date, int) else start_date.isoformat()
457+
)
432458
if end_date is not None:
433-
expected_ranges["lte"] = end_date.isoformat()
459+
expected_ranges["lte"] = (
460+
f"now{end_date:+}d" if isinstance(end_date, int) else end_date.isoformat()
461+
)
434462

435463
expected_query = {
436464
"size": 25,
@@ -509,7 +537,7 @@ def test_filter_int_range_both_none(self):
509537
with pytest.raises(ValueError, match="Cannot use int range filter"):
510538
SearchQueryBuilder().filter_int_range("test_field", None, None)
511539

512-
def test_filter_date_range_both_none(self):
540+
def test_filter_date_range_all_none(self):
513541
with pytest.raises(ValueError, match="Cannot use date range filter"):
514542
SearchQueryBuilder().filter_date_range("test_field", None, None)
515543

0 commit comments

Comments
 (0)