Added a "Published in the last X days" filter, applied by the backend
GET /api/opportunities takes released_from, an RFC 3339 time, and lists only the opportunities whose latest release is at or after it. It works with paging and with changed_after, which keeps the same batch either way; an invalid value is 400. The frontend's filter defaults to 7 days, from midnight UK time, and sends it with every load and catch-up, so a year of opportunities isn't downloaded to show a week's. Changing the days loads the list again; Clear filters goes back to 7.
This commit is contained in:
1 parent
b8244da2e5
commit
746fa0a9c1
19 files changed
+333
-64
No files matched your search
@@ -110,7 +110,7 @@ ts, err := s.ListOpportunities(ctx, store.OpportunityFilter{
|
||||
})
|
||||
rs, err := s.ListReleases(ctx, uuid) // oldest first, with tags
|
||||
batch, err := s.LatestChangeBatch(ctx) // hash of the newest change batch, or ""
|
||||
ts, batch, err = s.ListChangedOpportunities(ctx, batch) // changed since; ErrUnknownBatch
|
||||
ts, batch, err = s.ListChangedOpportunities(ctx, batch, time.Time{}) // changed since; ErrUnknownBatch
|
||||
ok, err := s.ReleaseExists(ctx, uuid, releaseID)
|
||||
```
|
||||
|
||||
@@ -127,7 +127,7 @@ ok, err := s.ReleaseExists(ctx, uuid, releaseID)
|
||||
|
||||
**Listing.** `ListOpportunities` filters by status, classification code, title text, a range on `LatestReleaseDate` and each mark (`MarkSet` or `MarkUnset`; an opportunity with no marks stored has every mark unset), newest first, 50 per page by default. It loads classifications and marks but not releases (which can be large); those are loaded by `ListReleases` or `GetOpportunity`.
|
||||
|
||||
`LatestChangeBatch` returns the hash of the newest change batch ("" if there is none). Read before listing, it is the batch to give `ListChangedOpportunities`, which returns the opportunities in every later batch, once each, unpaged and ordered as `ListOpportunities` orders them, with the hash of the newest of those batches to give it next time. Batches recorded while it runs are left for next time rather than missed. A hash it doesn't know, such as a deleted batch's, is `ErrUnknownBatch`.
|
||||
`LatestChangeBatch` returns the hash of the newest change batch ("" if there is none). Read before listing, it is the batch to give `ListChangedOpportunities`, which returns the opportunities in every later batch, once each, unpaged and ordered as `ListOpportunities` orders them, with the hash of the newest of those batches to give it next time. A non-zero `releasedFrom` leaves out opportunities whose latest release is before it, as `OpportunityFilter.ReleasedFrom` does; the hash is the same either way. Batches recorded while it runs are left for next time rather than missed. A hash it doesn't know, such as a deleted batch's, is `ErrUnknownBatch`.
|
||||
|
||||
|
||||
**Title search.** The words are matched against the title using FTS5: every word must appear, each also matches as a prefix, case and accents are ignored, and "migrating" finds "migration", etc. Input is sanitised, so FTS5 operators and quotes typed by a user are searched as plain words, never interpreted.
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestChangeBatchAfterClockWentBack(t *testing.T) {
|
||||
t.Errorf("batch created at %v, not after %v", latest.CreatedAt, future)
|
||||
}
|
||||
|
||||
got, hash, err := s.ListChangedOpportunities(ctx, "future")
|
||||
got, hash, err := s.ListChangedOpportunities(ctx, "future", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestChangeBatchRetention(t *testing.T) {
|
||||
&records,
|
||||
`SELECT COUNT(*) FROM opportunity_change_batch_opportunities`,
|
||||
)
|
||||
_, _, err = s.ListChangedOpportunities(ctx, old)
|
||||
_, _, err = s.ListChangedOpportunities(ctx, old, time.Time{})
|
||||
|
||||
if retention > 0 {
|
||||
if batches != 1 || records != 1 ||
|
||||
|
||||
@@ -313,7 +313,8 @@ ON CONFLICT DO NOTHING`
|
||||
|
||||
// selectOpportunitiesChangedBetween reads, into opportunityRows, the
|
||||
// opportunities in the batches created after the first argument and at or
|
||||
// before the second, once each, ordered as ListOpportunities orders them.
|
||||
// before the second, once each. ListChangedOpportunities may add AND
|
||||
// clauses, then orderOpportunitiesChanged.
|
||||
const selectOpportunitiesChangedBetween = `
|
||||
SELECT t.*
|
||||
FROM opportunities t
|
||||
@@ -322,5 +323,9 @@ WHERE t.uuid IN (
|
||||
FROM opportunity_change_batch_opportunities bo
|
||||
JOIN opportunity_change_batches b ON b.id = bo.batch_id
|
||||
WHERE b.created_at > ? AND b.created_at <= ?
|
||||
)
|
||||
)`
|
||||
|
||||
// orderOpportunitiesChanged ends selectOpportunitiesChangedBetween, ordering
|
||||
// as ListOpportunities orders.
|
||||
const orderOpportunitiesChanged = `
|
||||
ORDER BY t.latest_release_date DESC, t.uuid`
|
||||
@@ -124,7 +124,11 @@ var checks = map[string]queryCheck{
|
||||
// opportunity_change_batch_opportunities
|
||||
"insertChangeBatchOpportunity": {sql: insertChangeBatchOpportunity},
|
||||
"selectOpportunitiesChangedBetween": {
|
||||
sql: selectOpportunitiesChangedBetween,
|
||||
sql: selectOpportunitiesChangedBetween + orderOpportunitiesChanged,
|
||||
},
|
||||
"orderOpportunitiesChanged": {
|
||||
sql: selectOpportunitiesChangedBetween + "\nAND " +
|
||||
filterOpportunityReleasedFrom + orderOpportunitiesChanged,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -776,10 +776,14 @@ func (s *Store) LatestChangeBatch(ctx context.Context) (string, error) {
|
||||
// those batches, to give it next time. With no newer batches, it returns no
|
||||
// opportunities and after. An unknown hash is ErrUnknownBatch.
|
||||
//
|
||||
// Unless releasedFrom is zero, only opportunities whose latest release is on
|
||||
// or after it are returned (as OpportunityFilter.ReleasedFrom); the hash
|
||||
// returned is the same either way.
|
||||
//
|
||||
// Batches recorded while it runs are left for next time, so none is missed:
|
||||
// only those up to the newest batch when it starts are read.
|
||||
func (s *Store) ListChangedOpportunities(
|
||||
ctx context.Context, after string,
|
||||
ctx context.Context, after string, releasedFrom time.Time,
|
||||
) ([]types.Opportunity, string, error) {
|
||||
var since changeBatch
|
||||
err := s.db.GetContext(ctx, &since, selectChangeBatchByHash, after)
|
||||
@@ -796,14 +800,16 @@ func (s *Store) ListChangedOpportunities(
|
||||
return nil, "", fmt.Errorf("reading the latest change batch: %w", err)
|
||||
}
|
||||
|
||||
query := selectOpportunitiesChangedBetween
|
||||
args := []any{since.CreatedAt, latest.CreatedAt}
|
||||
if !releasedFrom.IsZero() {
|
||||
query += "\nAND " + filterOpportunityReleasedFrom
|
||||
args = append(args, releasedFrom.UTC())
|
||||
}
|
||||
query += orderOpportunitiesChanged
|
||||
|
||||
var rows []opportunityRow
|
||||
err = s.db.SelectContext(
|
||||
ctx,
|
||||
&rows,
|
||||
selectOpportunitiesChangedBetween,
|
||||
since.CreatedAt,
|
||||
latest.CreatedAt,
|
||||
)
|
||||
err = s.db.SelectContext(ctx, &rows, query, args...)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listing changed opportunities: %w", err)
|
||||
}
|
||||
|
||||
@@ -1020,7 +1020,9 @@ func TestChangeBatches(t *testing.T) {
|
||||
}
|
||||
changed := func(after string) ([]types.Opportunity, string) {
|
||||
t.Helper()
|
||||
opportunities, latest, err := s.ListChangedOpportunities(ctx, after)
|
||||
opportunities, latest, err := s.ListChangedOpportunities(
|
||||
ctx, after, time.Time{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1074,6 +1076,18 @@ func TestChangeBatches(t *testing.T) {
|
||||
t.Errorf("changed opportunity without classifications: %+v", got[1])
|
||||
}
|
||||
|
||||
// releasedFrom leaves out ocds-2 (released day 2), not the batch.
|
||||
got, hash, err := s.ListChangedOpportunities(ctx, first, day(3))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equal(uuids(got), []string{"ocds-3"}) || hash != second {
|
||||
t.Errorf(
|
||||
"changed from day 3: %v, %q; want [ocds-3], %q",
|
||||
uuids(got), hash, second,
|
||||
)
|
||||
}
|
||||
|
||||
mustSetMarks(t, s, types.OpportunityMarks{
|
||||
UUID: "ocds-1",
|
||||
Skipped: true,
|
||||
@@ -1091,7 +1105,7 @@ func TestChangeBatches(t *testing.T) {
|
||||
t.Errorf("changed after latest: %v, %q; want [], %q", got, again, hash)
|
||||
}
|
||||
|
||||
_, _, err := s.ListChangedOpportunities(ctx, "unknown")
|
||||
_, _, err = s.ListChangedOpportunities(ctx, "unknown", time.Time{})
|
||||
if !errors.Is(err, store.ErrUnknownBatch) {
|
||||
t.Errorf("unknown batch: err %v, want ErrUnknownBatch", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user