From 33bb108d63248ab969fa70559585f74537f33317 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:19 -0400 Subject: [PATCH 01/29] feat(web): add get_config_page_size and get_page_from_request utilities --- commcare_sync/tests/__init__.py | 0 commcare_sync/tests/test_views.py | 46 +++++++++++++++++++++++++++++++ commcare_sync/views.py | 20 ++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 commcare_sync/tests/__init__.py create mode 100644 commcare_sync/tests/test_views.py diff --git a/commcare_sync/tests/__init__.py b/commcare_sync/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commcare_sync/tests/test_views.py b/commcare_sync/tests/test_views.py new file mode 100644 index 00000000..ae40850a --- /dev/null +++ b/commcare_sync/tests/test_views.py @@ -0,0 +1,46 @@ +import pytest +from django.test import RequestFactory + +from commcare_sync.views import get_config_page_size, get_page_from_request + +VALID_PAGE_SIZES = [10, 20, 50] + + +class TestGetConfigPageSize: + def _req(self, params=''): + rf = RequestFactory() + return rf.get(f'/?{params}') + + def test_default_is_10(self): + assert get_config_page_size(self._req()) == 10 + + def test_valid_sizes_accepted(self): + for size in VALID_PAGE_SIZES: + assert get_config_page_size(self._req(f'page_size={size}')) == size + + def test_invalid_size_falls_back_to_default(self): + assert get_config_page_size(self._req('page_size=7')) == 10 + + def test_non_integer_falls_back_to_default(self): + assert get_config_page_size(self._req('page_size=abc')) == 10 + + +class TestGetPageFromRequest: + def _req(self, params=''): + rf = RequestFactory() + return rf.get(f'/?{params}') + + def test_default_is_1(self): + assert get_page_from_request(self._req()) == 1 + + def test_valid_page_returned(self): + assert get_page_from_request(self._req('page=3')) == 3 + + def test_zero_clamped_to_1(self): + assert get_page_from_request(self._req('page=0')) == 1 + + def test_negative_clamped_to_1(self): + assert get_page_from_request(self._req('page=-5')) == 1 + + def test_non_integer_returns_1(self): + assert get_page_from_request(self._req('page=abc')) == 1 diff --git a/commcare_sync/views.py b/commcare_sync/views.py index fdec0a61..28f48eb5 100644 --- a/commcare_sync/views.py +++ b/commcare_sync/views.py @@ -1,5 +1,7 @@ from django.conf import settings +_VALID_CONFIG_PAGE_SIZES = (10, 20, 50) + def get_ui_page_size(request): limit = settings.COMMCARE_SYNC_UI_PAGE_SIZE @@ -15,3 +17,21 @@ def get_hide_skipped_from_request(request): if 'hide_skipped' in request.GET: return request.GET['hide_skipped'] == 'y' return False + + +def get_config_page_size(request): + if 'page_size' in request.GET: + try: + size = int(request.GET['page_size']) + if size in _VALID_CONFIG_PAGE_SIZES: + return size + except ValueError: + pass + return _VALID_CONFIG_PAGE_SIZES[0] + + +def get_page_from_request(request): + try: + return max(int(request.GET.get('page', 1)), 1) + except ValueError: + return 1 From 3697f94415e7bcc2eb009b4aff2f6c4ed4240fd1 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Mon, 1 Jun 2026 17:35:03 -0400 Subject: [PATCH 02/29] test: move export_config fixture to apps/exports/tests/fixtures.py export_config lived inline in test_list_view.py; relocate it to the shared exports fixtures module so other test modules (and the multi-project / run fixtures added next) can reuse it via import. --- apps/exports/tests/fixtures.py | 11 +++++++++++ apps/exports/tests/test_list_view.py | 22 +++------------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/exports/tests/fixtures.py b/apps/exports/tests/fixtures.py index c6c4e6d4..b5a1cb47 100644 --- a/apps/exports/tests/fixtures.py +++ b/apps/exports/tests/fixtures.py @@ -131,3 +131,14 @@ 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(), + ) diff --git a/apps/exports/tests/test_list_view.py b/apps/exports/tests/test_list_view.py index ea2b1058..7e563d68 100644 --- a/apps/exports/tests/test_list_view.py +++ b/apps/exports/tests/test_list_view.py @@ -1,24 +1,8 @@ from django.urls import reverse -from unmagic import fixture, use +from unmagic import use -from apps.exports.models import ExportConfig -from tests.fixtures import ( - authed_client, - commcare_account, - commcare_project, - database, -) - - -@fixture -@use('db') -def export_config(): - yield ExportConfig.objects.create( - name='Test Export Config', - project=commcare_project(), - account=commcare_account(), - database=database(), - ) +from apps.exports.tests.fixtures import export_config +from tests.fixtures import authed_client class TestExportsHomeView: From 07567391edebc799e0b556d55d8dcb6f5e8751b9 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Mon, 1 Jun 2026 17:37:58 -0400 Subject: [PATCH 03/29] feat(exports): add edit_url and last_run_log_url to ExportConfigBase --- apps/exports/models.py | 19 +++++++++++++++ apps/exports/tests/fixtures.py | 35 +++++++++++++++++++++++++++- apps/exports/tests/test_list_view.py | 24 ++++++++++++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/exports/models.py b/apps/exports/models.py index bc03efcb..f5843dc2 100644 --- a/apps/exports/models.py +++ b/apps/exports/models.py @@ -41,6 +41,25 @@ def last_run(self): 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]) + 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 diff --git a/apps/exports/tests/fixtures.py b/apps/exports/tests/fixtures.py index b5a1cb47..ba5ac6a9 100644 --- a/apps/exports/tests/fixtures.py +++ b/apps/exports/tests/fixtures.py @@ -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, @@ -142,3 +147,31 @@ def export_config(): 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, + ) diff --git a/apps/exports/tests/test_list_view.py b/apps/exports/tests/test_list_view.py index 7e563d68..58140775 100644 --- a/apps/exports/tests/test_list_view.py +++ b/apps/exports/tests/test_list_view.py @@ -1,10 +1,32 @@ from django.urls import reverse from unmagic import use -from apps.exports.tests.fixtures import export_config +from apps.exports.tests.fixtures import export_config, multi_export_config from tests.fixtures import authed_client +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): + 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: @use(authed_client) def test_stats_in_context(self): From 11930b756f79a5712c893a601d570bbf46756696 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Mon, 1 Jun 2026 21:21:44 -0400 Subject: [PATCH 04/29] feat(exports): add config_table HTMX endpoint with pagination and ETag --- apps/exports/tests/test_list_view.py | 157 ++++++++++++++++- apps/exports/urls.py | 8 + apps/exports/views.py | 98 ++++++++++- templates/exports/partials/config_table.html | 168 +++++++++++++++++++ templates/exports/partials/run_log.html | 5 + 5 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 templates/exports/partials/config_table.html create mode 100644 templates/exports/partials/run_log.html diff --git a/apps/exports/tests/test_list_view.py b/apps/exports/tests/test_list_view.py index 58140775..3b329605 100644 --- a/apps/exports/tests/test_list_view.py +++ b/apps/exports/tests/test_list_view.py @@ -1,8 +1,21 @@ +import re + from django.urls import reverse from unmagic import use -from apps.exports.tests.fixtures import export_config, multi_export_config -from tests.fixtures import authed_client +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, + commcare_project, + database, +) class TestExportConfigBaseProperties: @@ -41,3 +54,143 @@ 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() diff --git a/apps/exports/urls.py b/apps/exports/urls.py index 54f0d50a..75ab592a 100644 --- a/apps/exports/urls.py +++ b/apps/exports/urls.py @@ -1,4 +1,5 @@ from django.urls import path + from . import views @@ -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//log/', views.run_log, name='run_log'), + path( + r'runs/multi-project//log/', + views.multi_run_log, + name='multi_run_log', + ), path( r'download/commcare-export-log/', views.download_commcare_export_log, diff --git a/apps/exports/views.py b/apps/exports/views.py index e613d7a0..1142f809 100644 --- a/apps/exports/views.py +++ b/apps/exports/views.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import os @@ -5,6 +6,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.paginator import EmptyPage, Paginator from django.db.models import Max from django.http import ( Http404, @@ -15,7 +17,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_GET, require_POST from reversion.models import Version from apps.commcare.models import CommCareAccount, CommCareProject @@ -26,7 +28,12 @@ _get_refresh_statistics, ) from apps.web.templatetags.dateformat_tags import readable_timedelta -from commcare_sync.views import get_hide_skipped_from_request, get_ui_page_size +from commcare_sync.views import ( + get_config_page_size, + get_hide_skipped_from_request, + get_page_from_request, + get_ui_page_size, +) from .api_client import fetch_available_configs from .forms import ( @@ -44,6 +51,42 @@ logger = logging.getLogger(__name__) +def _merged_export_configs(page_size, page_num): + """Return a Page object combining ExportConfig and MultiProjectExportConfig.""" + single = list( + ExportConfig.objects.select_related('project', 'created_by').annotate( + last_run_at=Max('runs__created_at') + ) + ) + multi = list( + MultiProjectExportConfig.objects.select_related('created_by').annotate( + last_run_at=Max('runs__created_at') + ) + ) + all_configs = sorted( + single + multi, + key=lambda c: c.last_run_at or c.updated_at, + reverse=True, + ) + paginator = Paginator(all_configs, page_size) + try: + return paginator.page(page_num) + except EmptyPage: + return paginator.page(paginator.num_pages) + + +def _compute_exports_etag(configs_list): + """MD5 fingerprint of (run.id, created_at, status) for each config's latest run.""" + parts = [] + for config in configs_list: + run = config.runs.order_by('-created_at').first() + if run: + parts.append(f'{run.id}:{run.created_at.isoformat()}:{run.status}') + else: + parts.append(f'config:{config.id}:no-runs') + return hashlib.md5('|'.join(parts).encode()).hexdigest() + + @login_required def home(request): now = timezone.now() @@ -51,6 +94,11 @@ def home(request): current_start = now - period previous_start = current_start - period + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + page_obj = _merged_export_configs(page_size, page_num) + etag = _compute_exports_etag(page_obj.object_list) + exports = ( ExportConfig.objects .select_related('project') @@ -63,6 +111,10 @@ def home(request): return render(request, 'exports/exports_home.html', { 'active_tab': 'exports', + 'configs': page_obj, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], + 'etag': etag, 'exports': exports, 'multi_project_exports': multi_project_exports, 'stats_period': readable_timedelta(period, short=True), @@ -72,6 +124,48 @@ def home(request): }) +@login_required +@require_GET +def config_table(request): + """HTMX endpoint: paginated + ETag-guarded exports config table partial.""" + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + page_obj = _merged_export_configs(page_size, page_num) + + etag = _compute_exports_etag(page_obj.object_list) + if request.GET.get('etag') == etag: + response = HttpResponse() + response['HX-Reswap'] = 'none' + return response + + return render( + request, + 'exports/partials/config_table.html', + { + 'configs': page_obj, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], + 'etag': etag, + }, + ) + + +@login_required +@require_GET +def run_log(request, run_id): + """HTMX endpoint: log fragment for an ExportRun.""" + run = get_object_or_404(ExportRun, id=run_id) + return render(request, 'exports/partials/run_log.html', {'run': run}) + + +@login_required +@require_GET +def multi_run_log(request, run_id): + """HTMX endpoint: log fragment for a MultiProjectExportRun.""" + run = get_object_or_404(MultiProjectExportRun, id=run_id) + return render(request, 'exports/partials/run_log.html', {'run': run}) + + @login_required def create_export_config(request): diff --git a/templates/exports/partials/config_table.html b/templates/exports/partials/config_table.html new file mode 100644 index 00000000..36497046 --- /dev/null +++ b/templates/exports/partials/config_table.html @@ -0,0 +1,168 @@ +{% load i18n %} +{% load exports_tags %} +{% load humanize %} +{% load dateformat_tags %} + +
+ {% if configs.object_list %} + + + + + + + + + + + {% for config in configs.object_list %} + + + + + + + + + + + + + {% endfor %} +
{% trans "Name" %}{% trans "Schedule" %}{% trans "Last Started" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ config.name }} + {% if config.is_paused %} + + {% endif %} + + {% if config.is_paused %} + {% trans "Paused" %} + {% elif config.schedule_display %} + {{ config.schedule_display }} + {% else %} + + {% endif %} + + {% if config.last_run %} + {{ config.last_run.created_at|naturaltime }} + {% else %} + {% trans "Never" %} + {% endif %} + + {% if config.last_run %} +
+ {{ config.last_run.status|to_status_icon }} + {{ config.last_run.status }} +
+
+ {{ config.last_run.duration|readable_timedelta_short }} +
+ {% else %} + + {% endif %} +
+ {% if config.last_run and config.last_run.status == 'completed' or config.last_run and config.last_run.status == 'failed' %} + + {% else %} + + {% endif %} + + {% trans "Edit" %} + +
+
+
+ + {# Pagination footer #} +
+
+ {% trans "Per page:" %} + {% for size in page_sizes %} + + {{ size }} + + {% endfor %} +
+ {% if configs.paginator.num_pages > 1 %} + + {% endif %} +
+ {% else %} +
+ {% trans "No export configurations found!" %} +
+ {% endif %} +
diff --git a/templates/exports/partials/run_log.html b/templates/exports/partials/run_log.html new file mode 100644 index 00000000..59213c48 --- /dev/null +++ b/templates/exports/partials/run_log.html @@ -0,0 +1,5 @@ +{% if run.log %} +
{{ run.log }}
+{% else %} +No log available. +{% endif %} From 65466f2cc9c4b87fdd5abd426079c97239805e36 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Mon, 1 Jun 2026 15:44:25 -0400 Subject: [PATCH 05/29] refactor: move last_run() into ScheduleMixin The last_run property was duplicated, byte-for-byte except the Run class named in the status check, across ExportConfigBase, RefreshConfig and ForwardingConfig. All three inherit ScheduleMixin, and QUEUED is shared via RunBaseModel.Status, so the method belongs on the mixin alongside has_queued_runs(). No behaviour change. --- apps/exports/models.py | 8 -------- apps/forwarding/models.py | 9 --------- apps/refreshes/models.py | 8 -------- apps/schedules/mixin.py | 8 ++++++++ 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/apps/exports/models.py b/apps/exports/models.py index f5843dc2..aede4cd9 100644 --- a/apps/exports/models.py +++ b/apps/exports/models.py @@ -29,14 +29,6 @@ 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() diff --git a/apps/forwarding/models.py b/apps/forwarding/models.py index 2e9f26fc..788e9b01 100644 --- a/apps/forwarding/models.py +++ b/apps/forwarding/models.py @@ -63,15 +63,6 @@ class ForwardingConfig(ScheduleMixin, BaseModel): def __str__(self): return self.name - @property - def last_run(self): - return ( - self.runs - .exclude(status=ForwardingRun.Status.QUEUED) - .order_by('-created_at') - .first() - ) - @property def latest_version(self): return Version.objects.get_for_object(self).first() diff --git a/apps/refreshes/models.py b/apps/refreshes/models.py index f8e7ca44..5a126185 100644 --- a/apps/refreshes/models.py +++ b/apps/refreshes/models.py @@ -43,14 +43,6 @@ class Meta: def __str__(self): return self.name - @property - def last_run(self): - return ( - self.runs.exclude(status=RefreshRun.Status.QUEUED) - .order_by('-created_at') - .first() - ) - @property def latest_version(self): return Version.objects.get_for_object(self).first() diff --git a/apps/schedules/mixin.py b/apps/schedules/mixin.py index 176220c2..b7658974 100644 --- a/apps/schedules/mixin.py +++ b/apps/schedules/mixin.py @@ -115,6 +115,14 @@ def has_queued_runs(self): return last_run.status == RunBaseModel.Status.QUEUED return False + @property + def last_run(self): + return ( + self.runs.exclude(status=RunBaseModel.Status.QUEUED) + .order_by('-created_at') + .first() + ) + @property def schedule_display(self): if not self.schedule_type: From c97be5f8f1f38aa51714cee65724d653ea4c7263 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Mon, 1 Jun 2026 15:44:39 -0400 Subject: [PATCH 06/29] perf: use prefetched _all_runs in ScheduleMixin.last_run() When a caller has prefetched a config's runs into _all_runs (via Prefetch(..., to_attr='_all_runs')), pick the latest non-queued run from that list in Python instead of issuing a per-config query. Falls back to the DB query when _all_runs is absent, so behaviour is unchanged until callers opt in. --- apps/schedules/mixin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/schedules/mixin.py b/apps/schedules/mixin.py index b7658974..95a6637f 100644 --- a/apps/schedules/mixin.py +++ b/apps/schedules/mixin.py @@ -117,6 +117,13 @@ def has_queued_runs(self): @property def last_run(self): + all_runs = getattr(self, '_all_runs', None) + if all_runs is not None: + # Use prefetched data: filter out QUEUED in Python + non_queued = [ + r for r in all_runs if r.status != RunBaseModel.Status.QUEUED + ] + return non_queued[0] if non_queued else None return ( self.runs.exclude(status=RunBaseModel.Status.QUEUED) .order_by('-created_at') From ba0af899c0b038a96271603a2316201f01d4fda1 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Mon, 1 Jun 2026 22:44:55 -0400 Subject: [PATCH 07/29] fix(exports): eliminate N+1 queries in config_table via prefetch_related --- apps/exports/views.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/exports/views.py b/apps/exports/views.py index 1142f809..24598dd6 100644 --- a/apps/exports/views.py +++ b/apps/exports/views.py @@ -7,7 +7,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import EmptyPage, Paginator -from django.db.models import Max +from django.db.models import Max, Prefetch from django.http import ( Http404, HttpResponse, @@ -54,14 +54,23 @@ def _merged_export_configs(page_size, page_num): """Return a Page object combining ExportConfig and MultiProjectExportConfig.""" single = list( - ExportConfig.objects.select_related('project', 'created_by').annotate( - last_run_at=Max('runs__created_at') - ) + ExportConfig.objects + .select_related('project') + .annotate(last_run_at=Max('runs__created_at')) + .prefetch_related(Prefetch( + 'runs', + queryset=ExportRun.objects.order_by('-created_at'), + to_attr='_all_runs', + )) ) multi = list( - MultiProjectExportConfig.objects.select_related('created_by').annotate( - last_run_at=Max('runs__created_at') - ) + MultiProjectExportConfig.objects + .annotate(last_run_at=Max('runs__created_at')) + .prefetch_related(Prefetch( + 'runs', + queryset=MultiProjectExportRun.objects.order_by('-created_at'), + to_attr='_all_runs', + )) ) all_configs = sorted( single + multi, @@ -79,7 +88,8 @@ def _compute_exports_etag(configs_list): """MD5 fingerprint of (run.id, created_at, status) for each config's latest run.""" parts = [] for config in configs_list: - run = config.runs.order_by('-created_at').first() + all_runs = getattr(config, '_all_runs', None) + run = all_runs[0] if all_runs else None if run: parts.append(f'{run.id}:{run.created_at.isoformat()}:{run.status}') else: From d3438431ea3925bd756635c9af71c0a871507d01 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:19 -0400 Subject: [PATCH 08/29] feat(exports): replace two-table layout with merged paginated config_table partial --- apps/exports/tests/test_list_view.py | 14 +++ templates/exports/exports_home.html | 161 +++++---------------------- 2 files changed, 42 insertions(+), 133 deletions(-) diff --git a/apps/exports/tests/test_list_view.py b/apps/exports/tests/test_list_view.py index 3b329605..697e63e8 100644 --- a/apps/exports/tests/test_list_view.py +++ b/apps/exports/tests/test_list_view.py @@ -194,3 +194,17 @@ def test_returns_log_content(self): ) 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() diff --git a/templates/exports/exports_home.html b/templates/exports/exports_home.html index c3146296..ddf344ff 100644 --- a/templates/exports/exports_home.html +++ b/templates/exports/exports_home.html @@ -43,141 +43,36 @@

{% trans "Export Configurations" %}

{% trans "Run All" %} - - - {% trans "New Export" %} - + {# Split dropdown for New Export / New Multi-Project Export #} +
+ + {% trans "New Export" %} + + + +
- {% if exports.exists %} - - - - - - - - - - - - {% for export in exports %} - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Schedule" %}{% trans "Last Started" %}{% trans "Status" %}{% trans "Actions" %}
- {{ export.name }} - {% if export.is_paused %} - - {% endif %} - - {% if export.is_paused %} - {% trans "Paused" %} - {% elif export.schedule_display %} - {{ export.schedule_display }} - {% else %} - - - {% endif %} - - {% if export.last_run %} - {{ export.last_run.created_at|naturaltime }} - {% else %} - {% trans "Never" %} - {% endif %} - - {% if export.last_run %} -
- {{ export.last_run.status|to_status_icon }} - {{ export.last_run.status }} -
-
- {{ export.last_run.duration|readable_timedelta_short }} -
- {% else %} - - - {% endif %} -
- - {% trans "Edit" %} - -
- {% else %} -
- {% trans "No export configurations found!" %} -
- - {% trans "Add Your First Export" %} - - {% endif %} + {% include "exports/partials/config_table.html" %} - - {% if multi_project_exports.exists %} -
-
-

- {% trans "Multi-Project Export Configurations" %} -

- - - {% trans "New Multi-Project Export" %} - -
- - - - - - - - - - {% for export in multi_project_exports %} - - - - - - {% endfor %} - -
{% trans "Export" %}{% trans "Projects" %}{% trans "Last Run" %}
- {{ export.name }} - {% if export.is_paused %} - - {% endif %} - {{ export.get_projects_display_short }} - {{ export.last_run.status|to_status_icon }} - {{ export.last_run.created_at|naturaltime }} -
-
- {% endif %} {% endblock app %} From a70577eb84cb8327a00bada072c3b0040bd25661 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 09/29] chore(exports): remove dead legacy exports context The legacy exports/multi_project_exports template variables were kept for exports_home.html compatibility; that template now uses the merged config_table partial, so the variables and their queries are dead. Remove them. --- apps/exports/views.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/exports/views.py b/apps/exports/views.py index 24598dd6..b50b9792 100644 --- a/apps/exports/views.py +++ b/apps/exports/views.py @@ -109,24 +109,12 @@ def home(request): page_obj = _merged_export_configs(page_size, page_num) etag = _compute_exports_etag(page_obj.object_list) - exports = ( - ExportConfig.objects - .select_related('project') - .annotate(last_run_at=Max('runs__created_at')) - .order_by('-last_run_at', '-updated_at') - ) - multi_project_exports = MultiProjectExportConfig.objects.order_by( - '-updated_at' - ) - return render(request, 'exports/exports_home.html', { 'active_tab': 'exports', 'configs': page_obj, 'page_size': page_size, 'page_sizes': [10, 20, 50], 'etag': etag, - 'exports': exports, - 'multi_project_exports': multi_project_exports, 'stats_period': readable_timedelta(period, short=True), 'export_stats': _get_export_statistics(current_start, previous_start), 'refresh_stats': _get_refresh_statistics(current_start, previous_start), From 7a40433a03c577b19c04242ba9186dbc591d3107 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:19 -0400 Subject: [PATCH 10/29] feat(refreshes): add config_table partial with pagination, ETag, and log expansion --- apps/refreshes/tests/test_views.py | 55 ++++++ apps/refreshes/urls.py | 2 + apps/refreshes/views.py | 74 +++++++- .../refreshes/partials/config_table.html | 176 ++++++++++++++++++ templates/refreshes/partials/run_log.html | 5 + templates/refreshes/refresh_configs.html | 55 +----- 6 files changed, 311 insertions(+), 56 deletions(-) create mode 100644 templates/refreshes/partials/config_table.html create mode 100644 templates/refreshes/partials/run_log.html diff --git a/apps/refreshes/tests/test_views.py b/apps/refreshes/tests/test_views.py index 79837ba3..d9729d05 100644 --- a/apps/refreshes/tests/test_views.py +++ b/apps/refreshes/tests/test_views.py @@ -277,3 +277,58 @@ def test_post_not_allowed(self): url = reverse('refreshes:fetch_materialized_views') response = authed_client().post(url) assert response.status_code == 405 + + + +class TestRefreshConfigTableView: + @use(authed_client) + def test_requires_login(self): + client = authed_client() + client.logout() + response = client.get(reverse('refreshes:config_table')) + assert response.status_code == 302 + + @use(authed_client, 'db') + def test_returns_200(self): + response = authed_client().get(reverse('refreshes:config_table')) + assert response.status_code == 200 + + @use(authed_client, _refresh_config) + def test_config_appears(self): + config = _refresh_config() + response = authed_client().get(reverse('refreshes:config_table')) + assert config.name in response.content.decode() + + @use(authed_client, database) + def test_pagination_default_10(self): + db_obj = database() + for i in range(15): + RefreshConfig.objects.create( + name=f'Refresh {i}', + database=db_obj, + materialized_views=['public.view1'], + ) + response = authed_client().get(reverse('refreshes:config_table')) + shown = response.content.decode().count('Refresh ') + assert shown == 10 + + @use(authed_client, _refresh_config) + def test_etag_match_returns_no_swap(self): + _refresh_config() + client = authed_client() + response = client.get(reverse('refreshes:config_table')) + match = re.search(r'data-etag="([a-f0-9]+)"', response.content.decode()) + assert match + etag = match.group(1) + response2 = client.get(reverse('refreshes:config_table'), {'etag': etag}) + assert response2.get('HX-Reswap') == 'none' + + @use(authed_client, _refresh_config) + def test_etag_mismatch_returns_content(self): + config = _refresh_config() + response = authed_client().get( + reverse('refreshes:config_table'), {'etag': 'stale'} + ) + assert response.get('HX-Reswap') is None + assert config.name in response.content.decode() + diff --git a/apps/refreshes/urls.py b/apps/refreshes/urls.py index 32195b96..e79eb8bc 100644 --- a/apps/refreshes/urls.py +++ b/apps/refreshes/urls.py @@ -45,4 +45,6 @@ views.fetch_materialized_views, name='fetch_materialized_views', ), + path(r'config-table/', views.config_table, name='config_table'), + path(r'runs//log/', views.run_log, name='run_log'), ] diff --git a/apps/refreshes/views.py b/apps/refreshes/views.py index 17156637..19f60023 100644 --- a/apps/refreshes/views.py +++ b/apps/refreshes/views.py @@ -1,9 +1,12 @@ +import hashlib import logging from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.paginator import EmptyPage, Paginator from django.db import transaction +from django.db.models import Prefetch from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse @@ -19,7 +22,9 @@ ) from apps.web.templatetags.dateformat_tags import readable_timedelta from commcare_sync.views import ( + get_config_page_size, get_hide_skipped_from_request, + get_page_from_request, get_ui_page_size, ) @@ -31,6 +36,19 @@ logger = logging.getLogger(__name__) +def _compute_refresh_etag(configs_list): + parts = [] + for config in configs_list: + # Use prefetched _all_runs if available, else DB query + all_runs = getattr(config, '_all_runs', None) + run = all_runs[0] if all_runs else config.runs.order_by('-created_at').first() + if run: + parts.append(f"{run.id}:{run.created_at.isoformat()}:{run.status}") + else: + parts.append(f"config:{config.id}:no-runs") + return hashlib.md5('|'.join(parts).encode()).hexdigest() + + @login_required def refresh_configs(request): """List all refresh configurations.""" @@ -39,13 +57,28 @@ def refresh_configs(request): current_start = now - period previous_start = current_start - period - configs = RefreshConfig.objects.order_by('-updated_at') + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + configs_qs = RefreshConfig.objects.order_by('-updated_at').prefetch_related( + Prefetch('runs', queryset=RefreshRun.objects.order_by('-created_at'), to_attr='_all_runs') + ) + paginator = Paginator(configs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + + etag = _compute_refresh_etag(page_obj.object_list) + return render( request, 'refreshes/refresh_configs.html', { 'active_tab': 'refreshes', - 'refresh_configs': configs, + 'configs': page_obj, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], + 'etag': etag, 'stats_period': readable_timedelta(period, short=True), 'export_stats': _get_export_statistics(current_start, previous_start), 'refresh_stats': _get_refresh_statistics(current_start, previous_start), @@ -54,6 +87,43 @@ def refresh_configs(request): ) +@login_required +@require_GET +def config_table(request): + """HTMX endpoint: paginated + ETag-guarded refresh config table partial.""" + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + configs_qs = RefreshConfig.objects.order_by('-updated_at').prefetch_related( + Prefetch('runs', queryset=RefreshRun.objects.order_by('-created_at'), to_attr='_all_runs') + ) + paginator = Paginator(configs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + + etag = _compute_refresh_etag(page_obj.object_list) + if request.GET.get('etag') == etag: + response = HttpResponse() + response['HX-Reswap'] = 'none' + return response + + return render(request, 'refreshes/partials/config_table.html', { + 'configs': page_obj, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], + 'etag': etag, + }) + + +@login_required +@require_GET +def run_log(request, run_id): + """HTMX endpoint: log fragment for a RefreshRun.""" + run = get_object_or_404(RefreshRun, id=run_id) + return render(request, 'refreshes/partials/run_log.html', {'run': run}) + + @login_required def create_refresh_config(request): """Create a new refresh configuration.""" diff --git a/templates/refreshes/partials/config_table.html b/templates/refreshes/partials/config_table.html new file mode 100644 index 00000000..07b72dc6 --- /dev/null +++ b/templates/refreshes/partials/config_table.html @@ -0,0 +1,176 @@ +{% load i18n %} +{% load exports_tags %} +{% load humanize %} +{% load dateformat_tags %} + +
+ {% if configs.object_list %} + + + + + + + + + + + {% for config in configs.object_list %} + + + + + + + + + + + + + {% endfor %} +
{% trans "Name" %}{% trans "Schedule" %}{% trans "Last Started" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ config.name }} + {% if config.is_paused %} + + {% endif %} + + {% if config.is_paused %} + {% trans "Paused" %} + {% elif config.schedule_display %} + {{ config.schedule_display }} + {% else %} + + {% endif %} + + {% if config.last_run %} + {{ config.last_run.created_at|naturaltime }} + {% else %} + {% trans "Never" %} + {% endif %} + + {% if config.last_run %} +
+ {{ config.last_run.status|to_status_icon }} + {{ config.last_run.status }} +
+
+ {{ config.last_run.duration|readable_timedelta_short }} +
+ {% else %} + + {% endif %} +
+ {% if config.last_run and config.last_run.status == 'completed' or config.last_run and config.last_run.status == 'failed' %} + + {% else %} + + {% endif %} + + {% trans "Edit" %} + +
+
+
+ + {# Pagination footer #} +
+
+ {% trans "Per page:" %} + {% for size in page_sizes %} + + {{ size }} + + {% endfor %} +
+ {% if configs.paginator.num_pages > 1 %} + + {% endif %} +
+ {% else %} +
+ {% trans "No refresh configurations found!" %} +
+ + {% trans "Add Your First Refresh" %} + + {% endif %} +
diff --git a/templates/refreshes/partials/run_log.html b/templates/refreshes/partials/run_log.html new file mode 100644 index 00000000..59213c48 --- /dev/null +++ b/templates/refreshes/partials/run_log.html @@ -0,0 +1,5 @@ +{% if run.log %} +
{{ run.log }}
+{% else %} +No log available. +{% endif %} diff --git a/templates/refreshes/refresh_configs.html b/templates/refreshes/refresh_configs.html index 315e7948..d9656aad 100644 --- a/templates/refreshes/refresh_configs.html +++ b/templates/refreshes/refresh_configs.html @@ -45,59 +45,6 @@

{% trans "Refresh Configurations" %}

- {% if refresh_configs.exists %} - - - - - - - - - - - - {% for config in refresh_configs %} - - - - - - - - {% endfor %} - -
{% trans "Refresh Config" %}{% trans "Database" %}{% trans "Views" %}{% trans "Created By" %}{% trans "Last Run" %}
- {{ config.name }} - {% if config.is_paused %} - - {% endif %} - {{ config.database.name }}{{ config.materialized_views|length }} - {% if request.user == config.created_by %}{% trans "You" %}{% else %}{{ config.created_by.get_display_name }}{% endif %} - - {% if config.last_run %} - {{ config.last_run.status|to_status_icon }} - {{ config.last_run.created_at|naturaltime }} - {% else %} - {% trans "Never" %} - {% endif %} -
- {% else %} -
- {% trans "No refresh configurations found!" %} -
- - - {% trans "Add Your First Refresh" %} - - {% endif %} + {% include "refreshes/partials/config_table.html" %} {% endblock app %} From fed1eec30af5e426c6357be50314954da3baa9be Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 11/29] feat(forwarding): add config_table partial with pagination, ETag, and log expansion --- apps/forwarding/urls.py | 10 + apps/forwarding/views.py | 81 +++++++- templates/forwarding/forwarders.html | 51 +---- .../forwarding/partials/config_table.html | 176 ++++++++++++++++++ templates/forwarding/partials/run_log.html | 5 + 5 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 templates/forwarding/partials/config_table.html create mode 100644 templates/forwarding/partials/run_log.html diff --git a/apps/forwarding/urls.py b/apps/forwarding/urls.py index bf5f9de4..38d024d3 100644 --- a/apps/forwarding/urls.py +++ b/apps/forwarding/urls.py @@ -60,4 +60,14 @@ views.run_forwarding, name='run_forwarding', ), + path( + 'config-table/', + views.config_table, + name='config_table', + ), + path( + 'runs//log/', + views.run_log, + name='run_log', + ), ] diff --git a/apps/forwarding/views.py b/apps/forwarding/views.py index 2c1bc61c..fac3d671 100644 --- a/apps/forwarding/views.py +++ b/apps/forwarding/views.py @@ -1,13 +1,17 @@ +import hashlib + from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.paginator import EmptyPage, Paginator from django.db import transaction +from django.db.models import Prefetch from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render, get_object_or_404 from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_GET, require_POST from apps.web.decorators import admin_required from apps.web.stats import ( @@ -17,7 +21,12 @@ ) from apps.web.templatetags.dateformat_tags import readable_timedelta -from commcare_sync.views import get_ui_page_size, get_hide_skipped_from_request +from commcare_sync.views import ( + get_config_page_size, + get_hide_skipped_from_request, + get_page_from_request, + get_ui_page_size, +) from .forms import ( ForwardingConfigForm, @@ -28,6 +37,18 @@ from .tasks import run_forwarding_task +def _compute_forwarding_etag(configs_list): + parts = [] + for config in configs_list: + all_runs = getattr(config, '_all_runs', None) + run = all_runs[0] if all_runs else config.runs.order_by('-created_at').first() + if run: + parts.append(f"{run.id}:{run.created_at.isoformat()}:{run.status}") + else: + parts.append(f"config:{config.id}:no-runs") + return hashlib.md5('|'.join(parts).encode()).hexdigest() + + @login_required def forwarders(request): """List all forwarding configurations.""" @@ -36,13 +57,28 @@ def forwarders(request): current_start = now - period previous_start = current_start - period - fwd_configs = ForwardingConfig.objects.order_by('-updated_at') + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + configs_qs = ForwardingConfig.objects.order_by('-updated_at').prefetch_related( + Prefetch('runs', queryset=ForwardingRun.objects.order_by('-created_at'), to_attr='_all_runs') + ) + paginator = Paginator(configs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + + etag = _compute_forwarding_etag(page_obj.object_list) + return render( request, 'forwarding/forwarders.html', { 'active_tab': 'forwarders', - 'forwarders': fwd_configs, + 'configs': page_obj, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], + 'etag': etag, 'stats_period': readable_timedelta(period, short=True), 'export_stats': _get_export_statistics(current_start, previous_start), 'refresh_stats': _get_refresh_statistics(current_start, previous_start), @@ -51,6 +87,43 @@ def forwarders(request): ) +@login_required +@require_GET +def config_table(request): + """HTMX endpoint: paginated + ETag-guarded forwarding config table partial.""" + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + configs_qs = ForwardingConfig.objects.order_by('-updated_at').prefetch_related( + Prefetch('runs', queryset=ForwardingRun.objects.order_by('-created_at'), to_attr='_all_runs') + ) + paginator = Paginator(configs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + + etag = _compute_forwarding_etag(page_obj.object_list) + if request.GET.get('etag') == etag: + response = HttpResponse() + response['HX-Reswap'] = 'none' + return response + + return render(request, 'forwarding/partials/config_table.html', { + 'configs': page_obj, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], + 'etag': etag, + }) + + +@login_required +@require_GET +def run_log(request, run_id): + """HTMX endpoint: log fragment for a ForwardingRun.""" + run = get_object_or_404(ForwardingRun, id=run_id) + return render(request, 'forwarding/partials/run_log.html', {'run': run}) + + @login_required def create_forwarding_config(request): """Create a new forwarding configuration.""" diff --git a/templates/forwarding/forwarders.html b/templates/forwarding/forwarders.html index 89ae4e72..efc709f4 100644 --- a/templates/forwarding/forwarders.html +++ b/templates/forwarding/forwarders.html @@ -45,55 +45,6 @@

{% trans "Forwarding Configurations" %}

- {% if forwarders.exists %} - - - - - - - - - - - {% for forwarder in forwarders %} - - - - - - - {% endfor %} - -
{% trans "Forwarder" %}{% trans "Database" %}{% trans "Destination" %}{% trans "Last Run" %}
- {{ forwarder.name }} - {% if forwarder.is_paused %} - - {% endif %} - {{ forwarder.database.name }}{{ forwarder.destination.name }} - {% if forwarder.last_run %} - {{ forwarder.last_run.status|to_status_icon }} - {{ forwarder.last_run.created_at|naturaltime }} - {% else %} - {% trans "Never" %} - {% endif %} -
- {% else %} -
- {% trans "No forwarding configurations found!" %} -
- - - {% trans "Add Your First Forwarder" %} - - {% endif %} + {% include "forwarding/partials/config_table.html" %} {% endblock app %} diff --git a/templates/forwarding/partials/config_table.html b/templates/forwarding/partials/config_table.html new file mode 100644 index 00000000..01f281a3 --- /dev/null +++ b/templates/forwarding/partials/config_table.html @@ -0,0 +1,176 @@ +{% load i18n %} +{% load exports_tags %} +{% load humanize %} +{% load dateformat_tags %} + +
+ {% if configs.object_list %} + + + + + + + + + + + {% for config in configs.object_list %} + + + + + + + + + + + + + {% endfor %} +
{% trans "Name" %}{% trans "Schedule" %}{% trans "Last Started" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ config.name }} + {% if config.is_paused %} + + {% endif %} + + {% if config.is_paused %} + {% trans "Paused" %} + {% elif config.schedule_display %} + {{ config.schedule_display }} + {% else %} + + {% endif %} + + {% if config.last_run %} + {{ config.last_run.created_at|naturaltime }} + {% else %} + {% trans "Never" %} + {% endif %} + + {% if config.last_run %} +
+ {{ config.last_run.status|to_status_icon }} + {{ config.last_run.status }} +
+
+ {{ config.last_run.duration|readable_timedelta_short }} +
+ {% else %} + + {% endif %} +
+ {% if config.last_run and config.last_run.status == 'completed' or config.last_run and config.last_run.status == 'failed' %} + + {% else %} + + {% endif %} + + {% trans "Edit" %} + +
+
+
+ + {# Pagination footer #} +
+
+ {% trans "Per page:" %} + {% for size in page_sizes %} + + {{ size }} + + {% endfor %} +
+ {% if configs.paginator.num_pages > 1 %} + + {% endif %} +
+ {% else %} +
+ {% trans "No forwarding configurations found!" %} +
+ + {% trans "Add Your First Forwarder" %} + + {% endif %} +
diff --git a/templates/forwarding/partials/run_log.html b/templates/forwarding/partials/run_log.html new file mode 100644 index 00000000..59213c48 --- /dev/null +++ b/templates/forwarding/partials/run_log.html @@ -0,0 +1,5 @@ +{% if run.log %} +
{{ run.log }}
+{% else %} +No log available. +{% endif %} From 6e9b3a592f2f7bf20f54600fe0254de5be95fef7 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 12/29] test: add smoke tests for list pages --- apps/exports/tests/test_list_view.py | 71 +++++++++++++++++++++++++++- apps/refreshes/tests/test_views.py | 54 +++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/apps/exports/tests/test_list_view.py b/apps/exports/tests/test_list_view.py index 697e63e8..c688d078 100644 --- a/apps/exports/tests/test_list_view.py +++ b/apps/exports/tests/test_list_view.py @@ -3,7 +3,11 @@ from django.urls import reverse from unmagic import use -from apps.exports.models import ExportConfig +from apps.exports.models import ( + ExportConfig, + ExportRun, + MultiProjectExportRun, +) from apps.exports.tests.fixtures import ( export_config, export_run, @@ -208,3 +212,68 @@ 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() + + +class TestExportsHomeSmoke: + """Smoke tests: full-page renders with configs in various run states.""" + + @use(authed_client, export_config) + def test_renders_with_no_runs(self): + config = export_config() + response = authed_client().get(reverse('exports:home')) + assert response.status_code == 200 + assert config.name in response.content.decode() + + @use(authed_client, export_config) + def test_renders_with_completed_run(self): + config = export_config() + ExportRun.objects.create( + base_export_config=config, + status=ExportRun.Status.COMPLETED, + log='Exported 100 rows.', + ) + response = authed_client().get(reverse('exports:home')) + assert response.status_code == 200 + content = response.content.decode() + assert config.name in content + assert 'completed' in content + + @use(authed_client, export_config) + def test_renders_with_failed_run(self): + ExportRun.objects.create( + base_export_config=export_config(), + status=ExportRun.Status.FAILED, + log='Error: connection refused.', + ) + response = authed_client().get(reverse('exports:home')) + assert response.status_code == 200 + assert 'failed' in response.content.decode() + + @use(authed_client, export_config) + def test_renders_with_started_run(self): + ExportRun.objects.create( + base_export_config=export_config(), + status=ExportRun.Status.STARTED, + ) + response = authed_client().get(reverse('exports:home')) + assert response.status_code == 200 + assert 'started' in response.content.decode() + + @use(authed_client, multi_export_config) + def test_renders_multi_config_with_completed_run(self): + config = multi_export_config() + MultiProjectExportRun.objects.create( + base_export_config=config, + status=MultiProjectExportRun.Status.COMPLETED, + ) + response = authed_client().get(reverse('exports:home')) + assert response.status_code == 200 + assert config.name in response.content.decode() + + @use(authed_client) + def test_new_export_split_dropdown_present(self): + response = authed_client().get(reverse('exports:home')) + assert response.status_code == 200 + content = response.content.decode() + assert 'dropdown-toggle-split' in content + assert 'Multi-Project Export' in content diff --git a/apps/refreshes/tests/test_views.py b/apps/refreshes/tests/test_views.py index d9729d05..e225eebf 100644 --- a/apps/refreshes/tests/test_views.py +++ b/apps/refreshes/tests/test_views.py @@ -332,3 +332,57 @@ def test_etag_mismatch_returns_content(self): assert response.get('HX-Reswap') is None assert config.name in response.content.decode() + + +class TestRefreshesListPageSmoke: + """Smoke tests: full-page renders with configs in various run states.""" + + @use(authed_client, 'db') + def test_renders_200(self): + response = authed_client().get(reverse('refreshes:refresh_configs')) + assert response.status_code == 200 + + @use(authed_client, 'db') + def test_includes_config_table_div(self): + response = authed_client().get(reverse('refreshes:refresh_configs')) + assert 'id="refreshes-config-table"' in response.content.decode() + + @use(authed_client, _refresh_config) + def test_renders_with_no_runs(self): + config = _refresh_config() + response = authed_client().get(reverse('refreshes:refresh_configs')) + assert response.status_code == 200 + assert config.name in response.content.decode() + + @use(authed_client, _refresh_config) + def test_renders_with_completed_run(self): + RefreshRun.objects.create( + refresh_config=_refresh_config(), + status=RefreshRun.Status.COMPLETED, + log='Refreshed 2 views.', + ) + response = authed_client().get(reverse('refreshes:refresh_configs')) + assert response.status_code == 200 + assert 'completed' in response.content.decode() + + @use(authed_client, _refresh_config) + def test_renders_with_failed_run(self): + RefreshRun.objects.create( + refresh_config=_refresh_config(), + status=RefreshRun.Status.FAILED, + log='Error refreshing.', + ) + response = authed_client().get(reverse('refreshes:refresh_configs')) + assert response.status_code == 200 + assert 'failed' in response.content.decode() + + @use(authed_client, _refresh_config) + def test_renders_with_started_run(self): + RefreshRun.objects.create( + refresh_config=_refresh_config(), + status=RefreshRun.Status.STARTED, + ) + response = authed_client().get(reverse('refreshes:refresh_configs')) + assert response.status_code == 200 + assert 'started' in response.content.decode() + From 930dc31d2858853d0988483e691df13c49987c6d Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 13/29] feat: add status filter constant and pagination to run history views --- apps/exports/views.py | 65 ++++++++++++++++++------------- apps/forwarding/views.py | 48 +++++++++++++---------- apps/refreshes/views.py | 46 +++++++++++++--------- commcare_sync/tests/test_views.py | 37 +++++++++++++++++- commcare_sync/views.py | 24 ++++++++++++ 5 files changed, 151 insertions(+), 69 deletions(-) diff --git a/apps/exports/views.py b/apps/exports/views.py index b50b9792..0a918105 100644 --- a/apps/exports/views.py +++ b/apps/exports/views.py @@ -30,8 +30,8 @@ from apps.web.templatetags.dateformat_tags import readable_timedelta from commcare_sync.views import ( get_config_page_size, - get_hide_skipped_from_request, get_page_from_request, + get_run_statuses_from_request, get_ui_page_size, ) @@ -316,39 +316,44 @@ def delete_multi_export_config(request, export_id): @login_required def export_details(request, export_id): export = get_object_or_404(ExportConfig, id=export_id) - runs = export.runs - hide_skipped = get_hide_skipped_from_request(request) - if hide_skipped: - runs = runs.exclude( - status__in=[ExportRun.Status.SKIPPED, ExportRun.Status.QUEUED] - ) - + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(export.runs.order_by('-created_at'), page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) return render( request, 'exports/export_details.html', { 'active_tab': 'exports', 'export': export, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], - 'hide_skipped': hide_skipped, - 'run_history_url': reverse( - 'exports:run_history_table', args=[export.id] - ), + 'runs': page_obj, + 'run_history_url': reverse('exports:run_history_table', args=[export.id]), + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) @login_required +@require_GET def run_history_table(request, export_id): """HTMX endpoint to refresh the run history table.""" export = get_object_or_404(ExportConfig, id=export_id) - runs = export.runs - hide_skipped = get_hide_skipped_from_request(request) is_multi_project = request.GET.get('is_multi_project') == 'true' - if hide_skipped: - runs = runs.exclude( - status__in=[ExportRun.Status.SKIPPED, ExportRun.Status.QUEUED] - ) + runs_qs = export.runs.order_by('-created_at') + statuses = get_run_statuses_from_request(request) + if statuses is not None: + runs_qs = runs_qs.filter(status__in=statuses) + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(runs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) run_history_url = reverse('exports:run_history_table', args=[export.id]) if is_multi_project: run_history_url += '?is_multi_project=true' @@ -357,9 +362,11 @@ def run_history_table(request, export_id): 'exports/partials/run_history_table.html', { 'export': export, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], + 'runs': page_obj, 'is_multi_project': is_multi_project, 'run_history_url': run_history_url, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) @@ -396,12 +403,13 @@ def _download_config_file(export_file_field): @login_required def multi_export_details(request, export_id): export = get_object_or_404(MultiProjectExportConfig, id=export_id) - runs = export.runs - hide_skipped = get_hide_skipped_from_request(request) - if hide_skipped: - runs = runs.exclude( - status__in=[ExportRun.Status.SKIPPED, ExportRun.Status.QUEUED] - ) + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(export.runs.order_by('-created_at'), page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) run_history_url = ( reverse('exports:run_history_table', args=[export.id]) + '?is_multi_project=true' @@ -412,9 +420,10 @@ def multi_export_details(request, export_id): { 'active_tab': 'exports', 'export': export, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], - 'hide_skipped': hide_skipped, + 'runs': page_obj, 'run_history_url': run_history_url, + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) diff --git a/apps/forwarding/views.py b/apps/forwarding/views.py index fac3d671..c1de3ad4 100644 --- a/apps/forwarding/views.py +++ b/apps/forwarding/views.py @@ -23,9 +23,8 @@ from commcare_sync.views import ( get_config_page_size, - get_hide_skipped_from_request, get_page_from_request, - get_ui_page_size, + get_run_statuses_from_request, ) from .forms import ( @@ -299,43 +298,52 @@ def delete_destination(request, destination_id): def forwarder_details(request, forwarder_id): """Display details for a forwarding configuration.""" forwarder = get_object_or_404(ForwardingConfig, id=forwarder_id) - runs = forwarder.runs - hide_skipped = get_hide_skipped_from_request(request) - if hide_skipped: - runs = runs.exclude( - status__in=[ForwardingRun.Status.SKIPPED, ForwardingRun.Status.QUEUED] - ) - + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(forwarder.runs.order_by('-created_at'), page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) return render( request, 'forwarding/forwarder_details.html', { 'active_tab': 'forwarders', 'forwarder': forwarder, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], - 'hide_skipped': hide_skipped, + 'runs': page_obj, + 'run_history_url': reverse('forwarding:run_history_table', args=[forwarder.id]), + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) @login_required +@require_GET def run_history_table(request, forwarder_id): """HTMX endpoint to refresh the run history table.""" forwarder = get_object_or_404(ForwardingConfig, id=forwarder_id) - runs = forwarder.runs - hide_skipped = get_hide_skipped_from_request(request) - - if hide_skipped: - runs = runs.exclude( - status__in=[ForwardingRun.Status.SKIPPED, ForwardingRun.Status.QUEUED] - ) - + runs_qs = forwarder.runs.order_by('-created_at') + statuses = get_run_statuses_from_request(request) + if statuses is not None: + runs_qs = runs_qs.filter(status__in=statuses) + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(runs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) return render( request, 'forwarding/partials/run_history_table.html', { 'forwarder': forwarder, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], + 'runs': page_obj, + 'run_history_url': reverse('forwarding:run_history_table', args=[forwarder.id]), + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) diff --git a/apps/refreshes/views.py b/apps/refreshes/views.py index 19f60023..56465a0b 100644 --- a/apps/refreshes/views.py +++ b/apps/refreshes/views.py @@ -23,9 +23,8 @@ from apps.web.templatetags.dateformat_tags import readable_timedelta from commcare_sync.views import ( get_config_page_size, - get_hide_skipped_from_request, get_page_from_request, - get_ui_page_size, + get_run_statuses_from_request, ) from .db_utils import check_connection, get_materialized_views @@ -212,21 +211,23 @@ def delete_refresh_config(request, config_id): def refresh_details(request, config_id): """Display details for a refresh configuration.""" config = get_object_or_404(RefreshConfig, id=config_id) - runs = config.runs - hide_skipped = get_hide_skipped_from_request(request) - if hide_skipped: - runs = runs.exclude( - status__in=[RefreshRun.Status.SKIPPED, RefreshRun.Status.QUEUED] - ) - + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(config.runs.order_by('-created_at'), page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) return render( request, 'refreshes/refresh_details.html', { 'active_tab': 'refreshes', 'config': config, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], - 'hide_skipped': hide_skipped, + 'runs': page_obj, + 'run_history_url': reverse('refreshes:run_history_table', args=[config.id]), + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) @@ -236,19 +237,26 @@ def refresh_details(request, config_id): def run_history_table(request, config_id): """HTMX endpoint to refresh the run history table.""" config = get_object_or_404(RefreshConfig, id=config_id) - runs = config.runs - hide_skipped = get_hide_skipped_from_request(request) - if hide_skipped: - runs = runs.exclude( - status__in=[RefreshRun.Status.SKIPPED, RefreshRun.Status.QUEUED] - ) - + runs_qs = config.runs.order_by('-created_at') + statuses = get_run_statuses_from_request(request) + if statuses is not None: + runs_qs = runs_qs.filter(status__in=statuses) + page_size = get_config_page_size(request) + page_num = get_page_from_request(request) + paginator = Paginator(runs_qs, page_size) + try: + page_obj = paginator.page(page_num) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) return render( request, 'refreshes/partials/run_history_table.html', { 'config': config, - 'runs': runs.order_by('-created_at')[: get_ui_page_size(request)], + 'runs': page_obj, + 'run_history_url': reverse('refreshes:run_history_table', args=[config.id]), + 'page_size': page_size, + 'page_sizes': [10, 20, 50], }, ) diff --git a/commcare_sync/tests/test_views.py b/commcare_sync/tests/test_views.py index ae40850a..c7737a60 100644 --- a/commcare_sync/tests/test_views.py +++ b/commcare_sync/tests/test_views.py @@ -1,9 +1,14 @@ import pytest from django.test import RequestFactory -from commcare_sync.views import get_config_page_size, get_page_from_request +from commcare_sync.views import ( + _VALID_CONFIG_PAGE_SIZES, + get_config_page_size, + get_page_from_request, + get_run_statuses_from_request, +) -VALID_PAGE_SIZES = [10, 20, 50] +VALID_PAGE_SIZES = list(_VALID_CONFIG_PAGE_SIZES) class TestGetConfigPageSize: @@ -44,3 +49,31 @@ def test_negative_clamped_to_1(self): def test_non_integer_returns_1(self): assert get_page_from_request(self._req('page=abc')) == 1 + + +class TestGetRunStatusesFromRequest: + def _req(self, params=''): + rf = RequestFactory() + return rf.get(f'/?{params}') + + def test_returns_none_when_no_param(self): + assert get_run_statuses_from_request(self._req()) is None + + def test_returns_empty_list_when_sentinel_but_no_statuses(self): + assert get_run_statuses_from_request( + self._req('has_status_filter=1') + ) == [] + + def test_returns_checked_statuses(self): + request = self._req( + 'has_status_filter=1&status_filter=queued&status_filter=completed' + ) + assert set(get_run_statuses_from_request(request)) == { + 'queued', 'completed' + } + + def test_ignores_invalid_status_values(self): + request = self._req( + 'has_status_filter=1&status_filter=bogus&status_filter=completed' + ) + assert set(get_run_statuses_from_request(request)) == {'completed'} diff --git a/commcare_sync/views.py b/commcare_sync/views.py index 28f48eb5..72b38f4b 100644 --- a/commcare_sync/views.py +++ b/commcare_sync/views.py @@ -1,5 +1,7 @@ from django.conf import settings +from apps.commcare.models import RunBaseModel + _VALID_CONFIG_PAGE_SIZES = (10, 20, 50) @@ -35,3 +37,25 @@ def get_page_from_request(request): return max(int(request.GET.get('page', 1)), 1) except ValueError: return 1 + + +# Per-run statuses available for filtering. RunBaseModel.Status is the shared base +# enum (queued/started/completed/failed/skipped); ExportRunBase additionally +# defines MULTIPLE — an aggregate for multi-project parent runs — which is +# deliberately not a per-run filter state, so deriving from the base excludes it. +_VALID_RUN_STATUSES = set(RunBaseModel.Status.values) + + +def get_run_statuses_from_request(request): + """Return list of statuses to filter runs by, or None if no filter active. + + Returns None → filter not submitted; show all runs (initial page load). + Returns list → filter active; show only runs whose status is in the list + (list may be empty, which means show nothing). + """ + if 'has_status_filter' not in request.GET: + return None + return [ + s for s in request.GET.getlist('status_filter') + if s in _VALID_RUN_STATUSES + ] From 7af9250c7b0d886c8462668e0e3264aba801dd34 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20run=20history=20component=20?= =?UTF-8?q?=E2=80=94=20inline=20status=20filter=20dropdown,=20remove=20old?= =?UTF-8?q?=20filter=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/web/components/run_history.html | 161 +++++++++++++++++----- 1 file changed, 123 insertions(+), 38 deletions(-) diff --git a/templates/web/components/run_history.html b/templates/web/components/run_history.html index 120e3f77..7f3a9194 100644 --- a/templates/web/components/run_history.html +++ b/templates/web/components/run_history.html @@ -1,63 +1,148 @@ {% load i18n %}
-

{% trans "Run History" %}

-
-
+
+

{% trans "Run History" %}

+
{% if show_force_sync %} -
-
- - {% if hide_skipped %} - +
-
-
-
-
+
+
{% trans "Waiting for task to start..." %} From cbb336f36e2e4c47ddc22db3172e1cdb115f5832 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20redesign=20detail=20page=20header?= =?UTF-8?q?s=20=E2=80=94=20inline=20buttons,=20Schedule=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/exports/export_details.html | 75 ++++++++--------- .../partials/multi_export_details.html | 81 ++++++++++--------- templates/forwarding/forwarder_details.html | 65 +++++++-------- templates/refreshes/refresh_details.html | 58 ++++++------- 4 files changed, 139 insertions(+), 140 deletions(-) diff --git a/templates/exports/export_details.html b/templates/exports/export_details.html index 211f6ce0..f450a173 100644 --- a/templates/exports/export_details.html +++ b/templates/exports/export_details.html @@ -5,31 +5,43 @@ {% load dateformat_tags %} {% block app %}
-
-
-

- {% blocktrans with name=export.name %}{{ name }} - Details{% endblocktrans %} -

-
-
- + -
+
{% trans "Project Space" %}

{{ export.project.domain }} - +

@@ -42,31 +54,20 @@

{{ export.account.username }}

{{ export.database.name }}

- {% trans "Last Modified" %} -

{{ export.updated_at|naturaltime }}

+ {% trans "Schedule" %} +

+ {% if export.schedule_display %} + {{ export.schedule_display }} + {% else %} + + {% endif %} +

-
{% include 'exports/partials/run_history.html' %} {% endblock app %} {% block page_js %} {% include 'web/components/run_button_script.html' with run_url_name='exports:run_export' object_id=export.id include_force_sync=True %} - {% include 'web/components/expandable_logs_script.html' %} {% endblock page_js %} diff --git a/templates/exports/partials/multi_export_details.html b/templates/exports/partials/multi_export_details.html index 65d9cfda..0c09f9cf 100644 --- a/templates/exports/partials/multi_export_details.html +++ b/templates/exports/partials/multi_export_details.html @@ -1,15 +1,34 @@ {% load i18n %} {% load humanize %}
-
-
-

- {% blocktrans with name=export.name %}{{ name }} - Details{% endblocktrans %} -

-
-
+ -
+
{% trans "Project Spaces" %}

@@ -28,7 +47,9 @@

target="_blank" style="white-space: nowrap;" >{{ project.domain }} - + {% if not forloop.last %}
{% endif %} {% endfor %} @@ -43,36 +64,16 @@

{{ export.account.username }}

{{ export.database.name }}

- {% trans "Last Modified" %} -

{{ export.updated_at|naturaltime }}

+ {% trans "Schedule" %} +

+ {% if export.schedule_display %} + {{ export.schedule_display }} + {% else %} + + {% endif %} +

-
-
- {% csrf_token %} -
+{# Hidden CSRF token required by the Run Now button's POST request #} +
{% csrf_token %}
diff --git a/templates/forwarding/forwarder_details.html b/templates/forwarding/forwarder_details.html index 259c04fe..baec907c 100644 --- a/templates/forwarding/forwarder_details.html +++ b/templates/forwarding/forwarder_details.html @@ -5,25 +5,28 @@ {% load dateformat_tags %} {% block app %}
-
-
-

- {% blocktrans with name=forwarder.name %}{{ name }} - Details{% endblocktrans %} -

-
-
- + -
+
{% trans "Database" %}

{{ forwarder.database.name }}

@@ -33,41 +36,35 @@

{{ forwarder.database.name }}

{{ forwarder.destination.name }}

- {% trans "Last Modified" %} -

{{ forwarder.updated_at|naturaltime }}

+ {% trans "Schedule" %} +

+ {% if forwarder.schedule_display %} + {{ forwarder.schedule_display }} + {% else %} + + {% endif %} +

-
+
{% trans "Query" %}
{{ forwarder.query }}
{% if forwarder.query_params %} -
+
{% trans "Query Parameters" %}
{{ forwarder.query_params }}
{% endif %} -
-
- {% csrf_token %} -
+
{% csrf_token %}
{% include 'forwarding/partials/run_history.html' %} {% endblock app %} {% block page_js %} {% include 'web/components/run_button_script.html' with run_url_name='forwarding:run_forwarding' object_id=forwarder.id include_force_sync=False %} - {% include 'web/components/expandable_logs_script.html' %} {% endblock page_js %} diff --git a/templates/refreshes/refresh_details.html b/templates/refreshes/refresh_details.html index 2fc0e097..e849ed54 100644 --- a/templates/refreshes/refresh_details.html +++ b/templates/refreshes/refresh_details.html @@ -5,35 +5,44 @@ {% load dateformat_tags %} {% block app %}
-
-
-

- {% blocktrans with name=config.name %}{{ name }} - Details{% endblocktrans %} -

-
-
- + -
+
{% trans "Database" %}

{{ config.database.name }}

- {% trans "Last Modified" %} -

{{ config.updated_at|naturaltime }}

+ {% trans "Schedule" %} +

+ {% if config.schedule_display %} + {{ config.schedule_display }} + {% else %} + + {% endif %} +

-
+
{% trans "Materialized Views (in refresh order)" %}{{ config.updated_at|naturaltime }}
-
{% include 'refreshes/partials/run_history.html' %} {% endblock app %} From ada5817cf98774ea59bfc79ab9a0f6a3ccdda176 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 16/29] =?UTF-8?q?feat(exports):=20run=20history=20table=20?= =?UTF-8?q?=E2=80=94=20new=20columns,=20pagination,=20HTMX=20log=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exports/partials/run_history_table.html | 263 +++++++++++------- 1 file changed, 167 insertions(+), 96 deletions(-) diff --git a/templates/exports/partials/run_history_table.html b/templates/exports/partials/run_history_table.html index e36317a3..2cc0554a 100644 --- a/templates/exports/partials/run_history_table.html +++ b/templates/exports/partials/run_history_table.html @@ -1,114 +1,185 @@ {% load i18n %} {% load dateformat_tags %} {% load exports_tags %} - - - - - - {% if is_partial_list %} - - {% endif %} - - - {% if not is_multi_project %} - - {% endif %} - {% if not is_partial_list %} - - {% endif %} - - - - {% for export_run in runs %} +
{% trans "Queued" %}{% trans "Started" %}{% trans "Project" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Log" %}{% trans "Config File" %}
+ - - - {% if is_partial_list %} - - {% endif %} - - - {% if not is_multi_project %} + + + + + + + {% for run in runs %} + - {% endif %} - - {% if not is_partial_list %} - - {% endif %} - - {% if not is_multi_project %} - - + - {% endif %} - {% endfor %} - -
- - {% if export_run.triggered_from_ui %} - - {% elif export_run.triggered_from_ui == False %} - - {% else %} - - {% endif %} - - - {% if is_multi_project %}{{ export_run.created_at }}{% else %}{{ export_run.created_at }}{% endif %} - {{ export_run.started_at|default:'-' }}{{ export_run.project.domain }}{{ export_run.duration|readable_timedelta }}{{ export_run.status|to_status_icon }} {{ export_run.status }}{% trans "Created At" %}{% trans "Status" %}{% trans "Actions" %}
- - + + {% if run.triggered_from_ui %} - - {% trans "View log" %} - - - {% if export_run.export_config_version.field_dict.config_file %} + {% elif run.triggered_from_ui == False %} + + {% else %} + + {% endif %} + + {% if is_multi_project %} {{ run.created_at }} - - {% else %} - {% trans "Not available" %} + {{ run.created_at }} {% endif %}
-
-{% if export_run.log %}{{ export_run.log }}{% else %}{% trans "No logs yet…" %}{% endif %}
+
+ {{ run.status|to_status_icon }} {{ run.status }} + {% if run.duration %} + ({{ run.duration|readable_timedelta }}) + {% endif %} + +
+ {% if not is_multi_project %} + {% if run.status == 'completed' or run.status == 'failed' %} + + {% else %} + + {% endif %} + {% if run.export_config_version and run.export_config_version.field_dict.config_file %} + + + {% trans "Config" %} + + {% endif %} + {% endif %} +
+ {% if not is_multi_project %} + + +
+ + + {% endif %} + {% endfor %} + + + + {# Pagination footer — uses HTMX so filter state is preserved #} +
+
+ {% trans "Per page:" %} + {% for size in page_sizes %} + + {% endfor %} +
+ {% if runs.paginator.num_pages > 1 %} + + {% endif %} +
+
From 792f01f9ecf87a3897f4896b56850a5ea23ad455 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:20 -0400 Subject: [PATCH 17/29] =?UTF-8?q?feat(refreshes):=20run=20history=20table?= =?UTF-8?q?=20=E2=80=94=20new=20columns,=20pagination,=20HTMX=20log=20load?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../refreshes/partials/run_history_table.html | 320 +++++++++++------- 1 file changed, 199 insertions(+), 121 deletions(-) diff --git a/templates/refreshes/partials/run_history_table.html b/templates/refreshes/partials/run_history_table.html index eef4c79a..030e8975 100644 --- a/templates/refreshes/partials/run_history_table.html +++ b/templates/refreshes/partials/run_history_table.html @@ -1,132 +1,210 @@ {% load i18n %} {% load dateformat_tags %} {% load exports_tags %} - - - - - - - - - - - - {% for refresh_run in runs %} +
{% trans "Queued" %}{% trans "Started" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Log" %}
+ - - - - - + + + - - - - {% if refresh_run.view_results %} - - + + {% for run in runs %} + + + + + + + - {% endif %} - {% endfor %} - -
- - {% if refresh_run.triggered_from_ui %} - - {% elif refresh_run.triggered_from_ui == False %} - - {% else %} - - {% endif %} - - {{ refresh_run.created_at }} - {{ refresh_run.started_at|default:'-' }}{{ refresh_run.duration|readable_timedelta }} - {{ refresh_run.status|to_status_icon }} {{ refresh_run.status }} - - - {% trans "View log" %} - - {% if refresh_run.view_results %} - - {% trans "View details" %} - - {% endif %} - {% trans "Created At" %}{% trans "Status" %}{% trans "Actions" %}
-
-{% if refresh_run.log %}{{ refresh_run.log }}{% else %}{% trans "No logs yet…" %}{% endif %}
-
-
{% trans "Per-View Results" %}
- - - - - - - - - - - {% for view_name, result in refresh_run.view_results.items %} - - - - - - - {% endfor %} - -
{% trans "View" %}{% trans "Status" %}{% trans "Duration" %}{% trans "Message" %}
{{ view_name }} - {% if result.status == 'success' %} - - {% trans "Success" %} - {% else %} - - {% trans "Failed" %} - {% endif %} - {{ result.duration|floatformat:2 }}s{{ result.message|default:'-' }}
+
+ + {% if run.triggered_from_ui %} + + {% elif run.triggered_from_ui == False %} + + {% else %} + + {% endif %} + + {{ run.created_at }} + + {{ run.status|to_status_icon }} {{ run.status }} + {% if run.duration %} + ({{ run.duration|readable_timedelta }}) + {% endif %} + +
+ {% if run.status == 'completed' or run.status == 'failed' %} + + {% else %} + + {% endif %} + {% if run.view_results %} + + {% endif %} +
+
+
+ {% if run.view_results %} + + +
{% trans "Per-View Results" %}
+ + + + + + + + + + + {% for view_name, result in run.view_results.items %} + + + + + + + {% endfor %} + +
{% trans "View" %}{% trans "Status" %}{% trans "Duration" %}{% trans "Message" %}
{{ view_name }} + {% if result.status == 'success' %} + + {% trans "Success" %} + {% else %} + + {% trans "Failed" %} + {% endif %} + {{ result.duration|floatformat:2 }}s{{ result.message|default:'-' }}
+ + + {% endif %} + {% endfor %} + + + + {# Pagination footer #} +
+
+ {% trans "Per page:" %} + {% for size in page_sizes %} + + {% endfor %} +
+ {% if runs.paginator.num_pages > 1 %} + + {% endif %} +
+
From 4c94ecaa96598983899e59a5db080f82b7b65b57 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:21 -0400 Subject: [PATCH 18/29] =?UTF-8?q?feat(forwarding):=20run=20history=20table?= =?UTF-8?q?=20=E2=80=94=20new=20columns,=20pagination,=20HTMX=20log=20load?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../partials/run_history_table.html | 223 ++++++++++++------ 1 file changed, 153 insertions(+), 70 deletions(-) diff --git a/templates/forwarding/partials/run_history_table.html b/templates/forwarding/partials/run_history_table.html index d65e978d..1a5119bf 100644 --- a/templates/forwarding/partials/run_history_table.html +++ b/templates/forwarding/partials/run_history_table.html @@ -1,80 +1,163 @@ {% load i18n %} {% load dateformat_tags %} {% load exports_tags %} - - - - - - - - - - - - {% for forwarding_run in runs %} +
{% trans "Queued" %}{% trans "Started" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Log" %}
+ - - - - - + + + + + + {% for run in runs %} + + - - - - - {% endfor %} - -
- - {% if forwarding_run.triggered_from_ui %} - - {% elif forwarding_run.triggered_from_ui == False %} - - {% else %} - - {% endif %} - - {{ forwarding_run.created_at }} - {{ forwarding_run.started_at|default:'-' }}{{ forwarding_run.duration|readable_timedelta }} - {{ forwarding_run.status|to_status_icon }} {{ forwarding_run.status }} - - + {% trans "Created At" %}{% trans "Status" %}{% trans "Actions" %}
- + {% if run.triggered_from_ui %} + + {% elif run.triggered_from_ui == False %} + + {% else %} + + {% endif %} - {% trans "View log" %} - -
-
-{% if forwarding_run.log %}{{ forwarding_run.log }}{% else %}{% trans "No logs yet…" %}{% endif %}
-
+ {{ run.created_at }} + + + {{ run.status|to_status_icon }} {{ run.status }} + {% if run.duration %} + ({{ run.duration|readable_timedelta }}) + {% endif %} + + + {% if run.status == 'completed' or run.status == 'failed' %} + + {% else %} + + {% endif %} + + + + +
+ + + {% endfor %} + + + + {# Pagination footer #} +
+
+ {% trans "Per page:" %} + {% for size in page_sizes %} + + {% endfor %} +
+ {% if runs.paginator.num_pages > 1 %} + + {% endif %} +
+
From 4ce4c77304d9dbd825deb03953874a23f7a018ad Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 13 May 2026 00:35:21 -0400 Subject: [PATCH 19/29] test: smoke tests for detail pages and run history table endpoints --- apps/exports/tests/test_details_view.py | 132 ++++++++++++++++++ apps/forwarding/tests/fixtures.py | 25 ++++ apps/forwarding/tests/test_details_view.py | 150 +++++++++++++++++++++ apps/forwarding/tests/test_models.py | 10 +- apps/refreshes/tests/test_details_view.py | 131 ++++++++++++++++++ apps/refreshes/tests/test_views.py | 31 ++++- 6 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 apps/exports/tests/test_details_view.py create mode 100644 apps/forwarding/tests/fixtures.py create mode 100644 apps/forwarding/tests/test_details_view.py create mode 100644 apps/refreshes/tests/test_details_view.py diff --git a/apps/exports/tests/test_details_view.py b/apps/exports/tests/test_details_view.py new file mode 100644 index 00000000..814de752 --- /dev/null +++ b/apps/exports/tests/test_details_view.py @@ -0,0 +1,132 @@ +from django.urls import reverse +from unmagic import use + +from apps.exports.models import ExportRun +from apps.exports.tests.fixtures import export_config_db_fixture +from tests.fixtures import authed_client + + +class TestExportDetailsSmoke: + @use(authed_client, export_config_db_fixture) + def test_returns_200(self): + response = authed_client().get( + reverse( + 'exports:export_details', args=[export_config_db_fixture().id] + ) + ) + assert response.status_code == 200 + + @use(authed_client, export_config_db_fixture) + def test_no_details_suffix_in_heading(self): + response = authed_client().get( + reverse( + 'exports:export_details', args=[export_config_db_fixture().id] + ) + ) + assert '- Details' not in response.content.decode() + + @use(authed_client, export_config_db_fixture) + def test_run_history_section_present(self): + response = authed_client().get( + reverse( + 'exports:export_details', args=[export_config_db_fixture().id] + ) + ) + content = response.content.decode() + assert 'Run History' in content + assert 'id="run-table"' in content + + @use(authed_client, export_config_db_fixture) + def test_schedule_column_present(self): + response = authed_client().get( + reverse( + 'exports:export_details', args=[export_config_db_fixture().id] + ) + ) + assert 'Schedule' in response.content.decode() + + @use(authed_client, export_config_db_fixture) + def test_status_filter_dropdown_present(self): + response = authed_client().get( + reverse( + 'exports:export_details', args=[export_config_db_fixture().id] + ) + ) + content = response.content.decode() + assert 'status-filter-form' in content + assert 'has_status_filter' in content + + +class TestExportRunHistoryTableEndpoint: + @use(authed_client, export_config_db_fixture) + def test_returns_200(self): + url = reverse( + 'exports:run_history_table', args=[export_config_db_fixture().id] + ) + assert authed_client().get(url).status_code == 200 + + @use(authed_client, export_config_db_fixture) + def test_no_filter_shows_all_statuses(self): + config = export_config_db_fixture() + completed_run = ExportRun.objects.create( + base_export_config=config, status=ExportRun.Status.COMPLETED + ) + failed_run = ExportRun.objects.create( + base_export_config=config, status=ExportRun.Status.FAILED + ) + url = reverse('exports:run_history_table', args=[config.id]) + content = authed_client().get(url).content.decode() + # Use log-{id} marker which only appears in rendered run rows + assert f'log-{completed_run.id}' in content + assert f'log-{failed_run.id}' in content + + @use(authed_client, export_config_db_fixture) + def test_status_filter_excludes_unchecked(self): + config = export_config_db_fixture() + completed_run = ExportRun.objects.create( + base_export_config=config, status=ExportRun.Status.COMPLETED + ) + failed_run = ExportRun.objects.create( + base_export_config=config, status=ExportRun.Status.FAILED + ) + url = reverse('exports:run_history_table', args=[config.id]) + content = ( + authed_client() + .get( + url, + QUERY_STRING='has_status_filter=1&status_filter=completed', + ) + .content.decode() + ) + # Use log-{id} marker which only appears in rendered run rows + assert f'log-{completed_run.id}' in content + assert f'log-{failed_run.id}' not in content + + @use(authed_client, export_config_db_fixture) + def test_empty_filter_shows_nothing(self): + config = export_config_db_fixture() + run = ExportRun.objects.create( + base_export_config=config, status=ExportRun.Status.COMPLETED + ) + url = reverse('exports:run_history_table', args=[config.id]) + content = ( + authed_client() + .get(url, QUERY_STRING='has_status_filter=1') + .content.decode() + ) + # No status values sent → no runs visible; use log-{id} marker which only + # appears when a run row is rendered, not in URL paths. + assert f'log-{run.id}' not in content + + @use(authed_client, export_config_db_fixture) + def test_pagination_default_10(self): + config = export_config_db_fixture() + for _ in range(15): + ExportRun.objects.create( + base_export_config=config, status=ExportRun.Status.COMPLETED + ) + url = reverse('exports:run_history_table', args=[config.id]) + response = authed_client().get(url) + assert response.status_code == 200 + # Pagination controls should appear when there are > 10 runs + assert 'pagination' in response.content.decode() diff --git a/apps/forwarding/tests/fixtures.py b/apps/forwarding/tests/fixtures.py new file mode 100644 index 00000000..9d307f81 --- /dev/null +++ b/apps/forwarding/tests/fixtures.py @@ -0,0 +1,25 @@ +from unmagic import fixture, use + +from tests.fixtures import database + +from ..models import ForwardingConfig, ForwardingDestination + + +@fixture +@use('db') +def destination(): + yield ForwardingDestination.objects.create( + name='Test API', + api_url='https://example.com/api', + ) + + +@fixture +@use('db') +def forwarding_config(): + yield ForwardingConfig.objects.create( + name='Test Forwarder', + database=database(), + destination=destination(), + query='SELECT 1', + ) diff --git a/apps/forwarding/tests/test_details_view.py b/apps/forwarding/tests/test_details_view.py new file mode 100644 index 00000000..7f72b7e5 --- /dev/null +++ b/apps/forwarding/tests/test_details_view.py @@ -0,0 +1,150 @@ +from django.urls import reverse +from unmagic import use + +from apps.forwarding.models import ForwardingRun +from tests.fixtures import authed_client + +from .fixtures import forwarding_config + + +class TestForwarderDetailsSmoke: + @use(authed_client, forwarding_config) + def test_returns_200(self): + response = authed_client().get( + reverse( + 'forwarding:forwarder_details', args=[forwarding_config().id] + ) + ) + assert response.status_code == 200 + + @use(authed_client, forwarding_config) + def test_no_details_suffix_in_heading(self): + response = authed_client().get( + reverse( + 'forwarding:forwarder_details', args=[forwarding_config().id] + ) + ) + assert '- Details' not in response.content.decode() + + @use(authed_client, forwarding_config) + def test_run_table_present(self): + response = authed_client().get( + reverse( + 'forwarding:forwarder_details', args=[forwarding_config().id] + ) + ) + assert 'id="run-table"' in response.content.decode() + + @use(authed_client, forwarding_config) + def test_status_filter_dropdown_present(self): + response = authed_client().get( + reverse( + 'forwarding:forwarder_details', args=[forwarding_config().id] + ) + ) + content = response.content.decode() + assert 'status-filter-form' in content + assert 'has_status_filter' in content + + @use(authed_client, forwarding_config) + def test_run_history_section_present(self): + response = authed_client().get( + reverse( + 'forwarding:forwarder_details', args=[forwarding_config().id] + ) + ) + content = response.content.decode() + assert 'Run History' in content + assert 'id="run-table"' in content + + @use(authed_client, forwarding_config) + def test_schedule_column_present(self): + response = authed_client().get( + reverse( + 'forwarding:forwarder_details', args=[forwarding_config().id] + ) + ) + assert 'Schedule' in response.content.decode() + + +class TestForwardingRunHistoryTableEndpoint: + @use(authed_client, forwarding_config) + def test_returns_200(self): + assert ( + authed_client() + .get( + reverse( + 'forwarding:run_history_table', + args=[forwarding_config().id], + ) + ) + .status_code + == 200 + ) + + @use(authed_client, forwarding_config) + def test_status_filter_excludes_unchecked(self): + config = forwarding_config() + completed_run = ForwardingRun.objects.create( + forwarding_config=config, + status=ForwardingRun.Status.COMPLETED, + ) + failed_run = ForwardingRun.objects.create( + forwarding_config=config, + status=ForwardingRun.Status.FAILED, + ) + url = reverse('forwarding:run_history_table', args=[config.id]) + content = ( + authed_client() + .get( + url, QUERY_STRING='has_status_filter=1&status_filter=completed' + ) + .content.decode() + ) + # Use log-{id} marker which only appears in rendered run rows + assert f'log-{completed_run.id}' in content + assert f'log-{failed_run.id}' not in content + + @use(authed_client, forwarding_config) + def test_no_filter_shows_all_statuses(self): + config = forwarding_config() + completed_run = ForwardingRun.objects.create( + forwarding_config=config, + status=ForwardingRun.Status.COMPLETED, + ) + failed_run = ForwardingRun.objects.create( + forwarding_config=config, + status=ForwardingRun.Status.FAILED, + ) + url = reverse('forwarding:run_history_table', args=[config.id]) + content = authed_client().get(url).content.decode() + assert f'log-{completed_run.id}' in content + assert f'log-{failed_run.id}' in content + + @use(authed_client, forwarding_config) + def test_empty_filter_shows_nothing(self): + config = forwarding_config() + run = ForwardingRun.objects.create( + forwarding_config=config, + status=ForwardingRun.Status.COMPLETED, + ) + url = reverse('forwarding:run_history_table', args=[config.id]) + content = ( + authed_client() + .get(url, QUERY_STRING='has_status_filter=1') + .content.decode() + ) + assert f'log-{run.id}' not in content + + @use(authed_client, forwarding_config) + def test_pagination_default_10(self): + config = forwarding_config() + for _ in range(15): + ForwardingRun.objects.create( + forwarding_config=config, + status=ForwardingRun.Status.COMPLETED, + ) + url = reverse('forwarding:run_history_table', args=[config.id]) + response = authed_client().get(url) + assert response.status_code == 200 + assert 'pagination' in response.content.decode() diff --git a/apps/forwarding/tests/test_models.py b/apps/forwarding/tests/test_models.py index 5ccaf9c4..d5f06a8e 100644 --- a/apps/forwarding/tests/test_models.py +++ b/apps/forwarding/tests/test_models.py @@ -16,15 +16,7 @@ ForwardingDestination, ForwardingRun, ) - - -@fixture -@use('db') -def destination(): - yield ForwardingDestination.objects.create( - name='Test API', - api_url='https://example.com/api', - ) +from .fixtures import destination @fixture diff --git a/apps/refreshes/tests/test_details_view.py b/apps/refreshes/tests/test_details_view.py new file mode 100644 index 00000000..9c9c2d07 --- /dev/null +++ b/apps/refreshes/tests/test_details_view.py @@ -0,0 +1,131 @@ +from django.urls import reverse +from unmagic import use + +from apps.refreshes.models import RefreshRun +from tests.fixtures import authed_client + +from .fixtures import refresh_config as _refresh_config + + +class TestRefreshDetailsSmoke: + @use(authed_client, _refresh_config) + def test_returns_200(self): + response = authed_client().get( + reverse('refreshes:refresh_details', args=[_refresh_config().id]) + ) + assert response.status_code == 200 + + @use(authed_client, _refresh_config) + def test_no_details_suffix_in_heading(self): + response = authed_client().get( + reverse('refreshes:refresh_details', args=[_refresh_config().id]) + ) + assert '- Details' not in response.content.decode() + + @use(authed_client, _refresh_config) + def test_run_table_present(self): + response = authed_client().get( + reverse('refreshes:refresh_details', args=[_refresh_config().id]) + ) + assert 'id="run-table"' in response.content.decode() + + @use(authed_client, _refresh_config) + def test_status_filter_dropdown_present(self): + response = authed_client().get( + reverse('refreshes:refresh_details', args=[_refresh_config().id]) + ) + content = response.content.decode() + assert 'status-filter-form' in content + assert 'has_status_filter' in content + + @use(authed_client, _refresh_config) + def test_run_history_section_present(self): + response = authed_client().get( + reverse('refreshes:refresh_details', args=[_refresh_config().id]) + ) + content = response.content.decode() + assert 'Run History' in content + assert 'id="run-table"' in content + + @use(authed_client, _refresh_config) + def test_schedule_column_present(self): + response = authed_client().get( + reverse('refreshes:refresh_details', args=[_refresh_config().id]) + ) + assert 'Schedule' in response.content.decode() + + +class TestRefreshRunHistoryTableEndpoint: + @use(authed_client, _refresh_config) + def test_returns_200(self): + assert ( + authed_client() + .get( + reverse( + 'refreshes:run_history_table', args=[_refresh_config().id] + ) + ) + .status_code + == 200 + ) + + @use(authed_client, _refresh_config) + def test_status_filter_excludes_unchecked(self): + config = _refresh_config() + completed_run = RefreshRun.objects.create( + refresh_config=config, status=RefreshRun.Status.COMPLETED + ) + failed_run = RefreshRun.objects.create( + refresh_config=config, status=RefreshRun.Status.FAILED + ) + url = reverse('refreshes:run_history_table', args=[config.id]) + content = ( + authed_client() + .get( + url, QUERY_STRING='has_status_filter=1&status_filter=completed' + ) + .content.decode() + ) + # Use log-{id} marker which only appears in rendered run rows + assert f'log-{completed_run.id}' in content + assert f'log-{failed_run.id}' not in content + + @use(authed_client, _refresh_config) + def test_no_filter_shows_all_statuses(self): + config = _refresh_config() + completed_run = RefreshRun.objects.create( + refresh_config=config, status=RefreshRun.Status.COMPLETED + ) + failed_run = RefreshRun.objects.create( + refresh_config=config, status=RefreshRun.Status.FAILED + ) + url = reverse('refreshes:run_history_table', args=[config.id]) + content = authed_client().get(url).content.decode() + assert f'log-{completed_run.id}' in content + assert f'log-{failed_run.id}' in content + + @use(authed_client, _refresh_config) + def test_empty_filter_shows_nothing(self): + config = _refresh_config() + run = RefreshRun.objects.create( + refresh_config=config, status=RefreshRun.Status.COMPLETED + ) + url = reverse('refreshes:run_history_table', args=[config.id]) + content = ( + authed_client() + .get(url, QUERY_STRING='has_status_filter=1') + .content.decode() + ) + assert f'log-{run.id}' not in content + + @use(authed_client, _refresh_config) + def test_pagination_default_10(self): + config = _refresh_config() + for _ in range(15): + RefreshRun.objects.create( + refresh_config=config, status=RefreshRun.Status.COMPLETED + ) + url = reverse('refreshes:run_history_table', args=[config.id]) + response = authed_client().get(url) + assert response.status_code == 200 + assert 'pagination' in response.content.decode() diff --git a/apps/refreshes/tests/test_views.py b/apps/refreshes/tests/test_views.py index e225eebf..f65024e0 100644 --- a/apps/refreshes/tests/test_views.py +++ b/apps/refreshes/tests/test_views.py @@ -1,4 +1,5 @@ import json +import re from unittest.mock import MagicMock, patch from django.urls import reverse @@ -333,6 +334,35 @@ def test_etag_mismatch_returns_content(self): assert config.name in response.content.decode() +class TestRefreshRunLogView: + @use(authed_client, _refresh_config) + def test_requires_login(self): + run = RefreshRun.objects.create( + refresh_config=_refresh_config(), + status=RefreshRun.Status.COMPLETED, + log='hello log', + ) + client = authed_client() + client.logout() + response = client.get(reverse('refreshes:run_log', args=[run.id])) + assert response.status_code == 302 + + @use(authed_client, _refresh_config) + def test_returns_log(self): + run = RefreshRun.objects.create( + refresh_config=_refresh_config(), + status=RefreshRun.Status.COMPLETED, + log='refresh log content', + ) + response = authed_client().get(reverse('refreshes:run_log', args=[run.id])) + assert response.status_code == 200 + assert 'refresh log content' in response.content.decode() + + @use(authed_client) + def test_404_for_missing(self): + response = authed_client().get(reverse('refreshes:run_log', args=[9999])) + assert response.status_code == 404 + class TestRefreshesListPageSmoke: """Smoke tests: full-page renders with configs in various run states.""" @@ -385,4 +415,3 @@ def test_renders_with_started_run(self): response = authed_client().get(reverse('refreshes:refresh_configs')) assert response.status_code == 200 assert 'started' in response.content.decode() - From 2a47fa2156d133557329ca17f38d698f99253b4b Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Tue, 2 Jun 2026 08:29:02 -0400 Subject: [PATCH 20/29] fix: Lint --- commcare_sync/tests/test_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/commcare_sync/tests/test_views.py b/commcare_sync/tests/test_views.py index c7737a60..ccbabf34 100644 --- a/commcare_sync/tests/test_views.py +++ b/commcare_sync/tests/test_views.py @@ -1,4 +1,3 @@ -import pytest from django.test import RequestFactory from commcare_sync.views import ( From 72c6d83b03bc3865fd03db943fcd3f84680cc102 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 3 Jun 2026 14:33:48 -0400 Subject: [PATCH 21/29] Move `edit_url` and `last_run_log_url` to subclasses --- apps/exports/models.py | 43 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/exports/models.py b/apps/exports/models.py index aede4cd9..59472b36 100644 --- a/apps/exports/models.py +++ b/apps/exports/models.py @@ -33,25 +33,6 @@ class Meta: 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]) - 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 @@ -78,6 +59,18 @@ def details_url(self): def __str__(self): return f'{self.name} - {self.project}' + @property + def edit_url(self): + return reverse('exports:edit_export_config', args=[self.id]) + + @property + def last_run_log_url(self): + run = self.last_run + return None if run is None else reverse( + 'exports:run_log', + args=[run.id], + ) + @reversion.register() class MultiProjectExportConfig(ExportConfigBase): @@ -113,6 +106,18 @@ def get_projects_display_short(self): '
'.join(p.domain for p in self.projects.all()) ) + @property + def edit_url(self): + return reverse('exports:edit_multi_export_config', args=[self.id]) + + @property + def last_run_log_url(self): + run = self.last_run + return None if run is None else reverse( + 'exports:multi_run_log', + args=[run.id], + ) + class ExportRunBase(RunBaseModel): class Status(models.TextChoices): From 60e30dc41a33b11159eff04a0ef232d06cf17a7c Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 3 Jun 2026 15:38:02 -0400 Subject: [PATCH 22/29] Add tests with run instances --- apps/exports/tests/test_list_view.py | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/exports/tests/test_list_view.py b/apps/exports/tests/test_list_view.py index c688d078..24c438ad 100644 --- a/apps/exports/tests/test_list_view.py +++ b/apps/exports/tests/test_list_view.py @@ -22,27 +22,40 @@ ) -class TestExportConfigBaseProperties: - @use(export_config) +@use(export_config) +class TestExportConfigProperties: 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_last_run_log_url_none_when_no_run(self): + assert export_config().last_run_log_url is None + + @use(export_run) + def test_last_run_log_url_with_run(self): + run = export_run() + expected = reverse('exports:run_log', args=[run.id]) + assert export_config().last_run_log_url == expected + + +@use(multi_export_config) +class TestMultiExportConfigProperties: + 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): - 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 + @use(multi_export_run) + def test_last_run_log_url_with_run_multi(self): + run = multi_export_run() + expected = reverse('exports:multi_run_log', args=[run.id]) + assert multi_export_config().last_run_log_url == expected + class TestExportsHomeView: @use(authed_client) From bbe1cb7113d685fd88a3f9cce10c38e16fbaad12 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 3 Jun 2026 15:57:07 -0400 Subject: [PATCH 23/29] Reuse `VALID_CONFIG_PAGE_SIZES` const --- apps/exports/views.py | 11 ++++++----- apps/forwarding/views.py | 10 +++++----- apps/refreshes/views.py | 9 +++++---- commcare_sync/consts.py | 1 + commcare_sync/tests/test_views.py | 4 ++-- commcare_sync/views.py | 7 +++---- 6 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 commcare_sync/consts.py diff --git a/apps/exports/views.py b/apps/exports/views.py index 0a918105..984a5211 100644 --- a/apps/exports/views.py +++ b/apps/exports/views.py @@ -28,6 +28,7 @@ _get_refresh_statistics, ) from apps.web.templatetags.dateformat_tags import readable_timedelta +from commcare_sync.consts import VALID_CONFIG_PAGE_SIZES from commcare_sync.views import ( get_config_page_size, get_page_from_request, @@ -113,7 +114,7 @@ def home(request): 'active_tab': 'exports', 'configs': page_obj, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, 'stats_period': readable_timedelta(period, short=True), 'export_stats': _get_export_statistics(current_start, previous_start), @@ -142,7 +143,7 @@ def config_table(request): { 'configs': page_obj, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, }, ) @@ -332,7 +333,7 @@ def export_details(request, export_id): 'runs': page_obj, 'run_history_url': reverse('exports:run_history_table', args=[export.id]), 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) @@ -366,7 +367,7 @@ def run_history_table(request, export_id): 'is_multi_project': is_multi_project, 'run_history_url': run_history_url, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) @@ -423,7 +424,7 @@ def multi_export_details(request, export_id): 'runs': page_obj, 'run_history_url': run_history_url, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) diff --git a/apps/forwarding/views.py b/apps/forwarding/views.py index c1de3ad4..af33d5b1 100644 --- a/apps/forwarding/views.py +++ b/apps/forwarding/views.py @@ -20,7 +20,7 @@ _get_refresh_statistics, ) from apps.web.templatetags.dateformat_tags import readable_timedelta - +from commcare_sync.consts import VALID_CONFIG_PAGE_SIZES from commcare_sync.views import ( get_config_page_size, get_page_from_request, @@ -76,7 +76,7 @@ def forwarders(request): 'active_tab': 'forwarders', 'configs': page_obj, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, 'stats_period': readable_timedelta(period, short=True), 'export_stats': _get_export_statistics(current_start, previous_start), @@ -110,7 +110,7 @@ def config_table(request): return render(request, 'forwarding/partials/config_table.html', { 'configs': page_obj, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, }) @@ -314,7 +314,7 @@ def forwarder_details(request, forwarder_id): 'runs': page_obj, 'run_history_url': reverse('forwarding:run_history_table', args=[forwarder.id]), 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) @@ -343,7 +343,7 @@ def run_history_table(request, forwarder_id): 'runs': page_obj, 'run_history_url': reverse('forwarding:run_history_table', args=[forwarder.id]), 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) diff --git a/apps/refreshes/views.py b/apps/refreshes/views.py index 56465a0b..44feaac6 100644 --- a/apps/refreshes/views.py +++ b/apps/refreshes/views.py @@ -21,6 +21,7 @@ _get_refresh_statistics, ) from apps.web.templatetags.dateformat_tags import readable_timedelta +from commcare_sync.consts import VALID_CONFIG_PAGE_SIZES from commcare_sync.views import ( get_config_page_size, get_page_from_request, @@ -76,7 +77,7 @@ def refresh_configs(request): 'active_tab': 'refreshes', 'configs': page_obj, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, 'stats_period': readable_timedelta(period, short=True), 'export_stats': _get_export_statistics(current_start, previous_start), @@ -110,7 +111,7 @@ def config_table(request): return render(request, 'refreshes/partials/config_table.html', { 'configs': page_obj, 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, }) @@ -227,7 +228,7 @@ def refresh_details(request, config_id): 'runs': page_obj, 'run_history_url': reverse('refreshes:run_history_table', args=[config.id]), 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) @@ -256,7 +257,7 @@ def run_history_table(request, config_id): 'runs': page_obj, 'run_history_url': reverse('refreshes:run_history_table', args=[config.id]), 'page_size': page_size, - 'page_sizes': [10, 20, 50], + 'page_sizes': VALID_CONFIG_PAGE_SIZES, }, ) diff --git a/commcare_sync/consts.py b/commcare_sync/consts.py new file mode 100644 index 00000000..18e91080 --- /dev/null +++ b/commcare_sync/consts.py @@ -0,0 +1 @@ +VALID_CONFIG_PAGE_SIZES = (10, 20, 50) diff --git a/commcare_sync/tests/test_views.py b/commcare_sync/tests/test_views.py index ccbabf34..d20ce1c9 100644 --- a/commcare_sync/tests/test_views.py +++ b/commcare_sync/tests/test_views.py @@ -1,13 +1,13 @@ from django.test import RequestFactory from commcare_sync.views import ( - _VALID_CONFIG_PAGE_SIZES, get_config_page_size, get_page_from_request, get_run_statuses_from_request, ) +from commcare_sync.consts import VALID_CONFIG_PAGE_SIZES -VALID_PAGE_SIZES = list(_VALID_CONFIG_PAGE_SIZES) +VALID_PAGE_SIZES = list(VALID_CONFIG_PAGE_SIZES) class TestGetConfigPageSize: diff --git a/commcare_sync/views.py b/commcare_sync/views.py index 72b38f4b..9174cc67 100644 --- a/commcare_sync/views.py +++ b/commcare_sync/views.py @@ -1,8 +1,7 @@ from django.conf import settings from apps.commcare.models import RunBaseModel - -_VALID_CONFIG_PAGE_SIZES = (10, 20, 50) +from commcare_sync.consts import VALID_CONFIG_PAGE_SIZES def get_ui_page_size(request): @@ -25,11 +24,11 @@ def get_config_page_size(request): if 'page_size' in request.GET: try: size = int(request.GET['page_size']) - if size in _VALID_CONFIG_PAGE_SIZES: + if size in VALID_CONFIG_PAGE_SIZES: return size except ValueError: pass - return _VALID_CONFIG_PAGE_SIZES[0] + return VALID_CONFIG_PAGE_SIZES[0] def get_page_from_request(request): From 8a9233f139abe6196b046c1b3debe2a31ec4cdad Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Wed, 3 Jun 2026 16:23:20 -0400 Subject: [PATCH 24/29] Rename "configs" to "page_obj" --- apps/exports/views.py | 8 ++++---- apps/forwarding/views.py | 4 ++-- apps/refreshes/views.py | 4 ++-- templates/exports/partials/config_table.html | 20 +++++++++---------- .../forwarding/partials/config_table.html | 20 +++++++++---------- .../refreshes/partials/config_table.html | 20 +++++++++---------- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/exports/views.py b/apps/exports/views.py index 984a5211..fba1491d 100644 --- a/apps/exports/views.py +++ b/apps/exports/views.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, Paginator +from django.core.paginator import EmptyPage, Page, Paginator from django.db.models import Max, Prefetch from django.http import ( Http404, @@ -52,7 +52,7 @@ logger = logging.getLogger(__name__) -def _merged_export_configs(page_size, page_num): +def _merged_export_configs(page_size: int, page_num: int) -> Page: """Return a Page object combining ExportConfig and MultiProjectExportConfig.""" single = list( ExportConfig.objects @@ -112,7 +112,7 @@ def home(request): return render(request, 'exports/exports_home.html', { 'active_tab': 'exports', - 'configs': page_obj, + 'page_obj': page_obj, 'page_size': page_size, 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, @@ -141,7 +141,7 @@ def config_table(request): request, 'exports/partials/config_table.html', { - 'configs': page_obj, + 'page_obj': page_obj, 'page_size': page_size, 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, diff --git a/apps/forwarding/views.py b/apps/forwarding/views.py index af33d5b1..af007c7d 100644 --- a/apps/forwarding/views.py +++ b/apps/forwarding/views.py @@ -74,7 +74,7 @@ def forwarders(request): 'forwarding/forwarders.html', { 'active_tab': 'forwarders', - 'configs': page_obj, + 'page_obj': page_obj, 'page_size': page_size, 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, @@ -108,7 +108,7 @@ def config_table(request): return response return render(request, 'forwarding/partials/config_table.html', { - 'configs': page_obj, + 'page_obj': page_obj, 'page_size': page_size, 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, diff --git a/apps/refreshes/views.py b/apps/refreshes/views.py index 44feaac6..65f789cb 100644 --- a/apps/refreshes/views.py +++ b/apps/refreshes/views.py @@ -75,7 +75,7 @@ def refresh_configs(request): 'refreshes/refresh_configs.html', { 'active_tab': 'refreshes', - 'configs': page_obj, + 'page_obj': page_obj, 'page_size': page_size, 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, @@ -109,7 +109,7 @@ def config_table(request): return response return render(request, 'refreshes/partials/config_table.html', { - 'configs': page_obj, + 'page_obj': page_obj, 'page_size': page_size, 'page_sizes': VALID_CONFIG_PAGE_SIZES, 'etag': etag, diff --git a/templates/exports/partials/config_table.html b/templates/exports/partials/config_table.html index 36497046..f833cc8c 100644 --- a/templates/exports/partials/config_table.html +++ b/templates/exports/partials/config_table.html @@ -9,9 +9,9 @@ hx-get="{% url 'exports:config_table' %}" hx-trigger="every 60s" hx-swap="outerHTML" - hx-vals="js:{page: '{{ configs.number }}', page_size: '{{ page_size }}', etag: document.getElementById('exports-config-table').dataset.etag}" + hx-vals="js:{page: '{{ page_obj.number }}', page_size: '{{ page_size }}', etag: document.getElementById('exports-config-table').dataset.etag}" > - {% if configs.object_list %} + {% if page_obj.object_list %} @@ -22,7 +22,7 @@ - {% for config in configs.object_list %} + {% for config in page_obj.object_list %}
{% trans "Actions" %}
@@ -116,14 +116,14 @@ {% endfor %} - {% if configs.paginator.num_pages > 1 %} + {% if page_obj.paginator.num_pages > 1 %}