Skip to content

Implement S3 media storage, event management, and API improvements - #279

Open
amit0539 wants to merge 86 commits into
mainfrom
dev
Open

amit0539 wants to merge 86 commits into
mainfrom
dev

Conversation

@amit0539

@amit0539 amit0539 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Description

Brief description of the changes introduced by this PR.

Type of Change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Performance improvement
  • Code refactoring
  • Test improvements

Related Issues

Fixes #(issue number)
Closes #(issue number)
Relates to #(issue number)

Changes Made

  • Change 1
  • Change 2
  • Change 3

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this change manually

Test Instructions

Please describe how reviewers can test your changes:

  1. Step 1
  2. Step 2
  3. Step 3

Screenshots (if applicable)

Add screenshots to help explain your changes.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules
  • I have run black ., isort ., and flake8 and fixed any issues
  • Database migrations are included (if applicable)

Additional Notes

Add any additional notes or context about the PR here.

saqibmanan and others added 30 commits October 9, 2026 19:07
A green 'Tests skipped' job records it on the run, and Finalize Deploy
still prunes images. main ignores the flag.
Run workflow on dev with 'rollback' ticked skips the build and runs the
existing rollback job, which restores the previously live image. The run
stays green when the rollback succeeds.
ci(deploy): SKIP_DEV_TESTS flag and a dev rollback button
The fallback https://api.civicdataspace.in does not resolve, so every
download_url in DCAT/Croissant/Dublin Core exports was a dead link when
PUBLIC_API_URL was unset. Use https://api.datakeep.civicdays.in, the prod
API host, in settings, the export adapter and .env.example.

Closes #238
fix(api): point PUBLIC_API_URL fallback at the live prod API
The export view caught every ValueError around export_dataset() and sent
str(exc) back with a 400, so internal errors (crosswalk, dates, rdflib)
reached anonymous users verbatim and were reported as client errors.

Validate standard and format against export_options() before exporting
and answer with a fixed message plus the options. Every other failure
now falls to the logged generic 500.

Closes #240
fix(api): stop returning exception text from metadata export
DVC never worked: uploaded files were never under DVC_REPO_PATH, so every
DVC call raised and createMajorVersion always failed. Remove the manager,
the setup command, version-change detection and the post_save signal.

createMajorVersion now writes the ResourceVersion row directly inside
transaction.atomic(), behind IsAuthenticated plus a check that the caller
owns the dataset or may change it.

BREAKING CHANGE: the setup_dvc management command and DVC_* settings are gone.
Media moves to S3 only, through django-storages. Two storages share one
bucket under <STORAGE_ENV>/public and <STORAGE_ENV>/private: public assets
get unsigned, cacheable URLs; private files get 5-minute presigned URLs.
The default storage is private, so anything that falls back to it is never
world-readable by accident. Empty access keys use the boto3 credential
chain (ECS/EC2 task role).

boto/urllib3 loggers are capped at WARNING because at DEBUG they log every
signed request, Authorization header included.

Tests swap all storages for InMemoryStorage and never reach S3.
Every FileField/ImageField now names a storage: logos, cover images,
profile pictures and publication block files go to public storage;
resource files and chart images go to private storage. The storages are
callables, so the migrations record a function reference, not bucket
settings.

upload_to paths drop their "files/public/" and "files/" prefixes, which
only repeated what MEDIA_ROOT used to add; the storages already put every
key under <env>/public or <env>/private.
S3 has no FieldFile.path, so every reader that used it would raise
NotImplementedError once media left local disk.

- local_copy() yields a temporary local copy (keeping the extension) for
  pandas, libmagic and the YAML/XML parsers; data indexing, entry counts
  and MIME validation use it.
- The lru_cache on the loaders is dropped: it keyed on temp paths that
  are deleted on exit, so it only held DataFrames in memory.
- Downloads stream with FileResponse instead of reading whole objects.
- Charts only check that the file exists; they read the indexed table.
- Search documents index the storage URL instead of a rewritten disk path.
Links issued before the move pointed at /public/<name> on disk. That route
now redirects to the file's public S3 URL, after stripping the old
"files/public/" or "files/" prefix from the name.

