From c507aaf9abeff4b93b7f9bdbc55801f2ccfc2d01 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 4 Feb 2026 11:41:11 -0500 Subject: [PATCH 1/4] Refs #36620 -- Guarded coverage tests workflow behind a label. This also removes the skip on the primary tests workflow so that it runs more predictably. --- .github/workflows/coverage_tests.yml | 40 +++++++++++++++++++++------- .github/workflows/tests.yml | 1 - 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/coverage_tests.yml b/.github/workflows/coverage_tests.yml index 07f461db6937..fa43897a2bdb 100644 --- a/.github/workflows/coverage_tests.yml +++ b/.github/workflows/coverage_tests.yml @@ -2,9 +2,9 @@ name: Coverage Tests on: pull_request: + types: [labeled, synchronize, opened, reopened] paths-ignore: - 'docs/**' - - '**/*.md' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,9 +15,24 @@ permissions: jobs: coverage: - name: Coverage Tests (Windows) - runs-on: windows-latest + if: contains(github.event.pull_request.labels.*.name, 'coverage') + runs-on: ubuntu-latest + name: Coverage Tests (PostgreSQL) timeout-minutes: 60 + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_DB: django + POSTGRES_USER: user + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout uses: actions/checkout@v6 @@ -32,19 +47,26 @@ jobs: cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' + - name: Update apt repo + run: sudo apt update + - name: Install libmemcached-dev for pylibmc + run: sudo apt install -y libmemcached-dev - name: Install dependencies run: | python -m pip install --upgrade pip wheel - python -m pip install -r tests/requirements/py3.txt -e . + python -m pip install -r tests/requirements/py3.txt -r tests/requirements/postgres.txt -e . python -m pip install 'coverage[toml]' diff-cover + - name: Create PostgreSQL settings file + run: mv ./.github/workflows/data/test_postgres.py.tpl ./tests/test_postgres.py + - name: Run tests with coverage env: PYTHONPATH: ${{ github.workspace }}/tests COVERAGE_PROCESS_START: ${{ github.workspace }}/tests/.coveragerc RUNTESTS_DIR: ${{ github.workspace }}/tests run: | - python -Wall tests/runtests.py -v2 + python -Wall tests/runtests.py --settings=test_postgres -v2 - name: Generate coverage report if: success() @@ -59,11 +81,11 @@ jobs: - name: Generate diff coverage report if: success() run: | - if (Test-Path 'tests/coverage.xml') { + if [ -f tests/coverage.xml ]; then diff-cover tests/coverage.xml --compare-branch=origin/main --fail-under=0 > diff-cover-report.md - } else { - Set-Content -Path diff-cover-report.md -Value 'No coverage data available.' - } + else + echo "No coverage data available." > diff-cover-report.md + fi - name: Save PR number if: success() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3012ccb83ea1..411fc7736a97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,6 @@ permissions: jobs: windows: - if: github.event_name == 'push' runs-on: windows-latest strategy: matrix: From 92d4aea5ffacc38c5f7903b9410d0abec83f14de Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 4 Feb 2026 11:25:09 -0500 Subject: [PATCH 2/4] Refs #36620 -- Shortened coverage diff comment. --- .github/workflows/coverage_comment.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage_comment.yml b/.github/workflows/coverage_comment.yml index fc585758366d..287cc558b50b 100644 --- a/.github/workflows/coverage_comment.yml +++ b/.github/workflows/coverage_comment.yml @@ -45,8 +45,15 @@ jobs: let body = 'No coverage data available.'; if (fs.existsSync(reportPath)) { body = fs.readFileSync(reportPath, 'utf8'); + const divider = '-------------'; + // Start after the second divder, where changed files are listed. + const start = body.indexOf(divider, 1); + body = body.substring(start); } - const commentBody = '### 📊 Coverage Report for Changed Files\n\n```\n' + body + '\n```\n\n**Note:** Missing lines are warnings only. Some lines may not be covered by SQLite tests as they are database-specific.\n\nFor more information about code coverage on pull requests, see the [contributing documentation](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/unit-tests/#code-coverage-on-pull-requests).'; + const commentBody = ( + '#### Uncovered lines in changed files\n\n```\n' + body + '```\n' + + + '**Note:** Missing lines are warnings only. Some database-specific lines may not be measured. [More information](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/unit-tests/#code-coverage-on-pull-requests)' + ); const prNumber = parseInt(process.env.PR_NUMBER); if (isNaN(prNumber)) { From 97228a86d2b7d8011b97bebdfe0f126a536a3841 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 11 Feb 2026 16:02:01 -0500 Subject: [PATCH 3/4] Refs #35809 -- Fixed test_selectbox_selected_rows() on macOS. --- tests/admin_views/tests.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 4beca793d61e..7f35d7c2f455 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -1,6 +1,7 @@ import datetime import os import re +import sys import unittest import zoneinfo from unittest import mock @@ -5983,6 +5984,12 @@ def setUp(self): title="A Long Title", published=True, slug="a-long-title" ) + @property + def modifier_key(self): + from selenium.webdriver.common.keys import Keys + + return Keys.COMMAND if sys.platform == "darwin" else Keys.CONTROL + @screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark", "high_contrast"]) def test_login_button_centered(self): from selenium.webdriver.common.by import By @@ -6406,8 +6413,8 @@ def test_selectbox_selected_rows(self): elem = self.selenium.find_element( By.CSS_SELECTOR, f"#id_user_permissions_from option[value='{perm.id}']" ) - ActionChains(self.selenium).key_down(Keys.CONTROL).click(elem).key_up( - Keys.CONTROL + ActionChains(self.selenium).key_down(self.modifier_key).click(elem).key_up( + self.modifier_key ).perform() # Move focus to other element. @@ -6425,8 +6432,8 @@ def test_selectbox_selected_rows(self): elem = self.selenium.find_element( By.CSS_SELECTOR, f"#id_user_permissions_to option[value='{perm.id}']" ) - ActionChains(self.selenium).key_down(Keys.CONTROL).click(elem).key_up( - Keys.CONTROL + ActionChains(self.selenium).key_down(self.modifier_key).click(elem).key_up( + self.modifier_key ).perform() # Move focus to other element. From 380d77cccefbe185ddb3f9368d8fdeb7b7cf7108 Mon Sep 17 00:00:00 2001 From: Sean Helvey Date: Wed, 11 Feb 2026 15:07:40 -0800 Subject: [PATCH 4/4] Fixed #36921 -- Fixed KeyError in inline form for model not registered with admin. Regression in b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010. --- django/contrib/admin/options.py | 34 +++++++++++++++++---------------- tests/admin_views/tests.py | 25 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 9c787d232912..b67b023bd313 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1419,7 +1419,7 @@ def response_add(self, request, obj, post_url_continue=None): # Find the optgroup for the new item, if available source_model_name = request.POST.get(SOURCE_MODEL_VAR) - + source_admin = None if source_model_name: app_label, model_name = source_model_name.split(".", 1) try: @@ -1428,21 +1428,23 @@ def response_add(self, request, obj, post_url_continue=None): msg = _('The app "%s" could not be found.') % source_model_name self.message_user(request, msg, messages.ERROR) else: - source_admin = self.admin_site._registry[source_model] - form = source_admin.get_form(request)() - if self.opts.verbose_name_plural in form.fields: - field = form.fields[self.opts.verbose_name_plural] - for option_value, option_label in field.choices: - # Check if this is an optgroup (label is a sequence - # of choices rather than a single string value). - if isinstance(option_label, (list, tuple)): - # It's an optgroup: - # (group_name, [(value, label), ...]) - optgroup_label = option_value - for choice_value, choice_display in option_label: - if choice_display == str(obj): - popup_response["optgroup"] = str(optgroup_label) - break + source_admin = self.admin_site._registry.get(source_model) + + if source_admin: + form = source_admin.get_form(request)() + if self.opts.verbose_name_plural in form.fields: + field = form.fields[self.opts.verbose_name_plural] + for option_value, option_label in field.choices: + # Check if this is an optgroup (label is a sequence + # of choices rather than a single string value). + if isinstance(option_label, (list, tuple)): + # It's an optgroup: + # (group_name, [(value, label), ...]) + optgroup_label = option_value + for choice_value, choice_display in option_label: + if choice_display == str(obj): + popup_response["optgroup"] = str(optgroup_label) + break popup_response_data = json.dumps(popup_response) return TemplateResponse( diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 7f35d7c2f455..fa9d9a2dc6f5 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -589,6 +589,31 @@ def test_popup_add_POST_with_invalid_source_model(self): self.assertIn("admin_views.nonexistent", str(messages[0])) self.assertIn("could not be found", str(messages[0])) + def test_popup_add_POST_with_unregistered_source_model(self): + """ + Popup add where source_model is a valid Django model but is not + registered in the admin site (e.g. a model only used as an inline) + should succeed without raising a KeyError. + """ + post_data = { + IS_POPUP_VAR: "1", + # Chapter exists as a model but is not registered in site (only + # in site6), simulating a model used only as an inline. + SOURCE_MODEL_VAR: "admin_views.chapter", + "title": "Test Article", + "content": "some content", + "date_0": "2010-09-10", + "date_1": "14:55:39", + } + response = self.client.post(reverse("admin:admin_views_article_add"), post_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "data-popup-response") + # No error messages - unregistered model is silently skipped. + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 0) + # No optgroup in the response. + self.assertNotContains(response, ""optgroup"") + def test_basic_edit_POST(self): """ A smoke test to ensure POST on edit_view works.