Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
33bb108
feat(web): add get_config_page_size and get_page_from_request utilities
kaapstorm May 13, 2026
3697f94
test: move export_config fixture to apps/exports/tests/fixtures.py
kaapstorm Jun 1, 2026
0756739
feat(exports): add edit_url and last_run_log_url to ExportConfigBase
kaapstorm Jun 1, 2026
11930b7
feat(exports): add config_table HTMX endpoint with pagination and ETag
kaapstorm Jun 2, 2026
65466f2
refactor: move last_run() into ScheduleMixin
kaapstorm Jun 1, 2026
c97be5f
perf: use prefetched _all_runs in ScheduleMixin.last_run()
kaapstorm Jun 1, 2026
ba0af89
fix(exports): eliminate N+1 queries in config_table via prefetch_related
kaapstorm Jun 2, 2026
d343843
feat(exports): replace two-table layout with merged paginated config_…
kaapstorm May 13, 2026
a70577e
chore(exports): remove dead legacy exports context
kaapstorm May 13, 2026
7a40433
feat(refreshes): add config_table partial with pagination, ETag, and …
kaapstorm May 13, 2026
fed1eec
feat(forwarding): add config_table partial with pagination, ETag, and…
kaapstorm May 13, 2026
6e9b3a5
test: add smoke tests for list pages
kaapstorm May 13, 2026
930dc31
feat: add status filter constant and pagination to run history views
kaapstorm May 13, 2026
7af9250
feat: run history component — inline status filter dropdown, remove o…
kaapstorm May 13, 2026
cbb336f
feat: redesign detail page headers — inline buttons, Schedule field
kaapstorm May 13, 2026
ada5817
feat(exports): run history table — new columns, pagination, HTMX log …
kaapstorm May 13, 2026
792f01f
feat(refreshes): run history table — new columns, pagination, HTMX lo…
kaapstorm May 13, 2026
4c94eca
feat(forwarding): run history table — new columns, pagination, HTMX l…
kaapstorm May 13, 2026
4ce4c77
test: smoke tests for detail pages and run history table endpoints
kaapstorm May 13, 2026
2a47fa2
fix: Lint
kaapstorm Jun 2, 2026
72c6d83
Move `edit_url` and `last_run_log_url` to subclasses
kaapstorm Jun 3, 2026
60e30dc
Add tests with run instances
kaapstorm Jun 3, 2026
bbe1cb7
Reuse `VALID_CONFIG_PAGE_SIZES` const
kaapstorm Jun 3, 2026
8a9233f
Rename "configs" to "page_obj"
kaapstorm Jun 3, 2026
8719d9f
Suppress false-positive mypy error
kaapstorm Jun 3, 2026
74322b0
Test the function's contract, not the constant's value
kaapstorm Jun 5, 2026
3cfaab5
Drop unnecessary variable
kaapstorm Jun 5, 2026
fa3adbb
Nit: Variable names
kaapstorm Jun 5, 2026
c777228
Nit: Linebreaks
kaapstorm Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions apps/exports/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,29 @@ class ExportConfigBase(ScheduleMixin, BaseModel):
class Meta:
abstract = True

@property
def last_run(self):
return (
self.runs.exclude(status=ExportRun.Status.QUEUED)
.order_by('-created_at')
.first()
)

@property
def latest_version(self):
return Version.objects.get_for_object(self).first()

@property
def edit_url(self):
if isinstance(self, ExportConfig):
return reverse('exports:edit_export_config', args=[self.id])
elif isinstance(self, MultiProjectExportConfig):
return reverse('exports:edit_multi_export_config', args=[self.id])
Comment thread
nospame marked this conversation as resolved.
Outdated
raise ValueError(f"Unknown config type: {type(self)}")

@property
def last_run_log_url(self):
run = self.last_run
if run is None:
return None
if isinstance(self, ExportConfig):
return reverse('exports:run_log', args=[run.id])
elif isinstance(self, MultiProjectExportConfig):
return reverse('exports:multi_run_log', args=[run.id])
raise ValueError(f"Unknown config type: {type(self)}")

