diff --git a/RELEASE.rst b/RELEASE.rst index 718f48699c..1e39cfae2b 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,28 @@ Release Notes ============= +Version 0.131.3 +--------------- + +- fix(deps): update dependency ramda to v0.32.0 (#2531) +- chore(deps): update dependency authlib to v1.6.5 [security] (#3008) +- fix: use global id for user sync filtering (#2992) +- Revert "chore(deps): update dependency python (#2031)" (#3003) +- Fix perf issues with the departments API (#3001) +- Removing first and last name requirement for a PATCH request (#2998) +- fix(deps): update django-health-check digest to 592f6a8 (#2916) +- Revert "fix(deps): update dependency webpack-dev-server to v5 [securi… (#2999) +- fix(deps): update dependency serialize-javascript to v6 [security] (#2538) +- fix(deps): update dependency webpack-dev-server to v5 [security] (#2706) +- login button redirect not working with apisix (#2996) +- feat: management commands to create signatories in bulk and delete a signatory (#2986) +- Add escape hatch to be able to disable zeal (#2995) +- Don't raise zeal exceptions normally (#2994) +- chore(deps): update dependency python (#2031) +- Fix flaky test regarding v2 program api (#2987) +- Switch from nplusone to django-zeal (#2990) +- fix(deps): update dependency django to v4.2.25 [security] (#2991) + Version 0.131.2 (Released October 14, 2025) --------------- diff --git a/b2b/views/v0/views_test.py b/b2b/views/v0/views_test.py index a0519d711d..2db61bdf54 100644 --- a/b2b/views/v0/views_test.py +++ b/b2b/views/v0/views_test.py @@ -23,7 +23,7 @@ ) from users.factories import UserFactory -pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("raise_nplusone")] +pytestmark = [pytest.mark.django_db] def test_b2b_contract_attachment_bad_code(user): @@ -223,6 +223,7 @@ def test_b2b_contract_attachment_full_contract(): ).exists() +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize("user_has_edx_user", [True, False]) @pytest.mark.parametrize("has_price", [True, False]) def test_b2b_enroll(mocker, settings, user_has_edx_user, has_price): diff --git a/cms/management/commands/bulk_populate_signatories.py b/cms/management/commands/bulk_populate_signatories.py new file mode 100644 index 0000000000..a5b636e477 --- /dev/null +++ b/cms/management/commands/bulk_populate_signatories.py @@ -0,0 +1,162 @@ +"""Reads the signatories from a CSV file and populates the Signatory model in bulk.""" + +import csv +import logging +from pathlib import Path + +import requests +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand +from wagtail.images.models import Image + +from cms.models import SignatoryIndexPage, SignatoryPage + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Bulk populate signatories from a CSV file.""" + + help = "Bulk populate signatories from a CSV file" + + def add_arguments(self, parser): + """Parses command line arguments.""" + parser.add_argument( + "--csv-file", + type=str, + help="Path to the CSV file containing signatory data", + required=True, + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show which signatories would be created without making any changes", + ) + + def handle(self, *args, **options): # noqa: ARG002 + """Handles the command execution.""" + csv_file_path = options["csv_file"] + dry_run = options["dry_run"] + + if dry_run: + self.stdout.write(self.style.WARNING("[DRY RUN] - No changes will be made")) + try: + with Path(csv_file_path).open("r", newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + for row_num, row in enumerate(reader, start=2): # Start at 2 for header + self.process_signatory(row, row_num, dry_run) + + except FileNotFoundError: + self.stdout.write(self.style.ERROR(f"CSV file not found: {csv_file_path}")) + except Exception as e: # noqa: BLE001 + self.stdout.write(self.style.ERROR(f"Error processing CSV: {e!s}")) + + def fetch_or_create_signature_image(self, signatory_name, image_url): + """Fetches an image from a URL and creates a Wagtail Image instance.""" + if not image_url: + return None + try: + signature_image_title = f"{signatory_name} Signature" + existing_image = Image.objects.filter( + title__icontains=signature_image_title + ).first() + + if existing_image: + return existing_image + else: + response = requests.get(image_url, timeout=10) + response.raise_for_status() + + # Extract filename from URL or create one + filename = Path(image_url.split("?")[0]).name + if not filename or "." not in filename: + filename = ( + f"signatory_{signatory_name.replace(' ', '_').lower()}.jpg" + ) + + # Create Wagtail Image instance + image_file = ContentFile(response.content, name=filename) + signature_image = Image( + title=f"{signatory_name} Signature", file=image_file + ) + signature_image.save() + except Exception as e: # noqa: BLE001 + self.stdout.write( + self.style.WARNING( + f"Could not download image for '{signatory_name}': {e!s}" + ) + ) + return None + else: + return signature_image + + def process_signatory(self, row, row_num, dry_run): + """Processes a single signatory row.""" + name = row.get("name", "").strip() + signatory_title = row.get("signatory_title", "").strip() + signatory_image_url = row.get("signatory_image_url", "").strip() + + if not name: + self.stdout.write( + self.style.WARNING(f"Row {row_num}: Skipping - no name provided") + ) + return + + # Check for duplicates + existing_signatory = SignatoryPage.objects.filter(name=name).first() + + try: + if dry_run: + if existing_signatory: + self.stdout.write( + self.style.SUCCESS( + f'[DRY RUN] Row {row_num}: Signatory already exists - would skip "{name}"' + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f'[DRY RUN] Row {row_num}: Would create signatory "{name}"' + ) + ) + else: + signature_image = self.fetch_or_create_signature_image( + name, signatory_image_url + ) + + if existing_signatory: + self.stdout.write( + self.style.SUCCESS( + f'Row {row_num}: Signatory already exists - skipping "{name}"' + ) + ) + else: + # Create new signatory + # Download and create image if URL provided + signatory_index_page = SignatoryIndexPage.objects.first() + if not signatory_index_page: + raise ValidationError("No SignatoryIndexPage found in the CMS.") # noqa: EM101, TRY301 + + signatory_page = SignatoryPage( + name=name, + title_1=signatory_title, + signature_image=signature_image, + ) + signatory_index_page.add_child(instance=signatory_page) + signatory_page.save_revision().publish() + + self.stdout.write( + self.style.SUCCESS(f'Row {row_num}: Created signatory "{name}"') + ) + + except ValidationError as e: + self.stdout.write( + self.style.ERROR(f'Row {row_num}: Validation error for "{name}": {e!s}') + ) + except Exception as e: # noqa: BLE001 + self.stdout.write( + self.style.ERROR( + f'Row {row_num}: Error processing signatory "{name}": {e!s}' + ) + ) diff --git a/cms/management/commands/remove_signatory.py b/cms/management/commands/remove_signatory.py new file mode 100644 index 0000000000..0cc8ee8df0 --- /dev/null +++ b/cms/management/commands/remove_signatory.py @@ -0,0 +1,92 @@ +from django.core.management.base import BaseCommand +from wagtail.blocks import StreamValue + +from cms.models import CertificatePage + + +class Command(BaseCommand): + """Django management command to remove a signatory from all certificate pages. + + This command finds all certificate pages that contain a specific signatory + and removes that signatory from their signatories field, creating new + revisions and publishing the updated pages. + """ + + help = "Remove a signatory from all certificate pages and create new revisions" + + def add_arguments(self, parser): + """Parses command line arguments.""" + parser.add_argument( + "--signatory-id", + type=int, + help="ID of the signatory page to remove", + required=True, + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show which certificate pages would be updated without making changes", + ) + + def handle(self, *args, **options): # noqa: ARG002 + """Handles the command execution.""" + signatory_id = options["signatory_id"] + dry_run = options["dry_run"] + + # Find all certificate pages that have this signatory + certificate_pages = CertificatePage.objects.all() + updated_count = 0 + + if not certificate_pages: + self.stdout.write( + self.style.WARNING( + f"No certificate pages found with signatory ID {signatory_id}" + ) + ) + return + + for cert_page in certificate_pages: + signatory_blocks = list(cert_page.signatories) + filtered_data = [ + { + "type": block.block_type, + "value": block.value.id, # pass the page ID, NOT the whole page instance + } + for block in signatory_blocks + if block.value.id != signatory_id + ] + + if len(filtered_data) != len(signatory_blocks): + if dry_run: + self.stdout.write( + self.style.SUCCESS( + f"[DRY RUN] Would remove signatory from certificate page {cert_page}" + ) + ) + else: + cert_page.signatories = StreamValue( + cert_page.signatories.stream_block, filtered_data, is_lazy=True + ) + cert_page.save_revision().publish() + self.stdout.write( + self.style.SUCCESS( + f"Successfully removed signatory from the certificate page {cert_page}" + ) + ) + updated_count += 1 + + if updated_count == 0: + self.stdout.write( + self.style.WARNING( + f"No certificate pages found with signatory ID {signatory_id}" + ) + ) + else: + action_text = ( + "[DRY RUN] Would remove" if dry_run else "Successfully removed" + ) + self.stdout.write( + self.style.SUCCESS( + f"{action_text} signatory from {updated_count} certificate pages" + ) + ) diff --git a/courses/api_test.py b/courses/api_test.py index a5f1eeb1eb..f9166686fc 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -51,7 +51,6 @@ ProgramCertificateFactory, ProgramEnrollmentFactory, ProgramFactory, - ProgramRequirementFactory, program_with_empty_requirements, # noqa: F401 program_with_requirements, # noqa: F401 ) @@ -239,7 +238,6 @@ def test_create_run_enrollments_multiple_programs( ) program_with_empty_requirements.add_requirement(test_enrollment.run.course) program2 = ProgramFactory.create() - ProgramRequirementFactory.add_root(program2) root_node = program2.requirements_root root_node.add_child( @@ -1213,7 +1211,6 @@ def test_generate_program_certificate_failure_missing_certificates( """ course = CourseFactory.create() CourseRunFactory.create_batch(3, course=course) - ProgramRequirementFactory.add_root(program_with_requirements.program) program_with_requirements.program.add_requirement(course) result = generate_program_certificate( @@ -1262,7 +1259,6 @@ def test_generate_program_certificate_success_single_requirement_course(user, mo ) course = CourseFactory.create() program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( @@ -1295,7 +1291,6 @@ def test_generate_program_certificate_success_multiple_required_courses(user, mo ) courses = CourseFactory.create_batch(3) program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( @@ -1328,7 +1323,6 @@ def test_generate_program_certificate_success_minimum_electives_not_met(user, mo # Create Program with 2 minimum elective courses. program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( @@ -1457,7 +1451,6 @@ def test_generate_program_certificate_failure_not_all_passed_nested_elective_sti ) # Create Program program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( @@ -1544,12 +1537,10 @@ def test_generate_program_certificate_with_subprogram_requirement(user, mocker): # Create a sub-program that the user will complete sub_program = ProgramFactory.create() sub_course = CourseFactory.create() - ProgramRequirementFactory.add_root(sub_program) sub_program.add_requirement(sub_course) # Create the main program that requires the sub-program main_program = ProgramFactory.create() - ProgramRequirementFactory.add_root(main_program) main_program.add_program_requirement(sub_program) # User completes the sub-program course and gets a certificate @@ -1591,12 +1582,10 @@ def test_generate_program_certificate_with_subprogram_requirement_missing_certif # Create a sub-program sub_program = ProgramFactory.create() sub_course = CourseFactory.create() - ProgramRequirementFactory.add_root(sub_program) sub_program.add_requirement(sub_course) # Create the main program that requires the sub-program main_program = ProgramFactory.create() - ProgramRequirementFactory.add_root(main_program) main_program.add_program_requirement(sub_program) # User does NOT complete the sub-program course (no certificate) @@ -1622,12 +1611,10 @@ def test_generate_program_certificate_with_revoked_subprogram_certificate(user, # Create a sub-program sub_program = ProgramFactory.create() sub_course = CourseFactory.create() - ProgramRequirementFactory.add_root(sub_program) sub_program.add_requirement(sub_course) # Create the main program that requires the sub-program main_program = ProgramFactory.create() - ProgramRequirementFactory.add_root(main_program) main_program.add_program_requirement(sub_program) # User completes the sub-program and gets a certificate, but it gets revoked diff --git a/courses/conftest.py b/courses/conftest.py index 9a36081ceb..d1b448c458 100644 --- a/courses/conftest.py +++ b/courses/conftest.py @@ -8,7 +8,6 @@ CourseFactory, CourseRunFactory, ProgramFactory, - ProgramRequirementFactory, ) from courses.models import ( ProgramRequirement, @@ -84,7 +83,6 @@ def _create_course(n): def _create_program(courses): program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root required_courses_node = root_node.add_child( node_type=ProgramRequirementNodeType.OPERATOR, diff --git a/courses/factories.py b/courses/factories.py index 8506875767..70e5144774 100644 --- a/courses/factories.py +++ b/courses/factories.py @@ -1,6 +1,8 @@ """Factories for creating course data in tests""" +import json import string # noqa: F401 +from pathlib import Path from types import SimpleNamespace import factory @@ -34,13 +36,22 @@ FAKE = faker.Factory.create() +# stub data that mirrors production +DEPARTMENTS = { + dept["slug"]: dept["name"] + for dept in json.loads(Path("courses/test_data/departments.json").read_text()) +} + class DepartmentFactory(DjangoModelFactory): - name = factory.Sequence(lambda x: f"Testing - {x} Department") + name = factory.LazyAttribute(lambda dept: DEPARTMENTS[dept.slug]) + slug = factory.Iterator(DEPARTMENTS.keys()) class Meta: model = Department + django_get_or_create = ("slug",) + class ProgramFactory(DjangoModelFactory): """Factory for Programs""" @@ -55,7 +66,7 @@ class ProgramFactory(DjangoModelFactory): def departments(self, create, extracted, **kwargs): # noqa: ARG002 if not create or not extracted: return - self.departments.add(*extracted) + self.departments.set(extracted) class Meta: model = Program @@ -166,14 +177,6 @@ class ProgramRequirementFactory(DjangoModelFactory): class Meta: model = ProgramRequirement - @classmethod - def add_root(cls, program): - if not ProgramRequirement.get_root_nodes().filter(program=program).exists(): - return ProgramRequirement.add_root( - program=program, node_type=ProgramRequirementNodeType.PROGRAM_ROOT.value - ) - return program.get_requirements_root() - class BlockedCountryFactory(DjangoModelFactory): """Factory for BlockedCountry""" @@ -272,7 +275,6 @@ class Meta: @pytest.fixture def program_with_empty_requirements(): program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( diff --git a/courses/migrations/0068_department_related_names.py b/courses/migrations/0068_department_related_names.py new file mode 100644 index 0000000000..cdc7022e84 --- /dev/null +++ b/courses/migrations/0068_department_related_names.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.25 on 2025-10-07 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0067_program_courses_pro_live_e5a870_idx_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="course", + name="departments", + field=models.ManyToManyField( + related_name="courses", to="courses.department" + ), + ), + migrations.AlterField( + model_name="program", + name="departments", + field=models.ManyToManyField( + related_name="programs", to="courses.department" + ), + ), + ] diff --git a/courses/models.py b/courses/models.py index 0b07f16c44..9790cf4dbd 100644 --- a/courses/models.py +++ b/courses/models.py @@ -11,12 +11,12 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import models -from django.db.models import Q +from django.db.models import Exists, OuterRef, Prefetch, Q from django.db.models.constraints import CheckConstraint, UniqueConstraint from django.utils.functional import cached_property from django.utils.text import slugify from django_countries.fields import CountryField -from mitol.common.models import TimestampedModel +from mitol.common.models import TimestampedModel, TimestampedModelQuerySet from mitol.common.utils.datetime import now_in_utc from mitol.openedx.utils import get_course_number from modelcluster.fields import ParentalManyToManyField @@ -182,6 +182,32 @@ def get_queryset(self): ) +class DepartmentQuerySet(TimestampedModelQuerySet): + """QuerySet for Department""" + + def for_serialization(self): + return self.prefetch_related( + Prefetch( + "courses", + queryset=Course.objects.annotate( + has_enrollable_courserun=Exists( + CourseRun.objects.enrollable().filter(course_id=OuterRef("pk")) + ), + ) + .filter( + live=True, + page__live=True, + has_enrollable_courserun=True, + ) + .only("id"), + ), + Prefetch( + "programs", + queryset=Program.objects.filter(live=True, page__live=True).only("id"), + ), + ) + + class Department(TimestampedModel): """ Departments. @@ -190,6 +216,8 @@ class Department(TimestampedModel): name = models.CharField(max_length=128, unique=True) slug = models.SlugField(max_length=128, unique=True) + objects = DepartmentQuerySet.as_manager() + def __str__(self): return self.name @@ -222,7 +250,9 @@ class Meta: blank=True, null=True, ) - departments = models.ManyToManyField(Department, blank=False) + departments = models.ManyToManyField( + Department, blank=False, related_name="programs" + ) availability = models.CharField( choices=AVAILABILITY_CHOICES, default=AVAILABILITY_ANYTIME, max_length=255 ) @@ -821,7 +851,9 @@ class Meta: max_length=255, unique=True, validators=[validate_url_path_field] ) live = models.BooleanField(default=False, db_index=True) - departments = models.ManyToManyField(Department, blank=False) + departments = models.ManyToManyField( + Department, blank=False, related_name="courses" + ) flexible_prices = GenericRelation( "flexiblepricing.FlexiblePrice", object_id_field="courseware_object_id", diff --git a/courses/models_test.py b/courses/models_test.py index 4a7117b9a8..57eac49354 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -22,7 +22,6 @@ ProgramCertificateFactory, ProgramEnrollmentFactory, ProgramFactory, - ProgramRequirementFactory, program_with_empty_requirements, # noqa: F401 program_with_requirements, # noqa: F401 ) @@ -1028,7 +1027,6 @@ def test_program_minimum_elective_courses_requirement(): """Tests to make sure the related programs functionality in the model works.""" minimum_elective_required = 5 program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( @@ -1050,7 +1048,6 @@ def test_program_minimum_elective_courses_requirement(): def test_program_minimum_elective_courses_requirement_no_elective_node(): """Tests to make sure the related programs functionality in the model works.""" program = ProgramFactory.create() - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( diff --git a/courses/serializers/v1/departments.py b/courses/serializers/v1/departments.py index ecd120acac..f2ede677b3 100644 --- a/courses/serializers/v1/departments.py +++ b/courses/serializers/v1/departments.py @@ -14,8 +14,8 @@ class Meta: class DepartmentWithCountSerializer(DepartmentSerializer): """CourseRun model serializer that includes the number of courses and programs associated with each departments""" - courses = serializers.IntegerField() - programs = serializers.IntegerField() + courses = serializers.IntegerField(source="courses_count") + programs = serializers.IntegerField(source="program_count") class Meta: model = models.Department diff --git a/courses/serializers/v2/departments.py b/courses/serializers/v2/departments.py index 62dd38f545..923ce4a030 100644 --- a/courses/serializers/v2/departments.py +++ b/courses/serializers/v2/departments.py @@ -1,7 +1,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from courses.models import CourseRun, Department +from courses.models import Department class DepartmentSerializer(serializers.ModelSerializer): @@ -18,7 +18,7 @@ class DepartmentWithCoursesAndProgramsSerializer(DepartmentSerializer): course_ids = serializers.SerializerMethodField() program_ids = serializers.SerializerMethodField() - @extend_schema_field(serializers.ListField) + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_course_ids(self, instance): """ Returns a list of course IDs associated with courses which are live and @@ -32,19 +32,9 @@ def get_course_ids(self, instance): Returns: list: Course IDs associated with the Department. """ - related_courses = instance.course_set.filter(live=True, page__live=True) - relevant_courseruns = ( - CourseRun.objects.enrollable() - .filter(course__in=related_courses) - .values_list("id", flat=True) - ) - return ( - related_courses.filter(courseruns__id__in=relevant_courseruns) - .distinct() - .values_list("id", flat=True) - ) + return [course.id for course in instance.courses.all()] - @extend_schema_field(serializers.ListField) + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_program_ids(self, instance): """ Returns a list of program IDs associated with the department @@ -56,11 +46,7 @@ def get_program_ids(self, instance): Returns: list: Program IDs associated with the Department. """ - return ( - instance.program_set.filter(live=True, page__live=True) - .distinct() - .values_list("id", flat=True) - ) + return [program.id for program in instance.programs.all()] class Meta: model = Department diff --git a/courses/test_data/departments.json b/courses/test_data/departments.json new file mode 100644 index 0000000000..a48fd338e8 --- /dev/null +++ b/courses/test_data/departments.json @@ -0,0 +1,90 @@ +[ + { + "name": "Aeronautics and Astronautics", + "slug": "aeronautics-and-astronautics" + }, + { + "name": "Anthropology", + "slug": "anthropology" + }, + { + "name": "Biology", + "slug": "biology" + }, + { + "name": "Brain and Cognitive Sciences", + "slug": "brain-and-cognitive-sciences" + }, + { + "name": "Center for Transportation and Logistics", + "slug": "center-for-transportation-and-logistics" + }, + { + "name": "Chemical Engineering", + "slug": "chemical-engineering" + }, + { + "name": "Chemistry", + "slug": "chemistry" + }, + { + "name": "Economics", + "slug": "economics" + }, + { + "name": "Electrical Engineering and Computer Science", + "slug": "electrical-engineering-and-computer-science" + }, + { + "name": "Global Studies and Languages", + "slug": "global-studies-and-languages" + }, + { + "name": "History", + "slug": "history" + }, + { + "name": "Humanities", + "slug": "humanities" + }, + { + "name": "Linguistics and Philosophy", + "slug": "linguistics-and-philosophy" + }, + { + "name": "Literature", + "slug": "literature" + }, + { + "name": "Management", + "slug": "management" + }, + { + "name": "Materials Science and Engineering", + "slug": "materials-science-and-engineering" + }, + { + "name": "Mathematics", + "slug": "mathematics" + }, + { + "name": "Mechanical Engineering", + "slug": "mechanical-engineering" + }, + { + "name": "Music and Theater Arts", + "slug": "music-and-theater-arts" + }, + { + "name": "Physics", + "slug": "physics" + }, + { + "name": "Political Science", + "slug": "political-science" + }, + { + "name": "Urban Studies and Planning", + "slug": "urban-studies-and-planning" + } +] diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index d5afaa34a3..902ae35bdf 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -710,7 +710,7 @@ class DepartmentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Department.objects.annotate( - courses=Count("course"), programs=Count("program") + course_count=Count("courses"), program_count=Count("programs") ) @extend_schema( diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 9a57cde15d..7b84128458 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -52,7 +52,7 @@ from main.utils import encode_json_cookie_value from openedx.exceptions import NoEdxApiAuthError -pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("raise_nplusone")] +pytestmark = [pytest.mark.django_db] EXAMPLE_URL = "http://example.com" @@ -146,6 +146,7 @@ def test_delete_program( assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True) @pytest.mark.parametrize("course_catalog_program_count", [15], indirect=True) def test_get_courses( @@ -301,6 +302,7 @@ def test_delete_course( assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED +@pytest.mark.skip_nplusone_check def test_get_course_runs(user_drf_client, course_runs, django_assert_max_num_queries): """Test the view that handles requests for all CourseRuns""" with django_assert_max_num_queries(38) as context: @@ -419,6 +421,7 @@ def test_delete_course_run(user_drf_client, course_runs, django_assert_max_num_q assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED +@pytest.mark.skip_nplusone_check def test_user_enrollments_list(user_drf_client, user): """The user enrollments view should return serialized enrollments for the logged-in user""" assert UserEnrollmentsApiViewSet.serializer_class == CourseRunEnrollmentSerializer @@ -740,6 +743,7 @@ def test_update_user_enrollment_failure( patched_log_exception.assert_called_once() +@pytest.mark.skip_nplusone_check def test_program_enrollments(user_drf_client, user, programs): """ Tests the program enrollments API, which should show the user's enrollment diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index f01fd7d415..d5d5ce90bd 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -148,6 +148,7 @@ def get_queryset(self): queryset=ProgramCollection.objects.only("id", "title"), ), ) + .order_by("title") ) @extend_schema( @@ -301,7 +302,7 @@ class DepartmentViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [] def get_queryset(self): - return Department.objects.all().order_by("name") + return Department.objects.for_serialization().order_by("name") @extend_schema( operation_id="departments_retrieve_v2", diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index e7dd7e026b..96a862855f 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -50,11 +50,12 @@ from main.test_utils import assert_drf_json_equal, duplicate_queries_check from users.factories import UserFactory -pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("raise_nplusone")] +pytestmark = [pytest.mark.django_db] logger = logging.getLogger(__name__) faker = Faker() +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True) @pytest.mark.parametrize("course_catalog_program_count", [12], indirect=True) def test_get_programs( @@ -194,6 +195,7 @@ def test_delete_program( assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED +@pytest.mark.skip_nplusone_check @pytest.mark.usefixtures("course_catalog_data") @pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True) @pytest.mark.parametrize("course_catalog_program_count", [12], indirect=True) @@ -351,6 +353,7 @@ def test_filter_with_org_id_user_not_associated_with_org_returns_no_courses( @pytest.mark.django_db +@pytest.mark.skip_nplusone_check def test_filter_without_org_id_authenticated_user(user_drf_client): course_with_contract = CourseFactory(title="Contract Course") contract = ContractPageFactory(active=True) @@ -573,6 +576,7 @@ def test_next_run_id_with_org_filter( # noqa: PLR0915 assert resp.status_code == 404 +@pytest.mark.skip_nplusone_check def test_user_enrollments_b2b_organization_filter(user_drf_client, user): """Test that user enrollments can be filtered by B2B organization ID""" diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index c12e13321c..f0d3db460e 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -410,6 +410,7 @@ def test_unenrollment_unenrolls_learner(mocker, user): unenroll_mock.assert_called() +@pytest.mark.skip_nplusone_check def test_process_cybersource_payment_response( # noqa: PLR0913 settings, rf, mocker, user_client, user, products ): @@ -446,6 +447,7 @@ def test_process_cybersource_payment_response( # noqa: PLR0913 assert result == OrderStatus.FULFILLED +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize("include_discount", [True, False]) def test_process_cybersource_payment_decline_response( # noqa: PLR0913 rf, mocker, user_client, user, products, include_discount diff --git a/ecommerce/mail_api_test.py b/ecommerce/mail_api_test.py index 07be434a48..a490543fb8 100644 --- a/ecommerce/mail_api_test.py +++ b/ecommerce/mail_api_test.py @@ -15,6 +15,7 @@ def products(): return ProductFactory.create_batch(5) +@pytest.mark.skip_nplusone_check def test_mail_api_task_called( # noqa: PLR0913 settings, mocker, user, products, user_client, django_capture_on_commit_callbacks ): @@ -36,6 +37,7 @@ def test_mail_api_task_called( # noqa: PLR0913 assert mock_delayed_send_ecommerce_order_receipt.call_args[0][0] == order.id +@pytest.mark.skip_nplusone_check def test_mail_api_receipt_generation( # noqa: PLR0913 settings, mocker, user, products, user_client, django_capture_on_commit_callbacks ): @@ -64,6 +66,7 @@ def test_mail_api_receipt_generation( # noqa: PLR0913 assert str(lines[0].unit_price) in rendered_template.body +@pytest.mark.skip_nplusone_check def test_mail_api_refund_email_generation( settings, mocker, user, products, user_client ): diff --git a/ecommerce/serializers_test.py b/ecommerce/serializers_test.py index 7aec397e84..d18c280c4c 100644 --- a/ecommerce/serializers_test.py +++ b/ecommerce/serializers_test.py @@ -353,6 +353,7 @@ def get_receipt_serializer_test_data(mocker, user, products, user_client): return (order, test_data) +@pytest.mark.skip_nplusone_check def test_order_receipt_purchase_serializer( settings, mocker, user, products, user_client ): @@ -366,6 +367,7 @@ def test_order_receipt_purchase_serializer( assert serialized_data == test_data["receipt"] +@pytest.mark.skip_nplusone_check def test_order_receipt_purchaser_serializer( settings, mocker, user, products, user_client ): @@ -379,6 +381,7 @@ def test_order_receipt_purchaser_serializer( assert serialized_data == test_data["purchaser"] +@pytest.mark.skip_nplusone_check def test_order_receipt_order_serializer(settings, mocker, user, products, user_client): settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 (order, test_data) = get_receipt_serializer_test_data( @@ -391,6 +394,7 @@ def test_order_receipt_order_serializer(settings, mocker, user, products, user_c assert serialized_data == test_data["order"] +@pytest.mark.skip_nplusone_check def test_order_receipt_lines_serializer(settings, mocker, user, products, user_client): settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 (order, test_data) = get_receipt_serializer_test_data( diff --git a/ecommerce/tasks_test.py b/ecommerce/tasks_test.py index a343c453ed..473ece8d23 100644 --- a/ecommerce/tasks_test.py +++ b/ecommerce/tasks_test.py @@ -15,6 +15,7 @@ def products(): return ProductFactory.create_batch(5) +@pytest.mark.skip_nplusone_check def test_delayed_order_receipt_sends_email( # noqa: PLR0913 settings, mocker, user, products, user_client, django_capture_on_commit_callbacks ): @@ -35,6 +36,7 @@ def test_delayed_order_receipt_sends_email( # noqa: PLR0913 mock_send_ecommerce_order_receipt.assert_called() +@pytest.mark.skip_nplusone_check def test_delayed_order_refund_sends_email( settings, mocker, user, products, user_client ): diff --git a/ecommerce/views_test.py b/ecommerce/views_test.py index 80c129971f..b96765cc45 100644 --- a/ecommerce/views_test.py +++ b/ecommerce/views_test.py @@ -89,6 +89,7 @@ def mock_create_run_enrollments(request, mocker): ) +@pytest.mark.skip_nplusone_check def test_list_products(user_drf_client, products): resp = user_drf_client.get(reverse("products_api-list"), {"l": 10, "o": 0}) resp_products = sorted(resp.json()["results"], key=op.itemgetter("id")) @@ -438,6 +439,7 @@ def test_redeem_time_limited_discount( # noqa: PLR0913 assert "not found" in resp_json +@pytest.mark.skip_nplusone_check def test_start_checkout(user, user_drf_client, products): """ Hits the start checkout view, which should create an Order record @@ -455,6 +457,7 @@ def test_start_checkout(user, user_drf_client, products): assert order.state == OrderStatus.PENDING +@pytest.mark.skip_nplusone_check def test_start_checkout_with_discounts(user, user_drf_client, products, discounts): """ Applies a discount, then hits the start checkout view, which should create @@ -499,6 +502,7 @@ def test_start_checkout_with_invalid_discounts(user, user_client, products, disc assert resp.status_code == 302 +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "apply_discount", [ @@ -549,6 +553,7 @@ def test_start_checkout_with_discounts_and_b2b( assert resp.status_code == 302 +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "decision, expected_redirect_url, expected_state, basket_exists", # noqa: PT006 [ @@ -811,6 +816,7 @@ def test_discount_rest_api(admin_drf_client, user_drf_client): assert Discount.objects.filter(pk=discount_payload["id"]).count() == 0 +@pytest.mark.skip_nplusone_check def test_discount_redemptions_api( user, products, discounts, admin_drf_client, user_drf_client ): @@ -895,6 +901,7 @@ def test_user_discounts_api(user_drf_client, admin_drf_client, discounts, user): assert data["count"] > 0 +@pytest.mark.skip_nplusone_check def test_paid_and_unpaid_courserun_checkout( settings, user, user_client, user_drf_client, products ): @@ -931,6 +938,7 @@ def test_paid_and_unpaid_courserun_checkout( assert resp.status_code == 200 +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "decision, expected_state, basket_exists", # noqa: PT006 [ @@ -1011,6 +1019,7 @@ def test_checkout_api_result( # noqa: PLR0913 assert Basket.objects.filter(id=basket.id).exists() is basket_exists +@pytest.mark.skip_nplusone_check def test_checkout_api_result_verification_failure( user_client, api_client, @@ -1043,6 +1052,7 @@ def test_checkout_api_result_verification_failure( assert resp.status_code == 403 +@pytest.mark.skip_nplusone_check @pytest.mark.parametrize( "upgrade_deadline, status_code", # noqa: PT006 [ @@ -1077,6 +1087,7 @@ def test_non_upgradable_courserun_checkout( # noqa: PLR0913 ) +@pytest.mark.skip_nplusone_check def test_start_checkout_with_zero_value(settings, user, user_client, products): """ Check that the checkout redirects the user to dashboard when basket price is zero @@ -1101,6 +1112,7 @@ def test_start_checkout_with_zero_value(settings, user, user_client, products): ) +@pytest.mark.skip_nplusone_check def test_start_checkout_and_ensure_edx_username_created(mocker, settings, products): """ Check that checking out with a user that doesn't have an edx username diff --git a/fixtures/common.py b/fixtures/common.py index 7f611a0118..e00953d3fc 100644 --- a/fixtures/common.py +++ b/fixtures/common.py @@ -6,8 +6,8 @@ import pytest import responses from django.test.client import Client -from nplusone.core import profiler from rest_framework.test import APIClient +from zeal import zeal_ignore from users.factories import UserFactory @@ -136,12 +136,6 @@ def user_profile_dict(): ) -@pytest.fixture -def nplusone_fail(settings): - """Configures the nplusone app to raise errors""" - settings.NPLUSONE_RAISE = True - - @pytest.fixture(autouse=True) def webpack_stats(settings): """Mocks out webpack stats""" @@ -156,10 +150,11 @@ def webpack_stats(settings): ) -@pytest.fixture -def raise_nplusone(request): - if request.node.get_closest_marker("skip_nplusone"): - yield - else: - with profiler.Profiler(): +@pytest.fixture(autouse=True) +def check_nplusone(request): + """Raise nplusone errors""" + if request.node.get_closest_marker("skip_nplusone_check"): + with zeal_ignore(): yield + else: + yield diff --git a/flexiblepricing/api_test.py b/flexiblepricing/api_test.py index cb4e81f0b4..d304114f7b 100644 --- a/flexiblepricing/api_test.py +++ b/flexiblepricing/api_test.py @@ -22,7 +22,6 @@ CourseFactory, CourseRunFactory, ProgramFactory, - ProgramRequirementFactory, ProgramRunFactory, ) from courses.models import ( @@ -170,7 +169,6 @@ def create_courseware(create_tiers=True, past=False): # noqa: FBT002 """ end_date = None program = ProgramFactory.create(live=True) - ProgramRequirementFactory.add_root(program) root_node = program.requirements_root root_node.add_child( diff --git a/flexiblepricing/views_test.py b/flexiblepricing/views_test.py index 623ce075bd..458bf267e4 100644 --- a/flexiblepricing/views_test.py +++ b/flexiblepricing/views_test.py @@ -81,6 +81,7 @@ def test_basic_exchange_rates(user_drf_client, exchange_rate): assert len(resp.json()) >= 2 +@pytest.mark.skip_nplusone_check def test_basic_flex_payments( user_drf_client, admin_drf_client, user, flexible_price_application, mocker ): diff --git a/frontend/public/package.json b/frontend/public/package.json index e1e4175841..496b8ae410 100644 --- a/frontend/public/package.json +++ b/frontend/public/package.json @@ -79,7 +79,7 @@ "prop-types": "15.8.1", "query-string": "6.14.1", "raf": "3.4.1", - "ramda": "0.26.1", + "ramda": "0.32.0", "react": "16.14.0", "react-addons-shallow-compare": "15.6.3", "react-day-picker": "7.4.10", @@ -111,7 +111,7 @@ "sass": "1.49.11", "sass-lint": "1.13.1", "sass-loader": "12.6.0", - "serialize-javascript": "3.1.0", + "serialize-javascript": "6.0.2", "shelljs": "0.8.5", "sinon": "4.5.0", "slick-carousel": "^1.8.1", diff --git a/frontend/public/src/containers/pages/login/LoginSso.js b/frontend/public/src/containers/pages/login/LoginSso.js index e0fe02f646..4867d9bd77 100644 --- a/frontend/public/src/containers/pages/login/LoginSso.js +++ b/frontend/public/src/containers/pages/login/LoginSso.js @@ -1,9 +1,22 @@ // @flow -import React from "react" -import { Redirect } from "react-router-dom" +import React, { useEffect } from "react" +import qs from "query-string" const LoginSso = () => { - return + useEffect(() => { + const nextUrl = + new URLSearchParams(window.location.search).get("next") || + window.location.pathname + const params = qs.stringify({ next: nextUrl }) + + window.location.href = `/login/?${params}` + }, []) + + return ( +
+
Redirecting to login...
+
+ ) } export default LoginSso diff --git a/frontend/public/src/lib/api.js b/frontend/public/src/lib/api.js index 667fc67dc6..0554ac5670 100644 --- a/frontend/public/src/lib/api.js +++ b/frontend/public/src/lib/api.js @@ -44,11 +44,11 @@ export function csrfSafeMethod(method: string): boolean { return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method) } -const headers = R.merge({ headers: {} }) +const headers = R.mergeRight({ headers: {} }) -const method = R.merge({ method: "GET" }) +const method = R.mergeRight({ method: "GET" }) -const credentials = R.merge({ credentials: "same-origin" }) +const credentials = R.mergeRight({ credentials: "same-origin" }) const setWith = R.curry((path, valFunc, obj) => R.set(path, valFunc(), obj)) @@ -57,7 +57,7 @@ const csrfToken = R.unless( setWith(R.lensPath(["headers", "X-CSRFToken"]), () => getCSRFCookie()) ) -const jsonHeaders = R.merge({ +const jsonHeaders = R.mergeRight({ headers: { "Content-Type": "application/json", Accept: "application/json" @@ -130,7 +130,7 @@ const _fetchJSONWithCSRF = async ( // and reject a Left. return R.compose( resolveEither, - S.bimap(R.merge({ errorStatusCode: response.status }), R.identity), + S.bimap(R.mergeRight({ errorStatusCode: response.status }), R.identity), filterE(() => response.ok), parseJSON, handleEmptyJSON diff --git a/hubspot_sync/tasks.py b/hubspot_sync/tasks.py index 19de2a7431..89b6a70084 100644 --- a/hubspot_sync/tasks.py +++ b/hubspot_sync/tasks.py @@ -387,11 +387,12 @@ def batch_upsert_hubspot_objects( # pylint:disable=too-many-arguments # noqa: id__in=[id[0] for id in synced_object_ids] # noqa: A001 ) if model_name == "user": - unsynced_objects = ( - unsynced_objects.filter(is_active=True, email__contains="@") - .exclude(social_auth__isnull=True) - .order_by(F("hubspot_sync_datetime").asc(nulls_first=True)) - ) + unsynced_objects = unsynced_objects.filter( + is_active=True, + email__contains="@", + global_id__isnull=False, + last_login__isnull=False, + ).order_by(F("hubspot_sync_datetime").asc(nulls_first=True)) unsynced_object_ids = unsynced_objects.values_list("id", flat=True) object_ids = unsynced_object_ids if create else synced_object_ids elif not create: diff --git a/main/settings.py b/main/settings.py index ca771e9084..9e6f36d5f8 100644 --- a/main/settings.py +++ b/main/settings.py @@ -36,7 +36,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.131.2" +VERSION = "0.131.3" log = logging.getLogger() @@ -352,11 +352,18 @@ "oauth2_provider.middleware.OAuth2TokenMiddleware", "django_scim.middleware.SCIMAuthCheckMiddleware", ) +ZEAL_ENABLE = get_bool( + name="ZEAL_ENABLE", default=False, description="Whether to enable zeal or not" +) -# enable the nplusone profiler only in debug mode -if DEBUG: - INSTALLED_APPS += ("nplusone.ext.django",) - MIDDLEWARE += ("nplusone.ext.django.NPlusOneMiddleware",) +# enable the zeal nplusone profiler only in debug mode or under pytest +if ZEAL_ENABLE or ENVIRONMENT == "pytest": + INSTALLED_APPS += ("zeal",) + # this should be the first middleware so we catch any issues in our own middleware + MIDDLEWARE += ("zeal.middleware.zeal_middleware",) + +ZEAL_RAISE = False +ZEAL_ALLOWLIST = [] SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" @@ -793,10 +800,6 @@ HOSTNAME = platform.node().split(".")[0] -# nplusone profiler logger configuration -NPLUSONE_LOGGER = logging.getLogger("nplusone") -NPLUSONE_LOG_LEVEL = logging.ERROR - LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -834,7 +837,7 @@ "level": DJANGO_LOG_LEVEL, "propagate": True, }, - "nplusone": {"handlers": ["console"], "level": "ERROR"}, + "zeal": {"handlers": ["console"], "level": "ERROR"}, }, "root": {"handlers": ["console"], "level": LOG_LEVEL}, } diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 3ea9fe9d16..756ee79630 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -2319,11 +2319,13 @@ components: pattern: ^[-a-zA-Z0-9_]+$ course_ids: type: array - items: {} + items: + type: integer readOnly: true program_ids: type: array - items: {} + items: + type: integer readOnly: true required: - course_ids diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 06ae62d548..8812be584c 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -2319,11 +2319,13 @@ components: pattern: ^[-a-zA-Z0-9_]+$ course_ids: type: array - items: {} + items: + type: integer readOnly: true program_ids: type: array - items: {} + items: + type: integer readOnly: true required: - course_ids diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 5d6e89dbe0..037505f2d1 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -2319,11 +2319,13 @@ components: pattern: ^[-a-zA-Z0-9_]+$ course_ids: type: array - items: {} + items: + type: integer readOnly: true program_ids: type: array - items: {} + items: + type: integer readOnly: true required: - course_ids diff --git a/poetry.lock b/poetry.lock index ad40b90c73..e40e93d348 100644 --- a/poetry.lock +++ b/poetry.lock @@ -109,14 +109,14 @@ tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" a [[package]] name = "authlib" -version = "1.6.4" +version = "1.6.5" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "authlib-1.6.4-py2.py3-none-any.whl", hash = "sha256:39313d2a2caac3ecf6d8f95fbebdfd30ae6ea6ae6a6db794d976405fdd9aa796"}, - {file = "authlib-1.6.4.tar.gz", hash = "sha256:104b0442a43061dc8bc23b133d1d06a2b0a9c2e3e33f34c4338929e816287649"}, + {file = "authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a"}, + {file = "authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b"}, ] [package.dependencies] @@ -204,18 +204,6 @@ files = [ jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} wcwidth = ">=0.1.4" -[[package]] -name = "blinker" -version = "1.9.0" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, - {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, -] - [[package]] name = "boto3" version = "1.38.34" @@ -1034,14 +1022,14 @@ files = [ [[package]] name = "django" -version = "4.2.24" +version = "4.2.25" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "django-4.2.24-py3-none-any.whl", hash = "sha256:a6527112c58821a0dfc5ab73013f0bdd906539790a17196658e36e66af43c350"}, - {file = "django-4.2.24.tar.gz", hash = "sha256:40cd7d3f53bc6cd1902eadce23c337e97200888df41e4a73b42d682f23e71d80"}, + {file = "django-4.2.25-py3-none-any.whl", hash = "sha256:9584cf26b174b35620e53c2558b09d7eb180a655a3470474f513ff9acb494f8c"}, + {file = "django-4.2.25.tar.gz", hash = "sha256:2391ab3d78191caaae2c963c19fd70b99e9751008da22a0adcc667c5a4f8d311"}, ] [package.dependencies] @@ -1146,26 +1134,27 @@ Django = ">=4.2" [[package]] name = "django-health-check" -version = "3.18.4.dev18+g9cfe2ea" -description = "Run checks on services like databases, queue servers, celery processes, etc." +version = "3.20.1.dev9+g592f6a8fc" +description = "Monitor the health of your Django app and its connected services." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [] develop = false [package.dependencies] -django = ">=2.2" +Django = ">=4.2" [package.extras] docs = ["sphinx"] +lint = ["ruff (==0.13.1)"] test = ["boto3", "celery", "django-storages", "pytest", "pytest-cov", "pytest-django", "redis"] [package.source] type = "git" url = "https://github.com/revsys/django-health-check" -reference = "9cfe2eaec5a15219513a36210b34875c03c64fe4" -resolved_reference = "9cfe2eaec5a15219513a36210b34875c03c64fe4" +reference = "592f6a8fc2a8481f4f810678461db52bd1b0e3d6" +resolved_reference = "592f6a8fc2a8481f4f810678461db52bd1b0e3d6" [[package]] name = "django-hijack" @@ -1496,6 +1485,18 @@ files = [ {file = "django_webpack_loader-3.2.0.tar.gz", hash = "sha256:af2b634ea11973d093ffb48a402b8838c6201d0e4e4ba9216f97b74e57791e76"}, ] +[[package]] +name = "django-zeal" +version = "2.0.4" +description = "Detect N+1s in your Django app" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "django_zeal-2.0.4-py3-none-any.whl", hash = "sha256:c37b0a7d203572d8eb8462a601f19dedfdf82324a25c7eb7273afdc52fc519fa"}, + {file = "django_zeal-2.0.4.tar.gz", hash = "sha256:934cd3968c1ede3f3ec5314c4da285ef4796f4b6de1f95870c5999370280e17e"}, +] + [[package]] name = "djangorestframework" version = "3.16.0" @@ -3178,22 +3179,6 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[[package]] -name = "nplusone" -version = "1.0.0" -description = "Detecting the n+1 queries problem in Python" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "nplusone-1.0.0-py2.py3-none-any.whl", hash = "sha256:96b1e6e29e6af3e71b67d0cc012a5ec8c97c6a2f5399f4ba41a2bbe0e253a9ac"}, - {file = "nplusone-1.0.0.tar.gz", hash = "sha256:1726c0a10c0aa7eabb04e24db2882ff97b6b7ee29d729a8d97dcbd12ef5a5651"}, -] - -[package.dependencies] -blinker = ">=1.3" -six = ">=1.9.0" - [[package]] name = "oauthlib" version = "3.2.2" @@ -6077,4 +6062,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "de4606a7c7f9184bcfaa46cf598308b2e391f468e9cdb9a9707ebb581b9459de" +content-hash = "a591f2e9ed75bfbbe0892a19d588f951f14a5e3d262dc77d97377f7c207e51bc" diff --git a/pyproject.toml b/pyproject.toml index 972a9201b6..871633b6e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,12 +21,12 @@ celery-redbeat = "^2.0.0" click = ">=8.1,!=8.2.0" deepdiff = "^8.0.0" dj-database-url = "^0.5.0" -django = "4.2.24" +django = "4.2.25" django-anymail = {extras = ["mailgun"], version = "^11.1"} django-cors-headers = "^4.0.0" django-countries = "^7.2.1" django-filter = "^24.3" -django-health-check = { git = "https://github.com/revsys/django-health-check", rev="9cfe2eaec5a15219513a36210b34875c03c64fe4" } # pragma: allowlist secret +django-health-check = { git = "https://github.com/revsys/django-health-check", rev="592f6a8fc2a8481f4f810678461db52bd1b0e3d6" } # pragma: allowlist secret django-hijack = "^3.6.0" django-ipware = "^7.0.0" django-oauth-toolkit = "^1.7.0" @@ -100,12 +100,12 @@ granian = "^2.5.4" bpython = "^0.25" ddt = "^1.6.0" django-debug-toolbar = "^4.1.0" +django-zeal = "^2.0.4" factory-boy = "^3.2.0" faker = "^8.8.2" flaky = "^3.7.0" freezegun = "^1.2" ipdb = "^0.13.13" -nplusone = "^1.0.0" pdbpp = "^0.11.6" pre-commit = "^3.7.0" pytest-cov = "^4.1.0" diff --git a/pytest.ini b/pytest.ini index 6c76e7ef3a..ddaa8c1560 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,6 +5,7 @@ filterwarnings = error ignore::DeprecationWarning ignore::PendingDeprecationWarning + ignore:N+1 detected on ignore:Failed to load HostKeys env = CELERY_TASK_ALWAYS_EAGER=True @@ -23,6 +24,7 @@ env = MITX_ONLINE_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend MITX_ONLINE_NOTIFICATION_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend MITX_ONLINE_ENVIRONMENT=pytest + MITXONLINE_DOCKER_BASE_URL= OPENEDX_API_BASE_URL=http://localhost:18000 OPENEDX_API_CLIENT_ID=fake_client_id OPENEDX_API_CLIENT_SECRET=fake_client_secret @@ -33,3 +35,4 @@ env = MITOL_APIGATEWAY_DISABLE_MIDDLEWARE=True markers = dont_mock_enrollments + skip_nplusone_check diff --git a/users/serializers.py b/users/serializers.py index e5e0a59abe..ace4f925b0 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -102,16 +102,12 @@ class LegalAddressSerializer(serializers.ModelSerializer): def validate_first_name(self, value): """Validates the first name of the user""" - if value == "": - raise serializers.ValidationError("First name cannot be blank") # noqa: EM101 if value and not USER_GIVEN_NAME_RE.match(value): raise serializers.ValidationError("First name is not valid") # noqa: EM101 return value def validate_last_name(self, value): """Validates the last name of the user""" - if value == "": - raise serializers.ValidationError("Last name cannot be blank") # noqa: EM101 if value and not USER_GIVEN_NAME_RE.match(value): raise serializers.ValidationError("Last name is not valid") # noqa: EM101 return value diff --git a/users/serializers_test.py b/users/serializers_test.py index 31f6739c5b..19c97480ac 100644 --- a/users/serializers_test.py +++ b/users/serializers_test.py @@ -37,8 +37,6 @@ def test_validate_legal_address(valid_address_dict): @pytest.mark.parametrize( "field,value,error", # noqa: PT006 [ - ["first_name", "", "First name cannot be blank"], # noqa: PT007 - ["last_name", "", "Last name cannot be blank"], # noqa: PT007 ["country", "", "This field may not be blank."], # noqa: PT007 ["country", None, "This field may not be null."], # noqa: PT007 ], diff --git a/yarn.lock b/yarn.lock index ef9c7519e6..ea57c189f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6750,9 +6750,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001688, caniuse-lite@npm:^1.0.30001702": - version: 1.0.30001716 - resolution: "caniuse-lite@npm:1.0.30001716" - checksum: ff087ff38c339d0b697ef7862f575cdd0aeb5986a9c3660be97071c3b3c5ccf52380da73bb8397fdb8a528e4f05de009f6885219f7f6d908b1bff2d89334bfce + version: 1.0.30001748 + resolution: "caniuse-lite@npm:1.0.30001748" + checksum: b3c19786e384d7a54796138f2b1d4e675ca4f9f1fd5e0402ffe1442025e799e5c27e64b0c3fcebef542aa5fa3410c5af8b965f890fff3c0e10cc2b3127a043fa languageName: node linkType: hard @@ -14831,7 +14831,7 @@ __metadata: prop-types: 15.8.1 query-string: 6.14.1 raf: 3.4.1 - ramda: 0.26.1 + ramda: 0.32.0 react: 16.14.0 react-addons-shallow-compare: 15.6.3 react-day-picker: 7.4.10 @@ -14863,7 +14863,7 @@ __metadata: sass: 1.49.11 sass-lint: 1.13.1 sass-loader: 12.6.0 - serialize-javascript: 3.1.0 + serialize-javascript: 6.0.2 shelljs: 0.8.5 sinon: 4.5.0 slick-carousel: ^1.8.1 @@ -17500,10 +17500,10 @@ __metadata: languageName: node linkType: hard -"ramda@npm:0.26.1": - version: 0.26.1 - resolution: "ramda@npm:0.26.1" - checksum: 19c2730e44c129538151ae034c89be9b2c6a4ccc7c65cff57497418bc532ce09282f98cd927c39b0b03c6bc3f1d1a12d822b7b07f96a1634f4958a6c05b7b384 +"ramda@npm:0.32.0": + version: 0.32.0 + resolution: "ramda@npm:0.32.0" + checksum: d26ce9796b60e89496ba3663d2e3d7f7eb0cb885c3949bc4fb0353b27b4409c25af4006c88f6970888d743ca16a0d8b616bcf8aaa353dc09b830f6f90cddb7b1 languageName: node linkType: hard @@ -19829,15 +19829,6 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:3.1.0": - version: 3.1.0 - resolution: "serialize-javascript@npm:3.1.0" - dependencies: - randombytes: ^2.1.0 - checksum: 0fc0131a78168d6237cfe1b21564f20a3b9b72e8ceebb21935baacf026631ed636912c20c7e9fa721a8f27a247e6f9849e705f27032d19863333c2cfab16d1c9 - languageName: node - linkType: hard - "serialize-javascript@npm:6.0.0": version: 6.0.0 resolution: "serialize-javascript@npm:6.0.0" @@ -19847,21 +19838,21 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^4.0.0": - version: 4.0.0 - resolution: "serialize-javascript@npm:4.0.0" +"serialize-javascript@npm:6.0.2, serialize-javascript@npm:^6.0.0, serialize-javascript@npm:^6.0.2": + version: 6.0.2 + resolution: "serialize-javascript@npm:6.0.2" dependencies: randombytes: ^2.1.0 - checksum: 3273b3394b951671fcf388726e9577021870dfbf85e742a1183fb2e91273e6101bdccea81ff230724f6659a7ee4cef924b0ff9baca32b79d9384ec37caf07302 + checksum: c4839c6206c1d143c0f80763997a361310305751171dd95e4b57efee69b8f6edd8960a0b7fbfc45042aadff98b206d55428aee0dc276efe54f100899c7fa8ab7 languageName: node linkType: hard -"serialize-javascript@npm:^6.0.0, serialize-javascript@npm:^6.0.2": - version: 6.0.2 - resolution: "serialize-javascript@npm:6.0.2" +"serialize-javascript@npm:^4.0.0": + version: 4.0.0 + resolution: "serialize-javascript@npm:4.0.0" dependencies: randombytes: ^2.1.0 - checksum: c4839c6206c1d143c0f80763997a361310305751171dd95e4b57efee69b8f6edd8960a0b7fbfc45042aadff98b206d55428aee0dc276efe54f100899c7fa8ab7 + checksum: 3273b3394b951671fcf388726e9577021870dfbf85e742a1183fb2e91273e6101bdccea81ff230724f6659a7ee4cef924b0ff9baca32b79d9384ec37caf07302 languageName: node linkType: hard