Only public files redirect. The old static() route also served resources
and drafts whenever DEBUG was on; anything outside public storage, or a
name that tries to traverse paths, is a 404 here.
Rows created on disk carry a "files/public/" or "files/" prefix that the
S3 keys no longer use. For each stored name the command puts the file at
the normalised key in the storage its field names, taken from the old S3
key or, with --source, from the old disk tree, then repoints the row.

Dry run by default; --execute copies and renames. Safe to re-run (names
already in place are skipped) and deletes nothing.
Regression tests for the local-disk/DVC to S3 migration: S3 storage
options (unsigned public URLs, 5-minute presigned private URLs, private
default), which storage each file field names, local_copy() and MIME
checks on storage without paths, prefix-free upload names, the legacy
/public redirect (public files only, no traversal), createMajorVersion's
owner-only version write, and migrate_media_to_s3 (dry run, disk and old
S3 sources, missing files, failed copies).
Replace the DVC references in SECURITY.md and AGENTS.md with the S3
storage layout and access rules, and add the no-co-author commit rule to
AGENTS.md.
Agents kept tripping over setup the file never mentioned (.env and logs/
are needed before tests boot; pre-commit pins python3.11) and over
S3-era rules that lived only in code review. Add a quick start, a
commands table with the SDK test command, task checklists for mutations,
model changes and bug fixes, a files-and-media section (no FieldFile.path,
local_copy, FileResponse, public vs private storage), and git safety
rules (self-contained commits, no AI co-author trailer, never stash the
user's work). Existing rules are unchanged.
CivicDataLab's scrapers fill "Source identifier" on every dataset they upload
(dataspace-scrapers/<scraper_id>/<dataset_key>) and read it back to recognise their
own datasets, so a dataset is never created twice and their upload records can be
rebuilt from DataSpace. The data migration creates the item, or switches it back on
if an admin switched it off; reversing it deletes nothing.

Tests: tests/test_source_identifier_migration.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fier

feat(metadata): add source identifier dataset metadata item
…blications

Add useCasesTable, collaborativesTable, aiModelsTable and publicationsTable,
shaped like datasetsTable: client filters and sort options limited to an
allowlist per module, limit/offset paging, the total after filters, and
counts per status for the dashboard tabs (ignoring the status filter so tab
labels stay stable).

Each query shows the same rows as the module's existing list query, plus
published (for AI models: public and active) rows when includePublic is
set. The existing list queries are unchanged. StatusCount and
status_counts() are shared in api/utils/qs_utils.py. No migration.
The tab counts in datasetsTable were an inline copy of what
status_counts() in api/utils/qs_utils.py now does for the other four
tables. Call the helper instead, so all five tables count statuses in one
place. status_counts() takes an optional item_type so the dataset table
keeps its DatasetStatusCount type; the GraphQL schema is unchanged.
feat(api): table queries for use cases, collaboratives, AI models, publications
The charts dashboard loads every built chart and chart image through
getChartData and searches them in the browser. chartsTable returns one
page of both kinds in a single list, with the total count and per-status
counts, using the shared table helpers.

Both tables are merged in one SQL UNION, so counting, sorting and slicing
happen in the database; only the rows of the page are then loaded.
Visibility and permission are the same as getChartData. Rows keep their
types (TypeResourceChart, TypeResourceChartImage), so existing fragments
work unchanged. getChartData is untouched.
Sorting and paging now go through the same helper as the other table
queries. Only filtering stays per table, because Django cannot filter
after a UNION. Drops the extra id tie-breaker, matching the others.
feat(api): add chartsTable with filters, sorting and pagination
This commit introduces a new Event model with fields for name, location, dates, and status, along with a corresponding GraphQL schema for creating, updating, and querying events. The schema includes input types for event creation and updates, as well as queries for retrieving events based on various filters. Additionally, utility functions for handling Luma embeds and event permissions are implemented to ensure proper validation and access control.
An event whose owner was deleted (user set to NULL) and has no
organization was reported as individual by the API but not by
Event.is_individual_event. Use the same check in both.
Each event gets a 12-character public_id; its link is the name words
plus that id (urlSlug), so renames never break links and two events
can share a name. eventBySlug finds the event by the id at the end of
the link. The slug is no longer unique and the counter loop is gone.

Also drops the 0055_event dependency on
0049_merge_publication_and_accessmodel, which does not exist.
Table names were built from the resource name and dataset title, and
Postgres keeps only the first 63 bytes of a name, so two resources with long
similar names shared one table and a re-upload of one replaced the other's
data. Re-uploads also dropped the old table before the new one existed, so
readers saw empty or half-written data and a failed re-upload lost it.

Each upload now gets a table named rdt_<row id>. index_resource_data builds
the new table in one data-database transaction, then switches the resource
over in one main-database commit (resource row locked, stored file
re-checked so the newest file wins, column rows rebuilt with errors
escaping) and confirms the new row exists; anything short of that drops
this build's table and keeps the old data. A post_delete receiver on
ResourceDataTable drops the old table after commit, which also frees
storage on resource, dataset and admin deletes; it leaves legacy tables
two rows still share. update_dataindex no longer wraps each call in a
transaction, and the flow refuses to run inside one.

Tests run against a real Postgres when TEST_DATA_DB_URL is set; CI adds a
postgres service and fails instead of skipping them.

Plan: plans/claude/data-index-table-names.md (Phase 1)
Existing tables still carry title-based names, and two resources can share
one (Postgres cut both names to the same 63 bytes), so one of them serves
the other's data. This one-off command moves them to rdt_<row id> names.

Short old names can't have collided and are renamed in place, holding the
resource row and waiting at most 2 s for readers. Long ones are rebuilt from
each resource's own file through the normal upload flow, oldest first and
with no read-time limit; the shared table is dropped once its last row has
moved. A long table whose file is missing or can no longer become a table
is renamed and marked "unverified" in its comment if no other row uses it,
and every later report lists it until the resource is re-uploaded. A rerun
after a crash finishes half-done renames.

It is a dry run by default. The dry run checks each stored file, so its
report shows what --apply will do, which tables are shared and which rows
need a person. Tables no row uses are listed with size and comment. They
are dropped only with --apply --drop-orphans --from-report <reviewed
report>, and only if still unused at that moment. The command refuses to
run when the data database looks like the main one. Every row's outcome is
logged as it happens, and an in-progress report is written before any
change.

The Layer 5 journey (re-upload while polling, racing re-uploads, delete) is
added for on-demand runs.

Plan: plans/claude/data-index-table-names.md (Phase 2)
Covers per-upload table naming, the build -> switch -> drop-after-commit
flow, the delete receiver and its shared-table guard, the readers, the
migrate_data_table_names command with its runbook and old naming schemes,
the Postgres test setup, the failure model, and known limitations.
SDK: no. The data API doc links to it.

Plan: plans/claude/data-index-table-names.md (Phase 4)
Final QC found that stopping a command (Ctrl+C) while the old table's
after-commit drop waited for a reader made the flow's clean-up drop the
new table, though the resource already pointed at it. The clean-up now
asks the database first: it drops the build's table only if its row isn't
saved, and leaves the table for the orphan audit when it can't tell. That
also covers a commit that went through but reported an error.

The runbook now says a row that fails twice the same way needs a person.

Plan: plans/claude/data-index-table-names.md (final QC)
Two headers that differ only after Postgres's 63-byte cut become one
column, so the file never gets a table. This bug predates per-upload
tables and is noted, not fixed, by user decision.
A re-upload whose rows can't be loaded still reports success, so the
download serves the new file while previews, charts and the data API serve
the old one. Marked pending a product decision: enforce the database's
limits at upload and flag problem columns in the column-description UI.
ubuntu-latest is now Ubuntu 26.04, where setup-python has no Python 3.9
build. The 3.9 job failed at setup and fail-fast cancelled 3.10 and 3.11
(seen on #295). The SDK still declares 3.9 support, so pin the runner
instead of dropping it.
feat(data-index): per-upload data tables, safe re-upload, delete clean-up
@psaesha
psaesha deployed to development November 14, 2026 17:18 — with GitHub Actions Active
They were skipped while CivicDataSpace-test#103 was open; it closed on
2026-09-24 and the provider suite now passes under parallel load.

This branch was successfully deployed

1 active deployment
development — 4441862f Deployed Nov 15, 2026 by saqibmanan via Finalize Deploy #106
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants