-
Notifications
You must be signed in to change notification settings - Fork 0
feat: bulk add courses to base catalog #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ManuelStarDo
merged 11 commits into
main
from
crls/feat/bulk-add-courses-to-base-catalog
Aug 12, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
6baf0df
feat: bulk add courses to base catalog
ccantillo 74500ba
fix: fix pydocstyle violations in bulk add view
ccantillo 21e19fd
refactor: manage courses inline on BaseCatalog change form
ccantillo cbf0c44
fix(admin): remove redundant label and bypass admin.E013 check
ccantillo 80d8d4b
test: add coverage for BaseCatalog inline course manager
ccantillo 71efbef
fix(quality): simplify empty list comparison in test
ccantillo 66f8bbb
fix: fix isort blank line in test_base_catalog_admin
ccantillo 267cbf6
fix: fix blank lines in test_base_catalog_admin
ccantillo 75e8a2f
fix: fix blank lines and implicit booleanness in test_base_catalog_admin
ccantillo b83ca8b
fix(admin): catch FieldDoesNotExist and add unique constraint on Base…
ccantillo 6fe5b23
fix: restore response_add and get_changeform_initial_data lost during…
ccantillo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
partner_catalog/migrations/0010_basecatalogcourse_unique_base_catalog_course.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| """Migration to add a uniqueness constraint on (base_catalog, course_overview) for BaseCatalogCourse.""" | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| """Add UniqueConstraint to prevent duplicate BaseCatalogCourse entries.""" | ||
|
|
||
| dependencies = [ | ||
| ("partner_catalog", "0009_alter_catalogcourseenrollment_course_overview"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddConstraint( | ||
| model_name="basecatalogcourse", | ||
| constraint=models.UniqueConstraint( | ||
| fields=["base_catalog", "course_overview"], | ||
| name="unique_base_catalog_course", | ||
| ), | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| """ | ||
| Tests for BaseCatalog admin — form pre-population and save_related sync logic (Suite 10). | ||
|
|
||
| Covers: | ||
| - BaseCatalogAdminForm.__init__: courses queryset is set on all forms | ||
| - BaseCatalogAdminForm.__init__: courses initial is empty for a new catalog | ||
| - BaseCatalogAdminForm.__init__: courses initial is pre-populated for an existing catalog | ||
| - BaseCatalogAdmin.save_related: adds newly selected courses | ||
| - BaseCatalogAdmin.save_related: removes deselected courses | ||
| - BaseCatalogAdmin.save_related: no-op when selection matches current state | ||
| - BaseCatalogAdmin.save_related: records added_by from request.user | ||
| - BaseCatalogAdmin.save_related: clears all courses when selection is empty | ||
| """ | ||
|
|
||
| from unittest.mock import MagicMock | ||
|
|
||
| import pytest | ||
| from django.contrib.admin.sites import AdminSite | ||
|
|
||
| from partner_catalog.admin import BaseCatalogAdmin, BaseCatalogAdminForm | ||
| from partner_catalog.models import BaseCatalog, BaseCatalogCourse | ||
| from partner_catalog.services.catalog_courses import CourseOverview | ||
| from tests.factories import make_user | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def make_base_catalog(slug_suffix="1"): | ||
| """Create and return a BaseCatalog for testing.""" | ||
| return BaseCatalog.objects.create(name=f"Test Catalog {slug_suffix}", slug=f"test-catalog-{slug_suffix}") | ||
|
|
||
|
|
||
| def make_course(): | ||
| """Create and return a CourseOverview (test backend) instance.""" | ||
| return CourseOverview.objects.create() | ||
|
|
||
|
|
||
| def _admin(): | ||
| """Return a BaseCatalogAdmin instance bound to a fresh AdminSite.""" | ||
| return BaseCatalogAdmin(BaseCatalog, AdminSite()) | ||
|
|
||
|
|
||
| def _request(user=None): | ||
| """Return a mock request with the given user (or a new staff user).""" | ||
| req = MagicMock() | ||
| req.user = user or make_user(is_staff=True) | ||
| return req | ||
|
|
||
|
|
||
| def _form(instance, selected_courses): | ||
| """Return a mock form with cleaned_data and instance set.""" | ||
| frm = MagicMock() | ||
| frm.instance = instance | ||
| frm.cleaned_data = {'courses': selected_courses} | ||
| return frm | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # BaseCatalogAdminForm — __init__ pre-population | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| class TestBaseCatalogAdminFormInit: | ||
| """Tests for BaseCatalogAdminForm.__init__ initialization behaviour.""" | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_courses_queryset_includes_all_courses(self): | ||
| """The courses queryset covers all CourseOverview objects.""" | ||
| make_course() | ||
| make_course() | ||
|
|
||
| form = BaseCatalogAdminForm() | ||
|
|
||
| assert form.fields['courses'].queryset.count() == 2 | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_courses_initial_is_empty_for_new_catalog(self): | ||
| """Without an existing instance the courses initial is not set.""" | ||
| form = BaseCatalogAdminForm() | ||
|
|
||
| assert not form.fields['courses'].initial | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_courses_initial_pre_populates_existing_courses(self): | ||
| """With an existing catalog the initial value matches its current courses.""" | ||
| catalog = make_base_catalog() | ||
| course1 = make_course() | ||
| course2 = make_course() | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course1) | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course2) | ||
|
|
||
| form = BaseCatalogAdminForm(instance=catalog) | ||
|
|
||
| initial_ids = {c.pk for c in form.fields['courses'].initial} | ||
| assert initial_ids == {course1.pk, course2.pk} | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_courses_initial_is_empty_for_catalog_with_no_courses(self): | ||
| """A catalog with no courses yields an empty initial queryset.""" | ||
| catalog = make_base_catalog() | ||
|
|
||
| form = BaseCatalogAdminForm(instance=catalog) | ||
|
|
||
| assert not list(form.fields['courses'].initial) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # BaseCatalogAdmin.save_related — course sync logic | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| class TestBaseCatalogAdminSaveRelated: | ||
| """Tests for BaseCatalogAdmin.save_related diff/sync logic.""" | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_adds_newly_selected_courses(self): | ||
| """save_related creates BaseCatalogCourse entries for newly selected courses.""" | ||
| catalog = make_base_catalog(slug_suffix="a") | ||
| course1 = make_course() | ||
| course2 = make_course() | ||
|
|
||
| _admin().save_related(_request(), _form(catalog, [course1, course2]), [], change=True) | ||
|
|
||
| assert catalog.courses.count() == 2 | ||
| assert BaseCatalogCourse.objects.filter(base_catalog=catalog, course_overview=course1).exists() | ||
| assert BaseCatalogCourse.objects.filter(base_catalog=catalog, course_overview=course2).exists() | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_removes_deselected_courses(self): | ||
| """save_related deletes BaseCatalogCourse entries for deselected courses.""" | ||
| catalog = make_base_catalog(slug_suffix="b") | ||
| course1 = make_course() | ||
| course2 = make_course() | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course1) | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course2) | ||
|
|
||
| _admin().save_related(_request(), _form(catalog, [course1]), [], change=True) | ||
|
|
||
| assert catalog.courses.count() == 1 | ||
| assert BaseCatalogCourse.objects.filter(base_catalog=catalog, course_overview=course1).exists() | ||
| assert not BaseCatalogCourse.objects.filter(base_catalog=catalog, course_overview=course2).exists() | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_no_op_when_selection_matches_current_state(self): | ||
| """save_related does not create duplicates when the selection is unchanged.""" | ||
| catalog = make_base_catalog(slug_suffix="c") | ||
| course = make_course() | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course) | ||
|
|
||
| _admin().save_related(_request(), _form(catalog, [course]), [], change=True) | ||
|
|
||
| assert catalog.courses.count() == 1 | ||
| assert BaseCatalogCourse.objects.filter(base_catalog=catalog).count() == 1 | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_records_added_by_from_request_user(self): | ||
| """save_related sets the added_by field to the current request user.""" | ||
| catalog = make_base_catalog(slug_suffix="d") | ||
| course = make_course() | ||
| user = make_user() | ||
|
|
||
| _admin().save_related(_request(user=user), _form(catalog, [course]), [], change=True) | ||
|
|
||
| entry = BaseCatalogCourse.objects.get(base_catalog=catalog, course_overview=course) | ||
| assert entry.added_by == user | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_clears_all_courses_when_selection_is_empty(self): | ||
| """save_related removes all entries when the submitted selection is empty.""" | ||
| catalog = make_base_catalog(slug_suffix="e") | ||
| course1 = make_course() | ||
| course2 = make_course() | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course1) | ||
| BaseCatalogCourse.objects.create(base_catalog=catalog, course_overview=course2) | ||
|
|
||
| _admin().save_related(_request(), _form(catalog, []), [], change=True) | ||
|
|
||
| assert not catalog.courses.count() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.