@property
def details_url(self):
raise NotImplementedError
Expand Down
46 changes: 45 additions & 1 deletion apps/exports/tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@

from apps.commcare.models import CommCareAccount, CommCareProject, CommCareServer
from apps.db.models import Database
from apps.exports.models import ExportConfig
from apps.exports.models import (
ExportConfig,
ExportRun,
MultiProjectExportConfig,
MultiProjectExportRun,
)
from tests.fixtures import (
commcare_account,
commcare_project,
Expand Down Expand Up @@ -131,3 +136,42 @@ def export_config_db_fixture():
)
config_file.close()
yield export_config


@fixture
@use('db')
def export_config():
yield ExportConfig.objects.create(
name='Test Export Config',
project=commcare_project(),
account=commcare_account(),
database=database(),
)


@fixture
@use('db')
def multi_export_config():
yield MultiProjectExportConfig.objects.create(
name='Multi Export Config',
account=commcare_account(),
database=database(),
)


@fixture
@use('db')
def export_run():
yield ExportRun.objects.create(
base_export_config=export_config(),
status=ExportRun.Status.COMPLETED,
)


@fixture
@use('db')
def multi_export_run():
yield MultiProjectExportRun.objects.create(
base_export_config=multi_export_config(),
status=MultiProjectExportRun.Status.COMPLETED,
)
193 changes: 183 additions & 10 deletions apps/exports/tests/test_list_view.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import re

from django.urls import reverse
from unmagic import fixture, use
from unmagic import use

from apps.exports.models import ExportConfig
from apps.exports.tests.fixtures import (
export_config,
export_run,
multi_export_config,
multi_export_run,
)
from tests.fixtures import (
authed_client,
commcare_account,
Expand All @@ -10,15 +18,26 @@
)


@fixture
@use('db')
def export_config():
yield ExportConfig.objects.create(
name='Test Export Config',
project=commcare_project(),
account=commcare_account(),
database=database(),
)
class TestExportConfigBaseProperties:
@use(export_config)
def test_export_config_edit_url(self):
config = export_config()
expected = reverse('exports:edit_export_config', args=[config.id])
assert config.edit_url == expected

@use(multi_export_config)
def test_multi_export_config_edit_url(self):
config = multi_export_config()
expected = reverse('exports:edit_multi_export_config', args=[config.id])
assert config.edit_url == expected

@use(export_config)
def test_last_run_log_url_none_when_no_run(self):
Comment thread
nospame marked this conversation as resolved.
Outdated
assert export_config().last_run_log_url is None

@use(multi_export_config)
def test_last_run_log_url_none_when_no_run_multi(self):
assert multi_export_config().last_run_log_url is None


class TestExportsHomeView:
Expand All @@ -35,3 +54,157 @@ def test_export_appears_in_list(self):
response = authed_client().get(reverse('exports:home'))
assert response.status_code == 200
assert export_config().name in response.content.decode()


class TestConfigTableView:
@use(authed_client)
def test_requires_login(self):
client = authed_client()
client.logout()
response = client.get(reverse('exports:config_table'))
assert response.status_code == 302

@use(authed_client)
def test_returns_200(self):
response = authed_client().get(reverse('exports:config_table'))
assert response.status_code == 200

@use(authed_client, export_config)
def test_config_appears(self):
response = authed_client().get(reverse('exports:config_table'))
assert export_config().name in response.content.decode()

@use(authed_client, commcare_project, commcare_account, database)
def test_pagination_default_page_size_10(self):
proj = commcare_project()
acct = commcare_account()
db_ = database()
for i in range(15):
ExportConfig.objects.create(
name=f'Config {i}',
project=proj,
account=acct,
database=db_,
)
response = authed_client().get(reverse('exports:config_table'))
assert response.status_code == 200
shown = response.content.decode().count('Config ')
assert shown == 10

@use(authed_client, commcare_project, commcare_account, database)
def test_page_size_param_respected(self):
proj = commcare_project()
acct = commcare_account()
db_ = database()
for i in range(25):
ExportConfig.objects.create(
name=f'Config {i}',
project=proj,
account=acct,
database=db_,
)
response = authed_client().get(
reverse('exports:config_table'), {'page_size': 20}
)
assert response.status_code == 200
shown = response.content.decode().count('Config ')
assert shown == 20

@use(authed_client, export_config)
def test_etag_match_returns_no_swap(self):
export_config()
client = authed_client()
# First request — get a valid etag
response = client.get(reverse('exports:config_table'))
assert response.status_code == 200
match = re.search(
r'data-etag="([a-f0-9]+)"', response.content.decode()
)
assert match, 'data-etag not found in response'
etag = match.group(1)

# Second request with matching etag — should return HX-Reswap: none
response2 = client.get(
reverse('exports:config_table'), {'etag': etag}
)
assert response2.status_code == 200
assert response2.get('HX-Reswap') == 'none'

@use(authed_client, export_config)
def test_etag_mismatch_returns_full_content(self):
config = export_config()
response = authed_client().get(
reverse('exports:config_table'), {'etag': 'stale'}
)
assert response.status_code == 200
assert response.get('HX-Reswap') is None
assert config.name in response.content.decode()

@use(authed_client, export_config)
def test_page_clamped_when_out_of_range(self):
export_config()
response = authed_client().get(
reverse('exports:config_table'), {'page': 999}
)
assert response.status_code == 200


class TestRunLogView:
@use(authed_client, export_run)
def test_requires_login(self):
run = export_run()
client = authed_client()
client.logout()
response = client.get(reverse('exports:run_log', args=[run.id]))
assert response.status_code == 302

@use(authed_client, export_run)
def test_returns_log_content(self):
run = export_run()
run.log = 'Test log output'
run.save()
response = authed_client().get(
reverse('exports:run_log', args=[run.id])
)
assert response.status_code == 200
assert 'Test log output' in response.content.decode()

@use(authed_client)
def test_404_for_invalid_run(self):
response = authed_client().get(reverse('exports:run_log', args=[9999]))
assert response.status_code == 404


class TestMultiRunLogView:
@use(authed_client, multi_export_run)
def test_requires_login(self):
run = multi_export_run()
client = authed_client()
client.logout()
response = client.get(reverse('exports:multi_run_log', args=[run.id]))
assert response.status_code == 302

@use(authed_client, multi_export_run)
def test_returns_log_content(self):
run = multi_export_run()
run.log = 'Multi log output'
run.save()
response = authed_client().get(
reverse('exports:multi_run_log', args=[run.id])
)
assert response.status_code == 200
assert 'Multi log output' in response.content.decode()


class TestExportsHomeViewUpdated:
@use(authed_client)
def test_home_includes_config_table_div(self):
response = authed_client().get(reverse('exports:home'))
assert response.status_code == 200
assert 'id="exports-config-table"' in response.content.decode()

@use(authed_client, multi_export_config)
def test_multi_config_appears_in_merged_table(self):
config = multi_export_config()
response = authed_client().get(reverse('exports:home'))
assert config.name in response.content.decode()
8 changes: 8 additions & 0 deletions apps/exports/urls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.urls import path

from . import views


Expand Down Expand Up @@ -77,6 +78,13 @@
views.fetch_config_files,
name='fetch_config_files',
),
path(r'config-table/', views.config_table, name='config_table'),
path(r'runs/<int:run_id>/log/', views.run_log, name='run_log'),
path(
r'runs/multi-project/<int:run_id>/log/',
views.multi_run_log,
name='multi_run_log',
),
path(
r'download/commcare-export-log/',
views.download_commcare_export_log,
Expand Down
Loading