From 955981d9784d6dc61359ea6c31bc37bab44a0cf2 Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Thu, 19 Sep 2024 15:23:04 -0400 Subject: [PATCH] 5343 upgrade django in mitxonline (#2387) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .secrets.baseline | 6 +- authentication/middleware.py | 5 +- authentication/middleware_test.py | 24 +- authentication/pipeline/user_test.py | 25 +- cms/models_test.py | 18 +- cms/urls.py | 6 +- courses/__init__.py | 1 - courses/admin.py | 144 ++-- courses/api.py | 5 +- courses/api_test.py | 10 +- .../commands/test_unenroll_enrollment.py | 6 +- .../0055_rename_programrequirement_index.py | 22 + courses/models.py | 16 +- courses/models_test.py | 4 +- courses/tasks.py | 4 +- courses/tasks_test.py | 4 +- courses/urls/__init__.py | 8 +- courses/views/v1/__init__.py | 4 +- courses/views/v1/views_test.py | 12 +- docker-compose.yml | 2 - ecommerce/__init__.py | 1 - ecommerce/admin.py | 23 +- ecommerce/api.py | 51 +- ecommerce/api_test.py | 51 +- ecommerce/factories.py | 1 + ecommerce/mail_api_test.py | 4 +- .../commands/find_paid_unenrolled_learners.py | 4 +- .../commands/refund_fulfilled_order.py | 6 +- ecommerce/migrations/0008_add_order_models.py | 7 +- .../0012_model_related_field_changes.py | 7 +- .../0015_add_review_status_to_order.py | 9 +- ...9_add_partially_refunded_state_to_order.py | 7 +- .../migrations/0036_alter_order_state.py | 29 + ecommerce/models.py | 359 ++++----- ecommerce/models_test.py | 39 +- ecommerce/serializers_test.py | 4 +- ecommerce/tasks_test.py | 4 +- ecommerce/urls.py | 10 +- ecommerce/views/v0/__init__.py | 15 +- ecommerce/views_test.py | 35 +- flexiblepricing/admin.py | 6 +- flexiblepricing/api_test.py | 7 +- .../commands/update_exchange_rates_test.py | 52 -- flexiblepricing/urls.py | 6 +- .../commands/configure_hubspot_properties.py | 64 +- ...reate_order_from_course_run_enrollments.py | 4 +- hubspot_sync/serializers.py | 18 +- hubspot_sync/serializers_test.py | 8 +- mail/urls.py | 4 +- mail/verification_api_test.py | 3 +- main/settings.py | 5 +- main/templates/base.html | 1 - main/test_utils.py | 5 +- main/urls.py | 10 +- micromasters_import/admin.py | 10 +- openedx/__init__.py | 1 - openedx/admin.py | 6 +- ...pires_on_openedx_ope_user_id_76f6b9_idx.py | 17 + openedx/models.py | 2 +- poetry.lock | 756 +++++++----------- pyproject.toml | 26 +- users/admin.py | 6 +- ...med_code_users_chang_expires_dbd4e5_idx.py | 17 + users/models.py | 2 +- users/urls.py | 3 +- wget-log | 0 66 files changed, 955 insertions(+), 1076 deletions(-) create mode 100644 courses/migrations/0055_rename_programrequirement_index.py create mode 100644 ecommerce/migrations/0036_alter_order_state.py delete mode 100644 flexiblepricing/management/commands/update_exchange_rates_test.py create mode 100644 openedx/migrations/0006_rename_openedxapiauth_user_access_token_expires_on_openedx_ope_user_id_76f6b9_idx.py create mode 100644 users/migrations/0024_rename_changeemailrequest_expires_on_confirmed_code_users_chang_expires_dbd4e5_idx.py create mode 100644 wget-log diff --git a/.secrets.baseline b/.secrets.baseline index 76a03ac207..b79d08d3bd 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -148,7 +148,7 @@ "filename": "docker-compose.yml", "hashed_secret": "965748b380ab0ab25d1846afc174a3d93a8ec06c", "is_verified": false, - "line_number": 8 + "line_number": 6 } ], "frontend/public/src/constants.js": [ @@ -193,7 +193,7 @@ "filename": "main/settings.py", "hashed_secret": "09edaaba587f94f60fbb5cee2234507bcb883cc2", "is_verified": false, - "line_number": 958 + "line_number": 955 } ], "pants": [ @@ -240,5 +240,5 @@ } ] }, - "generated_at": "2024-07-02T16:40:29Z" + "generated_at": "2024-09-16T18:23:36Z" } diff --git a/authentication/middleware.py b/authentication/middleware.py index 236759b4f3..a0012e4a04 100644 --- a/authentication/middleware.py +++ b/authentication/middleware.py @@ -1,7 +1,8 @@ """Authentication middleware""" +from urllib.parse import quote + from django.shortcuts import redirect -from django.utils.http import urlquote from social_core.exceptions import SocialAuthBaseException from social_django.middleware import SocialAuthExceptionMiddleware @@ -31,5 +32,5 @@ def process_exception(self, request, exception): if url: # noqa: RET503 url += ( "?" in url and "&" or "?" - ) + f"message={urlquote(message)}&backend={backend_name}" + ) + f"message={quote(message)}&backend={backend_name}" return redirect(url) diff --git a/authentication/middleware_test.py b/authentication/middleware_test.py index a6924d9ada..220d126d77 100644 --- a/authentication/middleware_test.py +++ b/authentication/middleware_test.py @@ -1,8 +1,9 @@ """Tests for auth middleware""" +from urllib.parse import quote + from django.contrib.sessions.middleware import SessionMiddleware from django.shortcuts import reverse -from django.utils.http import urlquote from rest_framework import status from social_core.exceptions import AuthAlreadyAssociated from social_django.utils import load_backend, load_strategy @@ -10,46 +11,49 @@ from authentication.middleware import SocialAuthExceptionRedirectMiddleware -def test_process_exception_no_strategy(rf, settings): +def test_process_exception_no_strategy(mocker, rf, settings): """Tests that if the request has no strategy it does nothing""" settings.DEBUG = False + get_response = mocker.MagicMock() request = rf.get(reverse("social:complete", args=("email",))) - middleware = SocialAuthExceptionRedirectMiddleware() + middleware = SocialAuthExceptionRedirectMiddleware(get_response) assert middleware.process_exception(request, None) is None -def test_process_exception(rf, settings): +def test_process_exception(mocker, rf, settings): """Tests that a process_exception handles auth exceptions correctly""" settings.DEBUG = False request = rf.get(reverse("social:complete", args=("email",))) # social_django depends on request.sesssion, so use the middleware to set that - SessionMiddleware().process_request(request) + get_response = mocker.MagicMock() + SessionMiddleware(get_response).process_request(request) strategy = load_strategy(request) backend = load_backend(strategy, "email", None) request.social_strategy = strategy request.backend = backend - middleware = SocialAuthExceptionRedirectMiddleware() + middleware = SocialAuthExceptionRedirectMiddleware(get_response) error = AuthAlreadyAssociated(backend) result = middleware.process_exception(request, error) assert result.status_code == status.HTTP_302_FOUND assert result.url == "{}?message={}&backend={}".format( - reverse("login"), urlquote(error.__str__()), backend.name + reverse("login"), quote(error.__str__()), backend.name ) -def test_process_exception_non_auth_error(rf, settings): +def test_process_exception_non_auth_error(mocker, rf, settings): """Tests that a process_exception handles non-auth exceptions correctly""" settings.DEBUG = False request = rf.get(reverse("social:complete", args=("email",))) # social_django depends on request.sesssion, so use the middleware to set that - SessionMiddleware().process_request(request) + get_response = mocker.MagicMock() + SessionMiddleware(get_response).process_request(request) strategy = load_strategy(request) backend = load_backend(strategy, "email", None) request.social_strategy = strategy request.backend = backend - middleware = SocialAuthExceptionRedirectMiddleware() + middleware = SocialAuthExceptionRedirectMiddleware(get_response) assert ( middleware.process_exception(request, Exception("something bad happened")) is None diff --git a/authentication/pipeline/user_test.py b/authentication/pipeline/user_test.py index 66cffa2367..7d402d57b4 100644 --- a/authentication/pipeline/user_test.py +++ b/authentication/pipeline/user_test.py @@ -82,10 +82,11 @@ def validate_email_auth_request_not_email_backend(mocker): [(True, {"flow": SocialAuthState.FLOW_LOGIN}), (False, {})], ) @pytest.mark.django_db -def test_validate_email_auth_request(rf, has_user, expected): +def test_validate_email_auth_request(mocker, rf, has_user, expected): """Test that validate_email_auth_request returns correctly given the input""" request = rf.post("/complete/email") - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) request.session.save() strategy = load_strategy(request) @@ -141,7 +142,7 @@ def test_user_password_not_email_backend(mocker): @pytest.mark.parametrize("user_password", ["abc123", "def456"]) -def test_user_password_login(rf, user, user_password): +def test_user_password_login(mocker, rf, user, user_password): """Tests that user_password works for login case""" request_password = "abc123" # noqa: S105 user.set_password(user_password) @@ -149,7 +150,8 @@ def test_user_password_login(rf, user, user_password): request = rf.post( "/complete/email", {"password": request_password, "email": user.email} ) - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) request.session.save() strategy = load_strategy(request) @@ -177,7 +179,7 @@ def test_user_password_login(rf, user, user_password): ) -def test_user_password_not_login(rf, user): +def test_user_password_not_login(mocker, rf, user): """ Tests that user_password performs denies authentication for an existing user if password not provided regardless of auth_type @@ -185,7 +187,8 @@ def test_user_password_not_login(rf, user): user.set_password("abc123") user.save() request = rf.post("/complete/email", {"email": user.email}) - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) request.session.save() strategy = load_strategy(request) @@ -201,12 +204,13 @@ def test_user_password_not_login(rf, user): ) -def test_user_password_not_exists(rf): +def test_user_password_not_exists(mocker, rf): """Tests that user_password raises auth error for nonexistent user""" request = rf.post( "/complete/email", {"password": "abc123", "email": "doesntexist@localhost"} ) - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) request.session.save() strategy = load_strategy(request) @@ -222,13 +226,14 @@ def test_user_password_not_exists(rf): ) -def test_user_not_active(rf, user): +def test_user_not_active(mocker, rf, user): """Tests that an inactive user raises auth error, InvalidPasswordException""" user.set_password("abc123") user.is_active = False user.save() request = rf.post("/complete/email", {"password": "abc123", "email": user.email}) - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) request.session.save() strategy = load_strategy(request) diff --git a/cms/models_test.py b/cms/models_test.py index 2ad3bf42f7..87745bde5f 100644 --- a/cms/models_test.py +++ b/cms/models_test.py @@ -234,7 +234,7 @@ def test_course_page_context_edx_access( # noqa: PLR0913 patched_get_relevant_run_qset.assert_called_once_with(course=course_page.course) -def generate_flexible_pricing_response(request_user, flexible_pricing_form): +def generate_flexible_pricing_response(mocker, request_user, flexible_pricing_form): """ Generates a fully realized request for the Flexible Pricing tests. @@ -248,8 +248,8 @@ def generate_flexible_pricing_response(request_user, flexible_pricing_form): rf = RequestFactory() request = rf.get("/") request.user = request_user - - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) request.session.save() @@ -288,7 +288,7 @@ def test_flex_pricing_form_display(mocker, is_authed, has_submission): courseware_object=flex_form.selected_course, ) - response = generate_flexible_pricing_response(request_user, flex_form) + response = generate_flexible_pricing_response(mocker, request_user, flex_form) # simple string checking for the rendered content # should match what's in the factory @@ -330,7 +330,7 @@ def test_flex_pricing_form_state_display(mocker, submission_status): courseware_object=course_page.course, ) - response = generate_flexible_pricing_response(request_user, flex_form) + response = generate_flexible_pricing_response(mocker, request_user, flex_form) if submission_status == FlexiblePriceStatus.CREATED: assert "Application Processing" in response.rendered_content @@ -481,13 +481,13 @@ def test_flex_pricing_single_submission( # test to make sure we get back a status message from the first form - response = generate_flexible_pricing_response(request_user, first_sub_form) + response = generate_flexible_pricing_response(mocker, request_user, first_sub_form) assert "Application Processing" in response.rendered_content # then test to make sure we get a status message back from the second form too - response = generate_flexible_pricing_response(request_user, second_sub_form) + response = generate_flexible_pricing_response(mocker, request_user, second_sub_form) # should not get a form here - should get Application Processing @@ -534,7 +534,7 @@ def test_flex_pricing_form_state_display_no_discount_tier( tier=tier, ) - response = generate_flexible_pricing_response(request_user, flex_form) + response = generate_flexible_pricing_response(mocker, request_user, flex_form) assert "No Discount Text" in response.rendered_content @@ -542,7 +542,7 @@ def test_flex_pricing_form_state_display_no_discount_tier( flexprice.save() flexprice.refresh_from_db() - response = generate_flexible_pricing_response(request_user, flex_form) + response = generate_flexible_pricing_response(mocker, request_user, flex_form) assert "Approved" in response.rendered_content diff --git a/cms/urls.py b/cms/urls.py index 6da95da46c..8e8c289ce0 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -12,7 +12,7 @@ The pattern(s) defined here serve the same Wagtail view that the library-defined pattern serves. """ # noqa: RUF002 -from django.conf.urls import url +from django.urls import re_path from wagtail import views from wagtail.coreutils import WAGTAIL_APPEND_SLASH @@ -36,6 +36,6 @@ urlpatterns = [ - url(custom_serve_pattern, views.serve, name="wagtail_serve_custom"), - url(program_custom_serve_pattern, views.serve, name="wagtail_serve_custom"), + re_path(custom_serve_pattern, views.serve, name="wagtail_serve_custom"), + re_path(program_custom_serve_pattern, views.serve, name="wagtail_serve_custom"), ] diff --git a/courses/__init__.py b/courses/__init__.py index 80f20226a9..d835a5f92c 100644 --- a/courses/__init__.py +++ b/courses/__init__.py @@ -1,2 +1 @@ # pylint: disable=missing-docstring,invalid-name -default_app_config = "courses.apps.CoursesConfig" diff --git a/courses/admin.py b/courses/admin.py index 3d529febef..84f3c9cd23 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -34,6 +34,7 @@ from main.utils import get_field_names +@admin.register(Program) class ProgramAdmin(admin.ModelAdmin): """Admin for Program""" @@ -44,6 +45,7 @@ class ProgramAdmin(admin.ModelAdmin): list_filter = ["live", "program_type", "departments"] +@admin.register(ProgramRun) class ProgramRunAdmin(admin.ModelAdmin): """Admin for ProgramRun""" @@ -53,6 +55,7 @@ class ProgramRunAdmin(admin.ModelAdmin): raw_id_fields = ("program",) +@admin.register(Course) class CourseAdmin(admin.ModelAdmin): """Admin for Course""" @@ -96,6 +99,7 @@ def get_form(self, request, obj=None, change=False, **kwargs): # noqa: FBT002 return super().get_form(request, obj=obj, change=change, **kwargs) +@admin.register(CourseRun) class CourseRunAdmin(TimestampedModelAdmin): """Admin for CourseRun""" @@ -120,6 +124,7 @@ class CourseRunAdmin(TimestampedModelAdmin): } +@admin.register(ProgramEnrollment) class ProgramEnrollmentAdmin(AuditableModelAdmin): """Admin for ProgramEnrollment""" @@ -149,21 +154,24 @@ def get_queryset(self, request): qs = qs.order_by(*ordering) return qs.select_related("user", "program") + @admin.display( + description="User Email", + ordering="user__email", + ) def get_user_email(self, obj): """Returns the related User email""" return obj.user.email - get_user_email.short_description = "User Email" - get_user_email.admin_order_field = "user__email" - + @admin.display( + description="Program", + ordering="program__readable_id", + ) def get_program_readable_id(self, obj): """Returns the related Program readable_id""" return obj.program.readable_id - get_program_readable_id.short_description = "Program" - get_program_readable_id.admin_order_field = "program__readable_id" - +@admin.register(ProgramEnrollmentAudit) class ProgramEnrollmentAuditAdmin(TimestampedModelAdmin): """Admin for ProgramEnrollmentAudit""" @@ -172,20 +180,22 @@ class ProgramEnrollmentAuditAdmin(TimestampedModelAdmin): list_display = ("id", "enrollment_id", "get_program_readable_id", "get_user") readonly_fields = get_field_names(ProgramEnrollmentAudit) + @admin.display( + description="Program", + ordering="enrollment__program__readable_id", + ) def get_program_readable_id(self, obj): """Returns the related Program readable_id""" return obj.enrollment.program.readable_id - get_program_readable_id.short_description = "Program" - get_program_readable_id.admin_order_field = "enrollment__program__readable_id" - + @admin.display( + description="User", + ordering="enrollment__user__email", + ) def get_user(self, obj): """Returns the related User's email""" return obj.enrollment.user.email - get_user.short_description = "User" - get_user.admin_order_field = "enrollment__user__email" - def has_add_permission(self, request): # noqa: ARG002 return False @@ -214,6 +224,7 @@ def has_add_permission(self, request, obj=None): # noqa: ARG002 return False +@admin.register(CourseRunEnrollment) class CourseRunEnrollmentAdmin(AuditableModelAdmin): """Admin for CourseRunEnrollment""" @@ -253,21 +264,24 @@ def get_queryset(self, request): qs = qs.order_by(*ordering) return qs.select_related("user", "run") + @admin.display( + description="User Email", + ordering="user__email", + ) def get_user_email(self, obj): """Returns the related User email""" return obj.user.email - get_user_email.short_description = "User Email" - get_user_email.admin_order_field = "user__email" - + @admin.display( + description="Course Run", + ordering="run__courseware_id", + ) def get_run_courseware_id(self, obj): """Returns the related CourseRun courseware_id""" return obj.run.courseware_id - get_run_courseware_id.short_description = "Course Run" - get_run_courseware_id.admin_order_field = "run__courseware_id" - +@admin.register(CourseRunEnrollmentAudit) class CourseRunEnrollmentAuditAdmin(TimestampedModelAdmin): """Admin for CourseRunEnrollmentAudit""" @@ -281,20 +295,22 @@ class CourseRunEnrollmentAuditAdmin(TimestampedModelAdmin): list_display = ("id", "enrollment_id", "get_run_courseware_id", "get_user") readonly_fields = get_field_names(CourseRunEnrollmentAudit) + @admin.display( + description="Course Run", + ordering="enrollment__run__courseware_id", + ) def get_run_courseware_id(self, obj): """Returns the related CourseRun courseware_id""" return obj.enrollment.run.courseware_id - get_run_courseware_id.short_description = "Course Run" - get_run_courseware_id.admin_order_field = "enrollment__run__courseware_id" - + @admin.display( + description="User", + ordering="enrollment__user__email", + ) def get_user(self, obj): """Returns the related User's email""" return obj.enrollment.user.email - get_user.short_description = "User" - get_user.admin_order_field = "enrollment__user__email" - def has_add_permission(self, request): # noqa: ARG002 return False @@ -302,6 +318,7 @@ def has_delete_permission(self, request, obj=None): # noqa: ARG002 return False +@admin.register(CourseRunGrade) class CourseRunGradeAdmin(admin.ModelAdmin): """Admin for CourseRunGrade""" @@ -323,27 +340,32 @@ class CourseRunGradeAdmin(admin.ModelAdmin): def get_queryset(self, request): # noqa: ARG002 return self.model.objects.get_queryset().select_related("user", "course_run") + @admin.display( + description="User Email", + ordering="user__email", + ) def get_user_email(self, obj): """Returns the related User email""" return obj.user.email + @admin.display( + description="Username", + ordering="user__username", + ) def get_user_username(self, obj): """Returns the related User username""" return obj.user.username - get_user_email.short_description = "User Email" - get_user_email.admin_order_field = "user__email" - get_user_username.short_description = "Username" - get_user_username.admin_order_field = "user__username" - + @admin.display( + description="Course Run", + ordering="course_run__courseware_id", + ) def get_run_courseware_id(self, obj): """Returns the related CourseRun courseware_id""" return obj.course_run.courseware_id - get_run_courseware_id.short_description = "Course Run" - get_run_courseware_id.admin_order_field = "course_run__courseware_id" - +@admin.register(CourseRunGradeAudit) class CourseRunGradeAuditAdmin(TimestampedModelAdmin): """Admin for CourseRunGradeAudit""" @@ -357,22 +379,22 @@ class CourseRunGradeAuditAdmin(TimestampedModelAdmin): ) readonly_fields = get_field_names(CourseRunGradeAudit) + @admin.display( + description="User Email", + ordering="course_run_grade__user__email", + ) def get_user_email(self, obj): """Returns the related User email""" return obj.course_run_grade.user.email - get_user_email.short_description = "User Email" - get_user_email.admin_order_field = "course_run_grade__user__email" - + @admin.display( + description="Course Run", + ordering="course_run_grade__course_run__courseware_id", + ) def get_run_courseware_id(self, obj): """Returns the related CourseRun courseware_id""" return obj.course_run_grade.course_run.courseware_id - get_run_courseware_id.short_description = "Course Run" - get_run_courseware_id.admin_order_field = ( - "course_run_grade__course_run__courseware_id" - ) - def has_add_permission(self, request): # noqa: ARG002 return False @@ -380,6 +402,7 @@ def has_delete_permission(self, request, obj=None): # noqa: ARG002 return False +@admin.register(Department) class DepartmentAdmin(admin.ModelAdmin): """Admin for Department""" @@ -387,6 +410,7 @@ class DepartmentAdmin(admin.ModelAdmin): list_display = ("name", "slug") +@admin.register(BlockedCountry) class BlockedCountryAdmin(TimestampedModelAdmin): """Admin for BlockedCountry""" @@ -397,6 +421,7 @@ class BlockedCountryAdmin(TimestampedModelAdmin): raw_id_fields = ("course",) +@admin.register(PaidCourseRun) class PaidCourseRunAdmin(TimestampedModelAdmin): """Admin for PaidCourseRun""" @@ -427,6 +452,7 @@ def get_order_state(self, obj): return obj.order.state +@admin.register(CourseRunCertificate) class CourseRunCertificateAdmin(TimestampedModelAdmin): """Admin for CourseRunCertificate""" @@ -447,19 +473,21 @@ class CourseRunCertificateAdmin(TimestampedModelAdmin): list_filter = ["is_revoked", "course_run__course"] raw_id_fields = ("user",) + @admin.display( + description="Active", + boolean=True, + ) def get_revoked_state(self, obj): """Return the revoked state""" return obj.is_revoked is not True - get_revoked_state.short_description = "Active" - get_revoked_state.boolean = True - def get_queryset(self, request): # noqa: ARG002 return self.model.all_objects.get_queryset().select_related( "user", "course_run" ) +@admin.register(ProgramCertificate) class ProgramCertificateAdmin(TimestampedModelAdmin): """Admin for ProgramCertificate""" @@ -480,17 +508,19 @@ class ProgramCertificateAdmin(TimestampedModelAdmin): list_filter = ["program__title", "is_revoked"] raw_id_fields = ("user",) + @admin.display( + description="Active", + boolean=True, + ) def get_revoked_state(self, obj): """Return the revoked state""" return obj.is_revoked is not True - get_revoked_state.short_description = "Active" - get_revoked_state.boolean = True - def get_queryset(self, request): # noqa: ARG002 return self.model.all_objects.get_queryset().select_related("user", "program") +@admin.register(PartnerSchool) class PartnerSchoolAdmin(TimestampedModelAdmin): """Admin for PartnerSchool""" @@ -499,6 +529,7 @@ class PartnerSchoolAdmin(TimestampedModelAdmin): search_fields = ["name", "email"] +@admin.register(LearnerProgramRecordShare) class LearnerProgramRecordShareAdmin(TimestampedModelAdmin): """Admin for LearnerProgramRecordShare""" @@ -507,29 +538,10 @@ class LearnerProgramRecordShareAdmin(TimestampedModelAdmin): search_fields = ["share_uuid"] +@admin.register(RelatedProgram) class RelatedProgramAdmin(admin.ModelAdmin): """Admin for Program""" model = RelatedProgram list_display = ("id", "first_program", "second_program") list_filter = ["first_program", "second_program"] - - -admin.site.register(Program, ProgramAdmin) -admin.site.register(ProgramRun, ProgramRunAdmin) -admin.site.register(Course, CourseAdmin) -admin.site.register(CourseRun, CourseRunAdmin) -admin.site.register(ProgramEnrollment, ProgramEnrollmentAdmin) -admin.site.register(ProgramEnrollmentAudit, ProgramEnrollmentAuditAdmin) -admin.site.register(CourseRunEnrollment, CourseRunEnrollmentAdmin) -admin.site.register(CourseRunEnrollmentAudit, CourseRunEnrollmentAuditAdmin) -admin.site.register(CourseRunGrade, CourseRunGradeAdmin) -admin.site.register(CourseRunGradeAudit, CourseRunGradeAuditAdmin) -admin.site.register(Department, DepartmentAdmin) -admin.site.register(BlockedCountry, BlockedCountryAdmin) -admin.site.register(PaidCourseRun, PaidCourseRunAdmin) -admin.site.register(CourseRunCertificate, CourseRunCertificateAdmin) -admin.site.register(ProgramCertificate, ProgramCertificateAdmin) -admin.site.register(PartnerSchool, PartnerSchoolAdmin) -admin.site.register(LearnerProgramRecordShare, LearnerProgramRecordShareAdmin) -admin.site.register(RelatedProgram, RelatedProgramAdmin) diff --git a/courses/api.py b/courses/api.py index 94ec2e8f8e..7a82e6e4e6 100644 --- a/courses/api.py +++ b/courses/api.py @@ -41,6 +41,7 @@ is_grade_valid, is_letter_grade_valid, ) +from ecommerce.models import OrderStatus from openedx.api import ( enroll_in_edx_course_runs, get_edx_api_course_detail_client, @@ -259,7 +260,7 @@ def deactivate_run_enrollment( Returns: CourseRunEnrollment: The deactivated enrollment """ - from ecommerce.models import Line, Order + from ecommerce.models import Line from hubspot_sync.task_helpers import sync_hubspot_line_by_line_id try: @@ -286,7 +287,7 @@ def deactivate_run_enrollment( line = Line.objects.filter( purchased_object_id=run_enrollment.run.id, purchased_content_type=content_type, - order__state__in=[Order.STATE.FULFILLED, Order.STATE.PENDING], + order__state__in=[OrderStatus.FULFILLED, OrderStatus.PENDING], order__purchaser=run_enrollment.user, ) if line: diff --git a/courses/api_test.py b/courses/api_test.py index 2350188f02..e3bd93f089 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -58,7 +58,7 @@ ProgramRequirementNodeType, ) from ecommerce.factories import LineFactory, OrderFactory, ProductFactory -from ecommerce.models import Order +from ecommerce.models import OrderStatus from main.test_utils import MockHttpError from openedx.constants import ( EDX_DEFAULT_ENROLLMENT_MODE, @@ -454,7 +454,7 @@ def test_deactivate_run_enrollment(self, patches): product = ProductFactory.create(purchasable_object=enrollment.run) version = Version.objects.get_for_object(product).first() order = OrderFactory.create( - state=Order.STATE.PENDING, purchaser=enrollment.user + state=OrderStatus.PENDING, purchaser=enrollment.user ) LineFactory.create( order=order, purchased_object=enrollment.run, product_version=version @@ -483,7 +483,7 @@ def test_deactivate_run_enrollment_api_fail(self, patches, keep_failed_enrollmen product = ProductFactory.create(purchasable_object=enrollment.run) version = Version.objects.get_for_object(product).first() order = OrderFactory.create( - state=Order.STATE.PENDING, purchaser=enrollment.user + state=OrderStatus.PENDING, purchaser=enrollment.user ) LineFactory.create( order=order, purchased_object=enrollment.run, product_version=version @@ -531,7 +531,7 @@ def test_deactivate_program_enrollment( with reversion.create_revision(): product = ProductFactory.create(purchasable_object=run) version = Version.objects.get_for_object(product).first() - order = OrderFactory.create(state=Order.STATE.PENDING, purchaser=user) + order = OrderFactory.create(state=OrderStatus.PENDING, purchaser=user) LineFactory.create( order=order, purchased_object=run, product_version=version ) @@ -592,7 +592,7 @@ def test_defer_enrollment( """ course_runs = CourseRunFactory.create_batch(3, course=course) existing_enrollment = CourseRunEnrollmentFactory.create(run=course_runs[0]) - fulfilled_order = OrderFactory.create(state=Order.STATE.FULFILLED) + fulfilled_order = OrderFactory.create(state=OrderStatus.FULFILLED) paid_course_run = PaidCourseRun.objects.create( user=existing_enrollment.user, course_run=course_runs[0], order=fulfilled_order ) diff --git a/courses/management/commands/test_unenroll_enrollment.py b/courses/management/commands/test_unenroll_enrollment.py index 5c551637dd..ceb8a500c0 100644 --- a/courses/management/commands/test_unenroll_enrollment.py +++ b/courses/management/commands/test_unenroll_enrollment.py @@ -14,7 +14,7 @@ ) from courses.management.commands import unenroll_enrollment from ecommerce.factories import LineFactory, OrderFactory, ProductFactory -from ecommerce.models import Order +from ecommerce.models import OrderStatus from users.factories import UserFactory pytestmark = [pytest.mark.django_db] @@ -86,7 +86,7 @@ def test_unenroll_enrollment(patches): with reversion.create_revision(): product = ProductFactory.create(purchasable_object=enrollment.run) version = Version.objects.get_for_object(product).first() - order = OrderFactory.create(state=Order.STATE.PENDING, purchaser=enrollment.user) + order = OrderFactory.create(state=OrderStatus.PENDING, purchaser=enrollment.user) LineFactory.create( order=order, purchased_object=enrollment.run, product_version=version ) @@ -113,7 +113,7 @@ def test_unenroll_enrollment_without_edx(mocker): with reversion.create_revision(): product = ProductFactory.create(purchasable_object=enrollment.run) version = Version.objects.get_for_object(product).first() - order = OrderFactory.create(state=Order.STATE.PENDING, purchaser=enrollment.user) + order = OrderFactory.create(state=OrderStatus.PENDING, purchaser=enrollment.user) LineFactory.create( order=order, purchased_object=enrollment.run, product_version=version ) diff --git a/courses/migrations/0055_rename_programrequirement_index.py b/courses/migrations/0055_rename_programrequirement_index.py new file mode 100644 index 0000000000..36aa09e8de --- /dev/null +++ b/courses/migrations/0055_rename_programrequirement_index.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2 on 2024-09-10 17:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0054_add_program_availability"), + ] + + operations = [ + migrations.RenameIndex( + model_name="programrequirement", + new_name="courses_pro_course__fdcdb6_idx", + old_fields=("course", "program"), + ), + migrations.RenameIndex( + model_name="programrequirement", + new_name="courses_pro_program_c8ff7c_idx", + old_fields=("program", "course"), + ), + ] diff --git a/courses/models.py b/courses/models.py index fab9a7d304..227eaecc0b 100644 --- a/courses/models.py +++ b/courses/models.py @@ -1196,12 +1196,12 @@ def change_payment_to_run(self, to_run): we can change the payment to another run """ # Due to circular dependancy importing locally - from ecommerce.models import Order + from ecommerce.models import OrderStatus paid_run = PaidCourseRun.objects.filter( user=self.user, course_run=self.run, - order__state=Order.STATE.FULFILLED, + order__state=OrderStatus.FULFILLED, ).first() if paid_run: paid_run.course_run = to_run @@ -1372,13 +1372,13 @@ def fulfilled_paid_course_run_exists(cls, user: User, run: CourseRun): """ # Due to circular dependancy importing locally - from ecommerce.models import Order + from ecommerce.models import OrderStatus # PaidCourseRun should only contain fulfilled orders return cls.objects.filter( user=user, course_run=run, - order__state=Order.STATE.FULFILLED, + order__state=OrderStatus.FULFILLED, ).exists() @@ -1542,10 +1542,10 @@ class Meta: condition=Q(depth=1), ), ) - index_together = ( - ("program", "course"), - ("course", "program"), - ) + indexes = [ + models.Index(fields=("program", "course")), + models.Index(fields=("course", "program")), + ] class PartnerSchool(TimestampedModel): diff --git a/courses/models_test.py b/courses/models_test.py index fcb6a5fe2a..a49f919794 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -36,7 +36,7 @@ limit_to_certificate_pages, ) from ecommerce.factories import OrderFactory, ProductFactory -from ecommerce.models import Order +from ecommerce.models import OrderStatus from main.test_utils import format_as_iso8601 from users.factories import UserFactory @@ -272,7 +272,7 @@ def test_change_payment_to_run(): user=user, active=True, change_status=None ) - fulfilled_order = OrderFactory.create(purchaser=user, state=Order.STATE.FULFILLED) + fulfilled_order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) paid_course_run = PaidCourseRun.objects.create( user=user, course_run=course_run_enrollment.run, diff --git a/courses/tasks.py b/courses/tasks.py index 6f15f3a123..1d2efbf52f 100644 --- a/courses/tasks.py +++ b/courses/tasks.py @@ -13,6 +13,7 @@ LearnerProgramRecordShare, PaidCourseRun, ) +from ecommerce.models import OrderStatus from main.celery import app log = logging.getLogger(__name__) @@ -80,7 +81,6 @@ def clear_unenrolled_paid_course_run(enrollment_id): these exist, the user won't be able to re-buy into the course later if they want to. """ - from ecommerce.models import Order try: enrollment = CourseRunEnrollment.all_objects.filter(id=enrollment_id).get() @@ -88,7 +88,7 @@ def clear_unenrolled_paid_course_run(enrollment_id): PaidCourseRun.objects.filter( user=enrollment.user, course_run=enrollment.run, - order__state=Order.STATE.FULFILLED, + order__state=OrderStatus.FULFILLED, ).delete() except Exception as e: # noqa: BLE001 log.error( # noqa: TRY400 diff --git a/courses/tasks_test.py b/courses/tasks_test.py index 36eee30b55..7dee4c8d0c 100644 --- a/courses/tasks_test.py +++ b/courses/tasks_test.py @@ -15,7 +15,7 @@ subscribe_edx_course_emails, ) from ecommerce.factories import OrderFactory -from ecommerce.models import Order +from ecommerce.models import OrderStatus pytestmark = pytest.mark.django_db @@ -62,7 +62,7 @@ def test_clear_unenrolled_paid_course_runs(user): course_run = CourseRunFactory.create() enrollment = CourseRunEnrollment.objects.create(user=user, run=course_run) - order = OrderFactory.create(purchaser=user, state=Order.STATE.FULFILLED) + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) PaidCourseRun.objects.create(user=user, course_run=course_run, order=order) diff --git a/courses/urls/__init__.py b/courses/urls/__init__.py index b41bc36fe5..f2542c62e7 100644 --- a/courses/urls/__init__.py +++ b/courses/urls/__init__.py @@ -1,6 +1,6 @@ """Course API URL routes""" -from django.urls import include, path, re_path +from django.urls import include, path from rest_framework import routers import courses.urls.v1.urls as urls_v1 @@ -11,9 +11,9 @@ urlpatterns = [ - re_path("^api/", include(urls_v1, "v1")), - re_path("^api/v1/", include(urls_v1, "v1")), - re_path("^api/v2/", include(urls_v2, "v2")), + path("api/", include(urls_v1, "v1")), + path("api/v1/", include(urls_v1, "v1")), + path("api/v2/", include(urls_v2, "v2")), ] urlpatterns += [ diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 6428bb8ea4..45ed4b7dec 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -58,7 +58,7 @@ get_program_certificate_by_enrollment, get_unenrollable_courses, ) -from ecommerce.models import FulfilledOrder, Order, PendingOrder, Product +from ecommerce.models import FulfilledOrder, OrderStatus, PendingOrder, Product from hubspot_sync.task_helpers import sync_hubspot_deal from main import features from main.constants import ( @@ -310,7 +310,7 @@ def respond(data, status=True): # noqa: FBT002 product_object_id = product.object_id product_content_type = product.content_type_id order = FulfilledOrder.objects.filter( - state=Order.STATE.FULFILLED, + state=OrderStatus.FULFILLED, purchaser=user, lines__purchased_object_id=product_object_id, lines__purchased_content_type_id=product_content_type, diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 7003340432..1ccea54322 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -38,7 +38,7 @@ ) from courses.views.v1 import UserEnrollmentsApiViewSet from ecommerce.factories import LineFactory, OrderFactory, ProductFactory -from ecommerce.models import Order +from ecommerce.models import Order, OrderStatus from main import features from main.constants import ( USER_MSG_COOKIE_NAME, @@ -602,9 +602,9 @@ def test_create_enrollments(mocker, user_client, api_request, product_exists): if api_request: assert "Ok" in str(resp.content) if product_exists: - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 1 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 1 else: - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 0 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 0 else: assert resp.status_code == status.HTTP_302_FOUND assert resp.url == reverse("user-dashboard") @@ -777,7 +777,7 @@ def test_create_enrollments_with_existing_fulfilled_order( with reversion.create_revision(): product = ProductFactory.create(purchasable_object=run) if fulfilled_order_exists: - order = OrderFactory.create(state=Order.STATE.FULFILLED, purchaser=user) + order = OrderFactory.create(state=OrderStatus.FULFILLED, purchaser=user) version = Version.objects.get_for_object(product).first() LineFactory.create(order=order, purchased_object=run, product_version=version) resp = user_client.post( @@ -787,7 +787,7 @@ def test_create_enrollments_with_existing_fulfilled_order( assert "Ok" in str(resp.content) if fulfilled_order_exists: - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 0 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 0 else: - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 1 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 1 patched_create_enrollments.assert_called_once() diff --git a/docker-compose.yml b/docker-compose.yml index 56099bcdf6..807fb99784 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - x-environment: &py-environment DEBUG: '${DEBUG:-True}' diff --git a/ecommerce/__init__.py b/ecommerce/__init__.py index a0bb4bd40d..d835a5f92c 100644 --- a/ecommerce/__init__.py +++ b/ecommerce/__init__.py @@ -1,2 +1 @@ # pylint: disable=missing-docstring,invalid-name -default_app_config = "ecommerce.apps.EcommerceConfig" diff --git a/ecommerce/admin.py b/ecommerce/admin.py index 537ccc8b26..9cc3790d89 100644 --- a/ecommerce/admin.py +++ b/ecommerce/admin.py @@ -8,9 +8,9 @@ from django.shortcuts import render from django.urls import reverse from django.views.generic import TemplateView -from fsm_admin.mixins import FSMTransitionMixin from mitol.common.admin import TimestampedModelAdmin from reversion.admin import VersionAdmin +from viewflow import fsm from ecommerce.api import refund_order from ecommerce.forms import AdminRefundOrderForm @@ -25,6 +25,8 @@ FulfilledOrder, Line, Order, + OrderFlow, + OrderStatus, PendingOrder, Product, RefundedOrder, @@ -203,7 +205,7 @@ def has_add_permission(self, request, obj=None): # noqa: ARG002 can_add = False -class BaseOrderAdmin(FSMTransitionMixin, TimestampedModelAdmin): +class BaseOrderAdmin(fsm.FlowAdminMixin, TimestampedModelAdmin): """Base admin for Order""" search_fields = [ @@ -217,6 +219,13 @@ class BaseOrderAdmin(FSMTransitionMixin, TimestampedModelAdmin): list_filter = ["state"] inlines = [OrderLineInline, OrderDiscountInline, OrderTransactionInline] readonly_fields = ["reference_number"] + flow_state = OrderFlow.state + + def get_transition_fields(self, request, obj, slug): # noqa: ARG002 + return ["state"] + + def get_object_flow(self, request, obj): + return OrderFlow(obj, user=request.user) def has_change_permission(self, request, obj=None): # noqa: ARG002 return False @@ -250,7 +259,7 @@ class PendingOrderAdmin(BaseOrderAdmin): def get_queryset(self, request): """Filter only to pending orders""" - return super().get_queryset(request).filter(state=Order.STATE.PENDING) + return super().get_queryset(request).filter(state=OrderStatus.PENDING) @admin.register(CanceledOrder) @@ -261,7 +270,7 @@ class CanceledOrderAdmin(BaseOrderAdmin): def get_queryset(self, request): """Filter only to canceled orders""" - return super().get_queryset(request).filter(state=Order.STATE.CANCELED) + return super().get_queryset(request).filter(state=OrderStatus.CANCELED) @admin.register(FulfilledOrder) @@ -294,7 +303,7 @@ def get_queryset(self, request): super() .get_queryset(request) .prefetch_related("purchaser", "lines__product_version") - .filter(state=Order.STATE.FULFILLED) + .filter(state=OrderStatus.FULFILLED) ) def response_change(self, request, obj): @@ -314,7 +323,7 @@ class RefundedOrderAdmin(BaseOrderAdmin): def get_queryset(self, request): """Filter only to refunded orders""" - return super().get_queryset(request).filter(state=Order.STATE.REFUNDED) + return super().get_queryset(request).filter(state=OrderStatus.REFUNDED) class AdminRefundOrderView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView): @@ -395,7 +404,7 @@ def post(self, request): def get(self, request): try: order = FulfilledOrder.objects.get(pk=request.GET["order"]) - if order.state != Order.STATE.FULFILLED: + if order.state != OrderStatus.FULFILLED: raise ObjectDoesNotExist() # noqa: RSE102, TRY301 except ObjectDoesNotExist: messages.error( diff --git a/ecommerce/api.py b/ecommerce/api.py index d475afa24a..4929981ab0 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -36,6 +36,7 @@ DiscountRedemption, FulfilledOrder, Order, + OrderStatus, PendingOrder, UserDiscount, ) @@ -240,8 +241,8 @@ def apply_user_discounts(request): def fulfill_completed_order(order, payment_data, basket=None, already_enrolled=False): # noqa: FBT002 - order.fulfill(payment_data, already_enrolled=already_enrolled) - order.save() + order_flow = order.get_object_flow() + order_flow.fulfill(payment_data, already_enrolled=already_enrolled) sync_hubspot_deal(order) if basket and basket.compare_to_order(order): @@ -304,16 +305,16 @@ def process_cybersource_payment_response(request, order): # This probably means the order needed to go through the process # again so maybe tell the user to do a thing. log.debug(f"Transaction declined: {processor_response.message}") # noqa: G004 - order.decline() - order.save() + order_flow = order.get_object_flow() + order_flow.decline() return_message = order.state elif processor_response.state == ProcessorResponse.STATE_ERROR: # Error - something went wrong with the request log.debug( f"Error happened submitting the transaction: {processor_response.message}" # noqa: G004 ) - order.error() - order.save() + order_flow = order.get_object_flow() + order_flow.errored() return_message = order.state elif processor_response.state in [ ProcessorResponse.STATE_CANCELLED, @@ -325,8 +326,8 @@ def process_cybersource_payment_response(request, order): # the order here (other than set it to Cancelled). # Transaction could be log.debug(f"Transaction cancelled/reviewed: {processor_response.message}") # noqa: G004 - order.cancel() - order.save() + order_flow = order.get_object_flow() + order_flow.cancel() return_message = order.state elif ( @@ -349,8 +350,8 @@ def process_cybersource_payment_response(request, order): log.error( f"Unknown state {processor_response.state} found: transaction ID {transaction_id}, reason code {reason_code}, response message {processor_response.message}" # noqa: G004 ) - order.cancel() - order.save() + order_flow = order.get_object_flow() + order_flow.cancel() return_message = order.state sync_hubspot_deal(order) @@ -396,7 +397,7 @@ def refund_order(*, order_id: int = None, reference_number: str = None, **kwargs message = "Either order_id or reference_number is required to fetch the Order." log.error(message) return False, message - if order.state != Order.STATE.FULFILLED: + if order.state != OrderStatus.FULFILLED: message = f"Order with order_id {order.id} is not in fulfilled state." log.error(message) return False, message @@ -433,7 +434,8 @@ def refund_order(*, order_id: int = None, reference_number: str = None, **kwargs if response.state in REFUND_SUCCESS_STATES: # Record refund transaction with PaymentGateway's refund response - order.refund( + order_flow = order.get_object_flow() + order_flow.refund( api_response_data=response.response_data, amount=transaction_dict["req_amount"], reason=refund_reason, @@ -510,11 +512,11 @@ def check_and_process_pending_orders_for_resolution(refnos=None): if refnos is not None: pending_orders = PendingOrder.objects.filter( - state=PendingOrder.STATE.PENDING, reference_number__in=refnos + state=OrderStatus.PENDING, reference_number__in=refnos ).values_list("reference_number", flat=True) else: pending_orders = PendingOrder.objects.filter( - state=PendingOrder.STATE.PENDING + state=OrderStatus.PENDING ).values_list("reference_number", flat=True) if len(pending_orders) == 0: @@ -535,12 +537,11 @@ def check_and_process_pending_orders_for_resolution(refnos=None): if int(payload["reason_code"]) == 100: # noqa: PLR2004 try: order = PendingOrder.objects.filter( - state=PendingOrder.STATE.PENDING, + state=OrderStatus.PENDING, reference_number=payload["req_reference_number"], ).get() - - order.fulfill(payload) - order.save() + order_flow = order.get_object_flow() + order_flow.fulfill(payload) sync_hubspot_deal(order) fulfilled_count += 1 @@ -553,11 +554,11 @@ def check_and_process_pending_orders_for_resolution(refnos=None): else: try: order = PendingOrder.objects.filter( - state=PendingOrder.STATE.PENDING, + state=OrderStatus.PENDING, reference_number=payload["req_reference_number"], ).get() - - order.cancel() + order_flow = order.get_object_flow() + order_flow.cancel() order.transactions.create( transaction_id=payload["transaction_id"], amount=order.total_price_paid, @@ -597,7 +598,7 @@ def check_for_duplicate_discount_redemptions(): Q(redeemed_discount__redemption_type=REDEMPTION_TYPE_ONE_TIME) | Q(redeemed_discount__redemption_type=REDEMPTION_TYPE_ONE_TIME_PER_USER) ) - .filter(redeemed_order__state=Order.STATE.FULFILLED) + .filter(redeemed_order__state=OrderStatus.FULFILLED) .prefetch_related("redeemed_discount") .all() ) @@ -611,7 +612,7 @@ def check_for_duplicate_discount_redemptions(): if ( redemption.redeemed_discount.redemption_type == REDEMPTION_TYPE_ONE_TIME and redemption.redeemed_discount.order_redemptions.filter( - redeemed_order__state=Order.STATE.FULFILLED + redeemed_order__state=OrderStatus.FULFILLED ).count() > 1 ): @@ -623,7 +624,7 @@ def check_for_duplicate_discount_redemptions(): redemption.redeemed_discount.redemption_type == REDEMPTION_TYPE_ONE_TIME_PER_USER and redemption.redeemed_discount.order_redemptions.filter( - redeemed_order__state=Order.STATE.FULFILLED + redeemed_order__state=OrderStatus.FULFILLED ).count() > 1 ): @@ -632,7 +633,7 @@ def check_for_duplicate_discount_redemptions(): for ( user_redemption ) in redemption.redeemed_discount.order_redemptions.filter( - redeemed_order__state=Order.STATE.FULFILLED + redeemed_order__state=OrderStatus.FULFILLED ).all(): if user_redemption.redeemed_by.id in seen_user: continue diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index 579e9bbb58..cf339c6386 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -39,6 +39,7 @@ DiscountRedemption, FulfilledOrder, Order, + OrderStatus, Transaction, ) from users.factories import UserFactory @@ -49,7 +50,7 @@ @pytest.fixture def fulfilled_order(): """Fixture for creating a fulfilled order""" - return OrderFactory.create(state=Order.STATE.FULFILLED) + return OrderFactory.create(state=OrderStatus.FULFILLED) @pytest.fixture @@ -149,12 +150,12 @@ def create_basket(user, products): @pytest.mark.parametrize( "order_state", [ - Order.STATE.REFUNDED, - Order.STATE.ERRORED, - Order.STATE.PENDING, - Order.STATE.DECLINED, - Order.STATE.CANCELED, - Order.STATE.REVIEW, + OrderStatus.REFUNDED, + OrderStatus.ERRORED, + OrderStatus.PENDING, + OrderStatus.DECLINED, + OrderStatus.CANCELED, + OrderStatus.REVIEW, ], ) def test_cybersource_refund_no_fulfilled_order(order_state): @@ -186,7 +187,7 @@ def test_cybersource_order_no_transaction(fulfilled_order): Ideally, there should be a payment type transaction for a fulfilled order """ - fulfilled_order = OrderFactory.create(state=Order.STATE.FULFILLED) + fulfilled_order = OrderFactory.create(state=OrderStatus.FULFILLED) refund_response, message = refund_order(order_id=fulfilled_order.id) assert f"There is no associated transaction against order_id {fulfilled_order.id}." # noqa: PLW0129 assert refund_response is False @@ -269,7 +270,7 @@ def test_order_refund_success(mocker, order_state, unenroll, fulfilled_transacti # The state of the order should be REFUNDED after a successful refund fulfilled_transaction.order.refresh_from_db() - assert fulfilled_transaction.order.state == Order.STATE.REFUNDED + assert fulfilled_transaction.order.state == OrderStatus.REFUNDED @pytest.mark.parametrize("unenroll", [True, False]) @@ -331,7 +332,7 @@ def test_order_refund_success_with_ref_num(mocker, unenroll, fulfilled_transacti # The state of the order should be REFUNDED after a successful refund fulfilled_transaction.order.refresh_from_db() - assert fulfilled_transaction.order.state == Order.STATE.REFUNDED + assert fulfilled_transaction.order.state == OrderStatus.REFUNDED def test_order_refund_failure(mocker, fulfilled_transaction): @@ -439,7 +440,7 @@ def test_process_cybersource_payment_response( # noqa: PLR0913 "transaction_id": "12345", } - order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) assert order.reference_number == payload["req_reference_number"] @@ -448,9 +449,9 @@ def test_process_cybersource_payment_response( # noqa: PLR0913 # This is checked on the BackofficeCallbackView and CheckoutCallbackView POST endpoints # since we expect to receive a response to both from Cybersource. If the current state is # PENDING, then we should process the response. - assert order.state == Order.STATE.PENDING + assert order.state == OrderStatus.PENDING result = process_cybersource_payment_response(request, order) - assert result == Order.STATE.FULFILLED + assert result == OrderStatus.FULFILLED @pytest.mark.parametrize("include_discount", [True, False]) @@ -475,7 +476,7 @@ def test_process_cybersource_payment_decline_response( # noqa: PLR0913 "transaction_id": "12345", } - order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) assert order.reference_number == payload["req_reference_number"] @@ -494,13 +495,13 @@ def test_process_cybersource_payment_decline_response( # noqa: PLR0913 # This is checked on the BackofficeCallbackView and CheckoutCallbackView POST endpoints # since we expect to receive a response to both from Cybersource. If the current state is # PENDING, then we should process the response. - assert order.state == Order.STATE.PENDING + assert order.state == OrderStatus.PENDING if include_discount: assert order.discounts.count() > 0 result = process_cybersource_payment_response(request, order) - assert result == Order.STATE.DECLINED + assert result == OrderStatus.DECLINED order.refresh_from_db() assert order.discounts.count() == 0 @@ -513,16 +514,12 @@ def test_check_and_process_pending_orders_for_resolution(mocker, test_type): - fail - there's an order but the payment failed (failed status in CyberSource) - empty - order isn't pending """ - order = OrderFactory.create(state=Order.STATE.PENDING) + order = OrderFactory.create(state=OrderStatus.PENDING) # mocking out the create_enrollment and create_paid_courserun calls # we don't really care that it hits edX for this - mocker.patch( - "ecommerce.models.FulfillableOrder.create_enrollments", return_value=True - ) - mocker.patch( - "ecommerce.models.FulfillableOrder.create_paid_courseruns", return_value=True - ) + mocker.patch("ecommerce.models.OrderFlow.create_enrollments", return_value=True) + mocker.patch("ecommerce.models.OrderFlow.create_paid_courseruns", return_value=True) test_payload = { "utf8": "", @@ -584,7 +581,7 @@ def test_check_and_process_pending_orders_for_resolution(mocker, test_type): test_payload["reason_code"] = "999" if test_type == "empty": - order.state = Order.STATE.CANCELED + order.state = OrderStatus.CANCELED order.save() order.refresh_from_db() @@ -603,11 +600,11 @@ def test_check_and_process_pending_orders_for_resolution(mocker, test_type): assert (fulfilled, cancelled, errored) == (0, 0, 0) elif test_type == "fail": order.refresh_from_db() - assert order.state == Order.STATE.CANCELED + assert order.state == OrderStatus.CANCELED assert (fulfilled, cancelled, errored) == (0, 1, 0) else: order.refresh_from_db() - assert order.state == Order.STATE.FULFILLED + assert order.state == OrderStatus.FULFILLED assert (fulfilled, cancelled, errored) == (1, 0, 0) @@ -620,7 +617,7 @@ def test_duplicate_redemption_check(peruser): def make_stuff(user, discount): """Helper function to DRY out the rest of the test""" - order = OrderFactory.create(purchaser=user, state=Order.STATE.FULFILLED) + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) redemption = DiscountRedemptionFactory.create( redeemed_by=user, redeemed_discount=discount, redeemed_order=order ) diff --git a/ecommerce/factories.py b/ecommerce/factories.py index f7a54734e8..cc0bcb3a80 100644 --- a/ecommerce/factories.py +++ b/ecommerce/factories.py @@ -109,6 +109,7 @@ class Meta: class OrderFactory(DjangoModelFactory): total_price_paid = fuzzy.FuzzyDecimal(10.00, 10.00) purchaser = SubFactory(UserFactory) + state = models.OrderStatus.PENDING class Meta: model = models.Order diff --git a/ecommerce/mail_api_test.py b/ecommerce/mail_api_test.py index c2b2dc7eef..07be434a48 100644 --- a/ecommerce/mail_api_test.py +++ b/ecommerce/mail_api_test.py @@ -78,8 +78,8 @@ def test_mail_api_refund_email_generation( transaction_data = {"id": "refunded-transaction"} refund_amount = order.total_price_paid / 2 - - transaction = order.refund( # noqa: F841 + order_flow = order.get_object_flow() + transaction = order_flow.refund( # noqa: F841 api_response_data=transaction_data, amount=refund_amount, reason="testing" ) diff --git a/ecommerce/management/commands/find_paid_unenrolled_learners.py b/ecommerce/management/commands/find_paid_unenrolled_learners.py index 5f225a71c8..0579334645 100644 --- a/ecommerce/management/commands/find_paid_unenrolled_learners.py +++ b/ecommerce/management/commands/find_paid_unenrolled_learners.py @@ -12,7 +12,7 @@ from mitol.common.utils.datetime import now_in_utc from courses.models import CourseRun, CourseRunEnrollment -from ecommerce.models import Line, Order +from ecommerce.models import Line, OrderStatus class Command(BaseCommand): @@ -42,7 +42,7 @@ def handle(self, *args, **kwargs): # pylint: disable=unused-argument # noqa: A purchased_lines = Line.objects.filter( purchased_object_id__in=active_courseruns, purchased_content_type=content_type, - order__state=Order.STATE.FULFILLED, + order__state=OrderStatus.FULFILLED, ).all() for line in purchased_lines: diff --git a/ecommerce/management/commands/refund_fulfilled_order.py b/ecommerce/management/commands/refund_fulfilled_order.py index b42ac4dac2..a4d03e0dbc 100644 --- a/ecommerce/management/commands/refund_fulfilled_order.py +++ b/ecommerce/management/commands/refund_fulfilled_order.py @@ -20,7 +20,7 @@ from courses.api import deactivate_run_enrollment from courses.models import CourseRunEnrollment -from ecommerce.models import Order +from ecommerce.models import Order, OrderStatus from hubspot_sync.task_helpers import sync_hubspot_deal from openedx.api import enroll_in_edx_course_runs @@ -55,12 +55,12 @@ def handle(self, *args, **kwargs): # noqa: ARG002 try: order = Order.objects.filter( - state=Order.STATE.FULFILLED, reference_number=kwargs["order"] + state=OrderStatus.FULFILLED, reference_number=kwargs["order"] ).get() except: # noqa: E722 raise CommandError("Couldn't find that order, or the order was ambiguous.") # noqa: B904, EM101 - order.state = Order.STATE.REFUNDED + order.state = OrderStatus.REFUNDED order.save() sync_hubspot_deal(order) diff --git a/ecommerce/migrations/0008_add_order_models.py b/ecommerce/migrations/0008_add_order_models.py index cfda346cab..a24073ee5f 100644 --- a/ecommerce/migrations/0008_add_order_models.py +++ b/ecommerce/migrations/0008_add_order_models.py @@ -1,7 +1,6 @@ # Generated by Django 3.2.10 on 2022-01-05 17:26 import django.db.models.deletion -import django_fsm from django.conf import settings from django.db import migrations, models @@ -32,12 +31,16 @@ class Migration(migrations.Migration): ("updated_on", models.DateTimeField(auto_now=True)), ( "state", - django_fsm.FSMField( + models.CharField( choices=[ ("pending", "Pending"), ("fulfilled", "Fulfilled"), ("canceled", "Canceled"), + ("declined", "Declined"), + ("errored", "Errored"), ("refunded", "Refunded"), + ("review", "Review"), + ("partially_refunded", "Partially Refunded"), ], default="pending", max_length=50, diff --git a/ecommerce/migrations/0012_model_related_field_changes.py b/ecommerce/migrations/0012_model_related_field_changes.py index fb22787fa3..74f091ba6f 100644 --- a/ecommerce/migrations/0012_model_related_field_changes.py +++ b/ecommerce/migrations/0012_model_related_field_changes.py @@ -1,7 +1,6 @@ # Generated by Django 3.2.12 on 2022-03-02 20:36 import django.db.models.deletion -import django_fsm from django.conf import settings from django.db import migrations, models @@ -90,14 +89,16 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="order", name="state", - field=django_fsm.FSMField( + field=models.CharField( choices=[ ("pending", "Pending"), ("fulfilled", "Fulfilled"), ("canceled", "Canceled"), - ("refunded", "Refunded"), ("declined", "Declined"), ("errored", "Errored"), + ("refunded", "Refunded"), + ("review", "Review"), + ("partially_refunded", "Partially Refunded"), ], default="pending", max_length=50, diff --git a/ecommerce/migrations/0015_add_review_status_to_order.py b/ecommerce/migrations/0015_add_review_status_to_order.py index 087a761be4..f553a89aac 100644 --- a/ecommerce/migrations/0015_add_review_status_to_order.py +++ b/ecommerce/migrations/0015_add_review_status_to_order.py @@ -1,7 +1,5 @@ # Generated by Django 3.2.12 on 2022-03-15 13:49 - -import django_fsm -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -23,15 +21,16 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="order", name="state", - field=django_fsm.FSMField( + field=models.CharField( choices=[ ("pending", "Pending"), ("fulfilled", "Fulfilled"), ("canceled", "Canceled"), - ("refunded", "Refunded"), ("declined", "Declined"), ("errored", "Errored"), + ("refunded", "Refunded"), ("review", "Review"), + ("partially_refunded", "Partially Refunded"), ], default="pending", max_length=50, diff --git a/ecommerce/migrations/0029_add_partially_refunded_state_to_order.py b/ecommerce/migrations/0029_add_partially_refunded_state_to_order.py index 1ad78d57b1..daffbd443f 100644 --- a/ecommerce/migrations/0029_add_partially_refunded_state_to_order.py +++ b/ecommerce/migrations/0029_add_partially_refunded_state_to_order.py @@ -1,7 +1,6 @@ # Generated by Django 3.2.14 on 2022-08-17 14:25 -import django_fsm -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -23,14 +22,14 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="order", name="state", - field=django_fsm.FSMField( + field=models.CharField( choices=[ ("pending", "Pending"), ("fulfilled", "Fulfilled"), ("canceled", "Canceled"), - ("refunded", "Refunded"), ("declined", "Declined"), ("errored", "Errored"), + ("refunded", "Refunded"), ("review", "Review"), ("partially_refunded", "Partially Refunded"), ], diff --git a/ecommerce/migrations/0036_alter_order_state.py b/ecommerce/migrations/0036_alter_order_state.py new file mode 100644 index 0000000000..87649a1937 --- /dev/null +++ b/ecommerce/migrations/0036_alter_order_state.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2 on 2024-09-05 15:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ecommerce", "0035_delete_duplicate_discountredemption"), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="state", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("fulfilled", "Fulfilled"), + ("canceled", "Canceled"), + ("declined", "Declined"), + ("errored", "Errored"), + ("refunded", "Refunded"), + ("review", "Review"), + ("partially_refunded", "Partially Refunded"), + ], + max_length=150, + ), + ), + ] diff --git a/ecommerce/models.py b/ecommerce/models.py index 2156006f29..6fccff14dc 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -14,13 +14,14 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models, transaction +from django.db.models import TextChoices from django.utils.functional import cached_property -from django_fsm import FSMField, transition from mitol.common.models import TimestampedModel from mitol.common.utils.datetime import now_in_utc from reversion.models import Version +from viewflow import this +from viewflow.fsm import State -from courses.api import create_run_enrollments from courses.models import CourseRun, PaidCourseRun from ecommerce.constants import ( DISCOUNT_TYPE_DOLLARS_OFF, @@ -305,7 +306,7 @@ def check_validity(self, user: User): self.redemption_type == REDEMPTION_TYPE_ONE_TIME and DiscountRedemption.objects.filter( redeemed_discount=self, - redeemed_order__state=Order.STATE.FULFILLED, + redeemed_order__state=OrderStatus.FULFILLED, ).count() > 0 ): @@ -315,7 +316,7 @@ def check_validity(self, user: User): self.redemption_type == REDEMPTION_TYPE_ONE_TIME_PER_USER and DiscountRedemption.objects.filter( redeemed_discount=self, - redeemed_order__state=Order.STATE.FULFILLED, + redeemed_order__state=OrderStatus.FULFILLED, redeemed_by=user, ).count() > 0 @@ -326,7 +327,7 @@ def check_validity(self, user: User): self.max_redemptions > 0 and DiscountRedemption.objects.filter( redeemed_discount=self, - redeemed_order__state=Order.STATE.FULFILLED, + redeemed_order__state=OrderStatus.FULFILLED, ).count() >= self.max_redemptions ): @@ -433,120 +434,103 @@ def __str__(self): return f"{self.discount} {self.user}" -class Order(TimestampedModel): - """An order containing information for a purchase.""" +class OrderStatus(TextChoices): + PENDING = "pending" + FULFILLED = "fulfilled" + CANCELED = "canceled" + DECLINED = "declined" + ERRORED = "errored" + REFUNDED = "refunded" + REVIEW = "review" + PARTIALLY_REFUNDED = "partially_refunded" - class STATE: - PENDING = "pending" - FULFILLED = "fulfilled" - CANCELED = "canceled" - DECLINED = "declined" - ERRORED = "errored" - REFUNDED = "refunded" - REVIEW = "review" - PARTIALLY_REFUNDED = "partially_refunded" - - @classmethod - def choices(cls): - return ( - (cls.PENDING, "Pending", "PendingOrder"), - (cls.FULFILLED, "Fulfilled", "FulfilledOrder"), - (cls.CANCELED, "Canceled", "CanceledOrder"), - (cls.REFUNDED, "Refunded", "RefundedOrder"), - (cls.DECLINED, "Declined", "DeclinedOrder"), - (cls.ERRORED, "Errored", "ErroredOrder"), - (cls.REVIEW, "Review", "ReviewOrder"), - ( - cls.PARTIALLY_REFUNDED, - "Partially Refunded", - "PartiallyRefundedOrder", - ), - ) - state = FSMField(default=STATE.PENDING, state_choices=STATE.choices()) - purchaser = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name="orders", - ) - total_price_paid = models.DecimalField( - decimal_places=5, - max_digits=20, - ) - reference_number = models.CharField(max_length=255, null=True, blank=True) # noqa: DJ001 +class OrderFlow: + state = State(OrderStatus, default=OrderStatus.PENDING) - # override save method to auto-fill generated_rerefence_number - def save(self, *args, **kwargs): - # initial save in order to get primary key for new order - super().save(*args, **kwargs) + def __init__(self, order, user): + self.order = order + self.user = user - # can't insert twice because it'll try to insert with a PK now - kwargs.pop("force_insert", None) + @state.setter() + def _set_order_state(self, value): + self.order.state = value - # if we don't have a generated reference number, we generate one and save again - if self.reference_number is None: - self.reference_number = self._generate_reference_number() - super().save(*args, **kwargs) + @state.getter() + def _get_order_state(self): + return self.order.state - # Flag to determine if the order is in review status - if it is, then - # we need to not step on the basket that may or may not exist when it is - # accepted - @property - def is_review(self): - return self.state == Order.STATE.REVIEW - - @property - def is_fulfilled(self): - return self.state == Order.STATE.FULFILLED - - def fulfill(self, payment_data): - """Fulfill this order""" - raise NotImplementedError + @state.on_success() + def _on_transition_success(self, descriptor, source, target, **kwargs): # noqa: ARG002 + self.order.save() + @state.transition(source=State.ANY, target=OrderStatus.CANCELED) def cancel(self): """Cancel this order""" - raise NotImplementedError + def is_approver(self, user): + return user.is_staff + + @state.transition( + source=OrderStatus.PENDING, + target=OrderStatus.DECLINED, + permission=this.is_approver, + ) def decline(self): - """Decline this order""" - raise NotImplementedError + """ + Decline this order. This additionally clears the discount redemptions + for the order so the discounts can be reused. + """ + for redemption in self.order.discounts.all(): + redemption.delete() - def review(self): - """Place order in review""" - raise NotImplementedError + return self + @state.transition(source=State.ANY, target=OrderStatus.ERRORED) def errored(self): """Error this order""" - raise NotImplementedError - def refund(self, *, api_response_data, **kwargs): - """Issue a refund""" - raise NotImplementedError - - def _generate_reference_number(self): - return f"{REFERENCE_NUMBER_PREFIX}{settings.ENVIRONMENT}-{self.id}" + @state.transition( + source=OrderStatus.FULFILLED, + target=OrderStatus.REFUNDED, + permission=this.is_approver, + ) + def refund(self, *, api_response_data: dict = None, **kwargs): # noqa: RUF013 + """ + Records the refund, and optionally attempts to unenroll the learner from + the things they bought. - @property - def purchased_runs(self): - """Return a list of purchased CourseRuns""" + Args: + api_response_data (dict): In case of API response we will have the response data dictionary + kwargs: Ideally it should have named parameters such as + 1- amount: that was refunded + 2- reason: for refunding the order - # TODO: handle programs # noqa: FIX002, TD002, TD003 - return [ - line.purchased_object - for line in self.lines.all() - if isinstance(line.purchased_object, CourseRun) - ] + at hand with enough details, So when the dict is passed we would save it as is, + otherwise fallback to default dict creation below + Returns: + Object (Transaction): return the refund transaction object for the refund. + """ + amount = kwargs.get("amount") + reason = kwargs.get("reason") - def __str__(self): - return f"{self.state.capitalize()} Order for {self.purchaser.name} ({self.purchaser.email})" + transaction_id = api_response_data.get("id") + if transaction_id is None: + raise ValidationError( + "Failed to record transaction: Missing transaction id from refund API response" # noqa: EM101 + ) - @staticmethod - def decode_reference_number(refno): - return refno.replace(f"{REFERENCE_NUMBER_PREFIX}{settings.ENVIRONMENT}-", "") + refund_transaction, created = self.order.transactions.get_or_create( + transaction_id=transaction_id, + data=api_response_data, + amount=amount, + transaction_type=TRANSACTION_TYPE_REFUND, + reason=reason, + ) + send_order_refund_email.delay(self.order.id) -class FulfillableOrder: - """class to handle common logics like fulfill, enrollment etc""" + return refund_transaction def create_transaction(self, payment_data): log = logging.getLogger(__name__) # noqa: F841 @@ -562,34 +546,32 @@ def create_transaction(self, payment_data): "Failed to record transaction: Missing transaction id from payment API response" # noqa: EM101 ) - self.transactions.get_or_create( + self.order.transactions.get_or_create( transaction_id=transaction_id, data=payment_data, - amount=self.total_price_paid, + amount=self.order.total_price_paid, ) def create_paid_courseruns(self): - for run in self.purchased_runs: + for run in self.order.purchased_runs: PaidCourseRun.objects.get_or_create( - order=self, course_run=run, user=self.purchaser + order=self.order, course_run=run, user=self.order.purchaser ) def create_enrollments(self): # create enrollments for what the learner has paid for + from courses.api import create_run_enrollments + create_run_enrollments( - self.purchaser, - self.purchased_runs, + self.order.purchaser, + self.order.purchased_runs, mode=EDX_ENROLLMENT_VERIFIED_MODE, keep_failed_enrollments=True, ) - def send_ecommerce_order_receipt(self): - send_ecommerce_order_receipt.delay(self.id) - - @transition( - field="state", - source=Order.STATE.PENDING, - target=Order.STATE.FULFILLED, + @state.transition( + source=OrderStatus.PENDING, + target=OrderStatus.FULFILLED, ) def fulfill(self, payment_data, already_enrolled=False): # noqa: FBT002 # record the transaction @@ -606,10 +588,78 @@ def fulfill(self, payment_data, already_enrolled=False): # noqa: FBT002 # No email is required as this order is generated from management command if not already_enrolled: # send the receipt emails - transaction.on_commit(self.send_ecommerce_order_receipt) + transaction.on_commit(self.order.send_ecommerce_order_receipt) + +class Order(TimestampedModel): + """An order containing information for a purchase.""" + + state = models.CharField(max_length=150, choices=OrderStatus.choices) + purchaser = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="orders", + ) + total_price_paid = models.DecimalField( + decimal_places=5, + max_digits=20, + ) + reference_number = models.CharField(max_length=255, null=True, blank=True) # noqa: DJ001 -class PendingOrder(FulfillableOrder, Order): + def get_object_flow(self): + """Instantiate the flow without default constructor""" + return OrderFlow(self, user=self.purchaser) + + # override save method to auto-fill generated_rerefence_number + def save(self, *args, **kwargs): + # initial save in order to get primary key for new order + super().save(*args, **kwargs) + + # can't insert twice because it'll try to insert with a PK now + kwargs.pop("force_insert", None) + + # if we don't have a generated reference number, we generate one and save again + if self.reference_number is None: + self.reference_number = self._generate_reference_number() + super().save(*args, **kwargs) + + # Flag to determine if the order is in review status - if it is, then + # we need to not step on the basket that may or may not exist when it is + # accepted + @property + def is_review(self): + return self.state == OrderStatus.REVIEW + + @property + def is_fulfilled(self): + return self.state == OrderStatus.FULFILLED + + def _generate_reference_number(self): + return f"{REFERENCE_NUMBER_PREFIX}{settings.ENVIRONMENT}-{self.id}" + + @property + def purchased_runs(self): + """Return a list of purchased CourseRuns""" + + # TODO: handle programs # noqa: FIX002, TD002, TD003 + return [ + line.purchased_object + for line in self.lines.all() + if isinstance(line.purchased_object, CourseRun) + ] + + def __str__(self): + return f"{self.state.capitalize()} Order for {self.purchaser.name} ({self.purchaser.email})" + + @staticmethod + def decode_reference_number(refno): + return refno.replace(f"{REFERENCE_NUMBER_PREFIX}{settings.ENVIRONMENT}-", "") + + def send_ecommerce_order_receipt(self): + send_ecommerce_order_receipt.delay(self.id) + + +class PendingOrder(Order): """An order that is pending payment""" @transaction.atomic @@ -651,7 +701,7 @@ def _get_or_create( lines__purchased_object_id__in=product_object_ids, lines__purchased_content_type_id__in=product_content_types, lines__product_version__in=product_versions, - state=Order.STATE.PENDING, + state=OrderStatus.PENDING, purchaser=user, ) ) @@ -667,7 +717,7 @@ def _get_or_create( order.refresh_from_db() else: order = Order.objects.create( - state=Order.STATE.PENDING, + state=OrderStatus.PENDING, purchaser=user, total_price_paid=0, ) @@ -742,28 +792,6 @@ def create_from_product( return order # noqa: RET504 - @transition(field="state", source=Order.STATE.PENDING, target=Order.STATE.CANCELED) - def cancel(self): - """Cancel this order""" - - @transition(field="state", source=Order.STATE.PENDING, target=Order.STATE.DECLINED) - def decline(self): - """ - Decline this order. This additionally clears the discount redemptions - for the order so the discounts can be reused. - """ - for redemption in self.discounts.all(): - redemption.delete() - - self.state = Order.STATE.DECLINED - self.save() - - return self - - @transition(field="state", source=Order.STATE.PENDING, target=Order.STATE.ERRORED) - def error(self): - """Error this order""" - class Meta: proxy = True @@ -771,60 +799,11 @@ class Meta: class FulfilledOrder(Order): """An order that has a fulfilled payment""" - @transition(field="state", source=Order.STATE.FULFILLED, target=Order.STATE.ERRORED) - def error(self): - """Error this order""" - - @transition( - field="state", - source=Order.STATE.FULFILLED, - target=Order.STATE.REFUNDED, - custom=dict(admin=False), # noqa: C408 - ) - def refund(self, *, api_response_data: dict = None, **kwargs): # noqa: RUF013 - """ - Records the refund, and optionally attempts to unenroll the learner from - the things they bought. - - Args: - api_response_data (dict): In case of API response we will have the response data dictionary - kwargs: Ideally it should have named parameters such as - 1- amount: that was refunded - 2- reason: for refunding the order - - at hand with enough details, So when the dict is passed we would save it as is, - otherwise fallback to default dict creation below - Returns: - Object (Transaction): return the refund transaction object for the refund. - """ - amount = kwargs.get("amount") - reason = kwargs.get("reason") - - transaction_id = api_response_data.get("id") - if transaction_id is None: - raise ValidationError( - "Failed to record transaction: Missing transaction id from refund API response" # noqa: EM101 - ) - - refund_transaction, created = self.transactions.get_or_create( - transaction_id=transaction_id, - data=api_response_data, - amount=amount, - transaction_type=TRANSACTION_TYPE_REFUND, - reason=reason, - ) - self.state = Order.STATE.REFUNDED - self.save() - - send_order_refund_email.delay(self.id) - - return refund_transaction - class Meta: proxy = True -class ReviewOrder(FulfillableOrder, Order): +class ReviewOrder(Order): """An order that has been placed under review by the payment processor.""" class Meta: @@ -838,10 +817,6 @@ class CanceledOrder(Order): The state of this can't be altered further. """ - @transition(field="state", source=Order.STATE.CANCELED, target=Order.STATE.ERRORED) - def error(self): - """Error this order""" - class Meta: proxy = True @@ -864,10 +839,6 @@ class DeclinedOrder(Order): The state of this can't be altered further. """ - @transition(field="state", source=Order.STATE.DECLINED, target=Order.STATE.ERRORED) - def error(self): - """Error this order""" - class Meta: proxy = True diff --git a/ecommerce/models_test.py b/ecommerce/models_test.py index c33866f191..d4888a93fb 100644 --- a/ecommerce/models_test.py +++ b/ecommerce/models_test.py @@ -32,6 +32,7 @@ FulfilledOrder, Line, Order, + OrderStatus, PendingOrder, Product, Transaction, @@ -74,7 +75,7 @@ def basket(): def perform_discount_redemption(user, discount): """Redeems a discount.""" - order = Order(purchaser=user, state=Order.STATE.FULFILLED, total_price_paid=10) + order = Order(purchaser=user, state=OrderStatus.FULFILLED, total_price_paid=10) order.save() redemption = DiscountRedemption( @@ -210,14 +211,14 @@ def test_order_refund(settings): basket_item = BasketItemFactory.create() order = PendingOrder.create_from_basket(basket_item.basket) - order.fulfill({"result": "Payment succeeded", "transaction_id": "12345"}) - order.save() + order_flow = order.get_object_flow() + order_flow.fulfill({"result": "Payment succeeded", "transaction_id": "12345"}) fulfilled_order = FulfilledOrder.objects.get(pk=order.id) assert fulfilled_order.transactions.count() == 1 - fulfilled_order.refund( + order_flow.refund( # API response for refund doesn't have transaction_id, it has different id api_response_data={ "id": "45678", @@ -226,11 +227,10 @@ def test_order_refund(settings): reason="Test refund", unenroll_learner=True, ) - fulfilled_order.save() fulfilled_order.refresh_from_db() - assert fulfilled_order.state == Order.STATE.REFUNDED + assert fulfilled_order.state == OrderStatus.REFUNDED assert fulfilled_order.transactions.count() == 2 @@ -345,9 +345,9 @@ def test_create_transaction_with_no_transaction_id(): """Test that creating payment or refund transaction without transaction id in payment data will raise exception""" with pytest.raises(ValidationError): # noqa: PT012 - pending_order = OrderFactory.create(state=Order.STATE.PENDING) - pending_order.fulfill({}) - pending_order.save() + pending_order = OrderFactory.create(state=OrderStatus.PENDING) + pending_order_flow = pending_order.get_object_flow() + pending_order_flow.fulfill({}) assert ( Transaction.objects.filter( transaction_type="payment", @@ -355,9 +355,10 @@ def test_create_transaction_with_no_transaction_id(): == 0 ) - fulfilled_order = OrderFactory.create(state=Order.STATE.FULFILLED) + fulfilled_order = OrderFactory.create(state=OrderStatus.FULFILLED) + fulfilled_order_flow = fulfilled_order.get_object_flow() with pytest.raises(ValidationError): - fulfilled_order.refund( + fulfilled_order_flow.refund( api_response_data={}, amount=fulfilled_order.total_price_paid, reason="Test refund", @@ -429,12 +430,12 @@ def test_pending_order_is_reused(basket): basket_item.save() order = PendingOrder.create_from_basket(basket) order.save() - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 1 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 1 order = PendingOrder.create_from_basket(basket) order.save() # Verify that the existing PendingOrder is reused and a duplicate is not created. # This is to ensure that we also reuse the HubSpot Deal associated with Orders. - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 1 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 1 @pytest.mark.parametrize( @@ -466,7 +467,7 @@ def test_pending_order_is_reused_but_discounts_cleared( order = PendingOrder.create_from_basket(basket) order.save() - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 1 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 1 if apply_discount == "to_order": redemption = DiscountRedemption( @@ -502,7 +503,7 @@ def test_pending_order_is_reused_but_discounts_cleared( # This is to ensure that we also reuse the HubSpot Deal associated with Orders. # Also ensure the discounts aren't reattached to the order if we just attached # the discount to the order - if it's in the basket, it should be reattached, but we should only get one - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 1 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 1 if apply_discount == "to_order": assert order.discounts.count() == 0 else: @@ -533,7 +534,7 @@ def test_new_pending_order_is_created_if_product_is_different(): order = PendingOrder.create_from_product(product=products[1], user=user) order.save() assert order.lines.count() == 1 - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 2 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 2 def test_pending_order_is_reused_if_multiple_exist(basket): @@ -548,7 +549,7 @@ def test_pending_order_is_reused_if_multiple_exist(basket): # Create 2 PendingOrders order1 = Order.objects.create( - state=Order.STATE.PENDING, + state=OrderStatus.PENDING, purchaser=basket.user, total_price_paid=0, ) @@ -560,7 +561,7 @@ def test_pending_order_is_reused_if_multiple_exist(basket): quantity=1, ) order2 = Order.objects.create( - state=Order.STATE.PENDING, + state=OrderStatus.PENDING, purchaser=basket.user, total_price_paid=0, ) @@ -578,7 +579,7 @@ def test_pending_order_is_reused_if_multiple_exist(basket): order.save() # Verify that one of the existing PendingOrder's is reused insteading of # creating a third. - assert Order.objects.filter(state=Order.STATE.PENDING).count() == 2 + assert Order.objects.filter(state=OrderStatus.PENDING).count() == 2 def test_discount_expires_in_past(unlimited_discount): diff --git a/ecommerce/serializers_test.py b/ecommerce/serializers_test.py index 5346a8bdf8..8f68635c1b 100644 --- a/ecommerce/serializers_test.py +++ b/ecommerce/serializers_test.py @@ -20,7 +20,7 @@ ProductFactory, UnlimitedUseDiscountFactory, ) -from ecommerce.models import BasketDiscount, Order +from ecommerce.models import BasketDiscount, Order, OrderStatus from ecommerce.serializers import ( BaseProductSerializer, BasketItemSerializer, @@ -281,7 +281,7 @@ def create_order_receipt(mocker, user, products, user_client): "transaction_id": "12345", } - order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) resp = user_client.post(reverse("checkout-result-callback"), payload) diff --git a/ecommerce/tasks_test.py b/ecommerce/tasks_test.py index e0788509fb..a343c453ed 100644 --- a/ecommerce/tasks_test.py +++ b/ecommerce/tasks_test.py @@ -53,8 +53,8 @@ def test_delayed_order_refund_sends_email( transaction_data = {"id": "refunded-transaction"} refund_amount = order.total_price_paid / 2 - - order.refund( + order_flow = order.get_object_flow() + order_flow.refund( api_response_data=transaction_data, amount=refund_amount, reason="testing" ) diff --git a/ecommerce/urls.py b/ecommerce/urls.py index 1c8ba5efd4..21744e71f8 100644 --- a/ecommerce/urls.py +++ b/ecommerce/urls.py @@ -1,4 +1,4 @@ -from django.urls import include, re_path +from django.urls import include, path, re_path from rest_framework.routers import SimpleRouter from rest_framework_extensions.routers import NestedRouterMixin @@ -80,15 +80,15 @@ class SimpleRouterWithNesting(NestedRouterMixin, SimpleRouter): ) urlpatterns = [ - re_path(r"^api/v0/", include(router.urls)), - re_path(r"^api/", include(router.urls)), + path("api/v0/", include(router.urls)), + path("api/", include(router.urls)), re_path( "checkout/to_payment", CheckoutInterstitialView.as_view(), name="checkout_interstitial_page", ), - re_path( - r"^api/orders/receipt/(?P\d+)/$", + path( + "api/orders/receipt//", OrderReceiptView.as_view(), name="order_receipt_api", ), diff --git a/ecommerce/views/v0/__init__.py b/ecommerce/views/v0/__init__.py index a263cfcefb..187693044a 100644 --- a/ecommerce/views/v0/__init__.py +++ b/ecommerce/views/v0/__init__.py @@ -48,6 +48,7 @@ DiscountProduct, DiscountRedemption, Order, + OrderStatus, Product, UserDiscount, ) @@ -555,19 +556,19 @@ def post_checkout_redirect(self, order_state, order, request): Returns: HttpResponse """ - if order_state == Order.STATE.CANCELED: + if order_state == OrderStatus.CANCELED: return redirect_with_user_message( reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_CANCELLED} ) - elif order_state == Order.STATE.ERRORED: + elif order_state == OrderStatus.ERRORED: return redirect_with_user_message( reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_ERROR} ) - elif order_state == Order.STATE.DECLINED: + elif order_state == OrderStatus.DECLINED: return redirect_with_user_message( reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_DECLINED} ) - elif order_state == Order.STATE.FULFILLED: + elif order_state == OrderStatus.FULFILLED: return redirect_with_user_message( reverse("user-dashboard"), { @@ -618,7 +619,7 @@ def post(self, request, *args, **kwargs): # noqa: ARG002 # If it isn't, then we just need to redirect the user with the # proper message. - if order.state == Order.STATE.PENDING: + if order.state == OrderStatus.PENDING: processed_order_state = api.process_cybersource_payment_response( request, order ) @@ -654,7 +655,7 @@ def post(self, request, *args, **kwargs): # noqa: ARG002 # the user's browser. if order is None: raise Http404 - elif order.state == Order.STATE.PENDING: + elif order.state == OrderStatus.PENDING: api.process_cybersource_payment_response(request, order) return Response(status=status.HTTP_200_OK) @@ -796,7 +797,7 @@ class OrderHistoryViewSet(ReadOnlyModelViewSet): def get_queryset(self): return ( Order.objects.filter(purchaser=self.request.user) - .filter(state__in=[Order.STATE.FULFILLED, Order.STATE.REFUNDED]) + .filter(state__in=[OrderStatus.FULFILLED, OrderStatus.REFUNDED]) .order_by("-created_on") .all() ) diff --git a/ecommerce/views_test.py b/ecommerce/views_test.py index 7b2034342a..8154061baf 100644 --- a/ecommerce/views_test.py +++ b/ecommerce/views_test.py @@ -34,6 +34,7 @@ Discount, DiscountProduct, Order, + OrderStatus, PendingOrder, UserDiscount, ) @@ -453,7 +454,7 @@ def test_start_checkout(user, user_drf_client, products): order = Order.objects.filter(purchaser=user).get() - assert order.state == Order.STATE.PENDING + assert order.state == OrderStatus.PENDING def test_start_checkout_with_discounts(user, user_drf_client, products, discounts): @@ -470,7 +471,7 @@ def test_start_checkout_with_discounts(user, user_drf_client, products, discount order = Order.objects.filter(purchaser=user).get() - assert order.state == Order.STATE.PENDING + assert order.state == OrderStatus.PENDING def test_start_checkout_with_invalid_discounts(user, user_client, products, discounts): @@ -503,11 +504,11 @@ def test_start_checkout_with_invalid_discounts(user, user_client, products, disc @pytest.mark.parametrize( "decision, expected_redirect_url, expected_state, basket_exists", # noqa: PT006 [ - ("CANCEL", reverse("cart"), Order.STATE.CANCELED, True), - ("DECLINE", reverse("cart"), Order.STATE.DECLINED, True), - ("ERROR", reverse("cart"), Order.STATE.ERRORED, True), - ("REVIEW", reverse("cart"), Order.STATE.CANCELED, True), - ("ACCEPT", reverse("user-dashboard"), Order.STATE.FULFILLED, False), + ("CANCEL", reverse("cart"), OrderStatus.CANCELED, True), + ("DECLINE", reverse("cart"), OrderStatus.DECLINED, True), + ("ERROR", reverse("cart"), OrderStatus.ERRORED, True), + ("REVIEW", reverse("cart"), OrderStatus.CANCELED, True), + ("ACCEPT", reverse("user-dashboard"), OrderStatus.FULFILLED, False), ], ) def test_checkout_result( # noqa: PLR0913 @@ -546,7 +547,7 @@ def test_checkout_result( # noqa: PLR0913 # Load the pending order from the DB(factory) - should match the ref# in # the payload we get back - order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) assert order.reference_number == payload["req_reference_number"] @@ -849,8 +850,8 @@ def test_paid_and_unpaid_courserun_checkout( product = products[0] basket = create_basket_with_product(user, product) order = PendingOrder.create_from_basket(basket) - order.fulfill({"result": "Payment succeeded", "transaction_id": "12345"}) - order.save() + order_flow = order.get_object_flow() + order_flow.fulfill({"result": "Payment succeeded", "transaction_id": "12345"}) basket.delete() @@ -876,11 +877,11 @@ def test_paid_and_unpaid_courserun_checkout( @pytest.mark.parametrize( "decision, expected_state, basket_exists", # noqa: PT006 [ - ("CANCEL", Order.STATE.CANCELED, True), - ("DECLINE", Order.STATE.DECLINED, True), - ("ERROR", Order.STATE.ERRORED, True), - ("REVIEW", Order.STATE.CANCELED, True), - ("ACCEPT", Order.STATE.FULFILLED, False), + ("CANCEL", OrderStatus.CANCELED, True), + ("DECLINE", OrderStatus.DECLINED, True), + ("ERROR", OrderStatus.ERRORED, True), + ("REVIEW", OrderStatus.CANCELED, True), + ("ACCEPT", OrderStatus.FULFILLED, False), ], ) def test_checkout_api_result( # noqa: PLR0913 @@ -917,7 +918,7 @@ def test_checkout_api_result( # noqa: PLR0913 # Load the pending order from the DB(factory) - should match the ref# in # the payload we get back - order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + order = Order.objects.get(state=OrderStatus.PENDING, purchaser=user) assert order.reference_number == payload["req_reference_number"] @@ -974,7 +975,7 @@ def test_checkout_api_result_verification_failure( payload = resp.json()["payload"] payload = { **{f"req_{key}": value for key, value in payload.items()}, - "decision": Order.STATE.PENDING, + "decision": OrderStatus.PENDING, "message": "payment processor message", "transaction_id": "12345", } diff --git a/flexiblepricing/admin.py b/flexiblepricing/admin.py index 3e02849c62..f318b84be1 100644 --- a/flexiblepricing/admin.py +++ b/flexiblepricing/admin.py @@ -19,6 +19,7 @@ class CurrencyExchangeRateAdmin(admin.ModelAdmin): model = CurrencyExchangeRate +@admin.register(CountryIncomeThreshold) class CountryIncomeThresholdAdmin(admin.ModelAdmin): """Admin for CountryIncomeThreshold""" @@ -51,6 +52,7 @@ class FlexiblePricingRequestSubmissionAdmin(admin.ModelAdmin): readonly_fields = ("form_data", "user", "page", "submit_time") +@admin.register(FlexiblePriceTier) class FlexiblePriceTierAdmin(admin.ModelAdmin): """Admin for FlexiblePriceTier""" @@ -64,7 +66,3 @@ class FlexiblePriceTierAdmin(admin.ModelAdmin): "current", ) raw_id_fields = ("discount",) - - -admin.site.register(CountryIncomeThreshold, CountryIncomeThresholdAdmin) -admin.site.register(FlexiblePriceTier, FlexiblePriceTierAdmin) diff --git a/flexiblepricing/api_test.py b/flexiblepricing/api_test.py index 5592a0ede9..b538ba1068 100644 --- a/flexiblepricing/api_test.py +++ b/flexiblepricing/api_test.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timedelta +from pathlib import Path import ddt import freezegun @@ -61,8 +62,10 @@ def test_parse_country_income_thresholds_no_header(tmp_path): """parse_country_income_thresholds should throw error if no header is found""" path = tmp_path / "test.csv" - open(path, "w") # create a file # noqa: SIM115, PTH123 - with pytest.raises(CountryIncomeThresholdException) as exc: + with ( + Path.open(path, "w"), + pytest.raises(CountryIncomeThresholdException) as exc, + ): # create a file parse_country_income_thresholds(path) assert exc.value.args[0] == "Unable to find the header row" diff --git a/flexiblepricing/management/commands/update_exchange_rates_test.py b/flexiblepricing/management/commands/update_exchange_rates_test.py deleted file mode 100644 index 1fafa37f5f..0000000000 --- a/flexiblepricing/management/commands/update_exchange_rates_test.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Test for management command generating exchange rates -""" - -from unittest.mock import patch - -from django.test import TestCase -from django.test.utils import override_settings - -from flexiblepricing.management.commands import update_exchange_rates -from flexiblepricing.models import CurrencyExchangeRate -from flexiblepricing.tasks import get_open_exchange_rates_url - - -@patch("flexiblepricing.tasks.requests.get") -class GenerateExchangeRatesTest(TestCase): - """ - Tests for generate_exchange_rates management command - """ - - @classmethod - def setUpTestData(cls): - cls.command = update_exchange_rates.Command() - - def setUp(self): - super(GenerateExchangeRatesTest, self).setUp() # noqa: UP008 - self.data = { - "extraneous information": "blah blah blah", - "rates": {"CBA": "3.5", "FED": "1.9", "RQP": "0.5"}, - } - - @override_settings( - OPEN_EXCHANGE_RATES_APP_ID="foo_id", - OPEN_EXCHANGE_RATES_URL="http://foo.bar.com", - ) - def test_currency_exchange_rate_command(self, mocked_request): - """ - Assert currency exchange rates are created using management command - """ - mocked_request.return_value.json.return_value = self.data - mocked_request.return_value.status_code = 200 - assert CurrencyExchangeRate.objects.count() == 0 - self.command.handle("generate_exchange_rates") - called_args, _ = mocked_request.call_args - assert called_args[0] == get_open_exchange_rates_url("latest.json") - assert CurrencyExchangeRate.objects.count() == 3 - currency_cba = CurrencyExchangeRate.objects.get(currency_code="CBA") - assert currency_cba.exchange_rate == 3.5 - currency_fed = CurrencyExchangeRate.objects.get(currency_code="FED") - assert currency_fed.exchange_rate == 1.9 - currency_rqp = CurrencyExchangeRate.objects.get(currency_code="RQP") - assert currency_rqp.exchange_rate == 0.5 diff --git a/flexiblepricing/urls.py b/flexiblepricing/urls.py index 0905f35fdd..ed1115502e 100644 --- a/flexiblepricing/urls.py +++ b/flexiblepricing/urls.py @@ -1,4 +1,4 @@ -from django.urls import include, re_path +from django.urls import include, path from rest_framework.routers import SimpleRouter from rest_framework_extensions.routers import NestedRouterMixin @@ -40,6 +40,6 @@ class SimpleRouterWithNesting(NestedRouterMixin, SimpleRouter): ) urlpatterns = [ - re_path(r"^api/v0/flexible_pricing/", include(router.urls)), - re_path(r"^api/flexible_pricing/", include(router.urls)), + path("api/v0/flexible_pricing/", include(router.urls)), + path("api/flexible_pricing/", include(router.urls)), ] diff --git a/hubspot_sync/management/commands/configure_hubspot_properties.py b/hubspot_sync/management/commands/configure_hubspot_properties.py index cbd60a5a30..1ff2ac4f4b 100644 --- a/hubspot_sync/management/commands/configure_hubspot_properties.py +++ b/hubspot_sync/management/commands/configure_hubspot_properties.py @@ -45,50 +45,50 @@ "fieldType": "select", "options": [ { - "value": models.Order.STATE.FULFILLED, - "label": models.Order.STATE.FULFILLED, + "value": models.OrderStatus.FULFILLED, + "label": models.OrderStatus.FULFILLED, "displayOrder": 0, "hidden": False, }, { - "value": models.Order.STATE.CANCELED, - "label": models.Order.STATE.CANCELED, + "value": models.OrderStatus.CANCELED, + "label": models.OrderStatus.CANCELED, "displayOrder": 1, "hidden": False, }, { - "value": models.Order.STATE.ERRORED, - "label": models.Order.STATE.ERRORED, + "value": models.OrderStatus.ERRORED, + "label": models.OrderStatus.ERRORED, "displayOrder": 2, "hidden": False, }, { - "value": models.Order.STATE.DECLINED, - "label": models.Order.STATE.DECLINED, + "value": models.OrderStatus.DECLINED, + "label": models.OrderStatus.DECLINED, "displayOrder": 3, "hidden": False, }, { - "value": models.Order.STATE.PENDING, - "label": models.Order.STATE.PENDING, + "value": models.OrderStatus.PENDING, + "label": models.OrderStatus.PENDING, "displayOrder": 4, "hidden": False, }, { - "value": models.Order.STATE.REFUNDED, - "label": models.Order.STATE.REFUNDED, + "value": models.OrderStatus.REFUNDED, + "label": models.OrderStatus.REFUNDED, "displayOrder": 5, "hidden": False, }, { - "value": models.Order.STATE.PARTIALLY_REFUNDED, - "label": models.Order.STATE.PARTIALLY_REFUNDED, + "value": models.OrderStatus.PARTIALLY_REFUNDED, + "label": models.OrderStatus.PARTIALLY_REFUNDED, "displayOrder": 6, "hidden": False, }, { - "value": models.Order.STATE.REVIEW, - "label": models.Order.STATE.REVIEW, + "value": models.OrderStatus.REVIEW, + "label": models.OrderStatus.REVIEW, "displayOrder": 7, "hidden": False, }, @@ -503,50 +503,50 @@ "fieldType": "select", "options": [ { - "value": models.Order.STATE.FULFILLED, - "label": models.Order.STATE.FULFILLED, + "value": models.OrderStatus.FULFILLED, + "label": models.OrderStatus.FULFILLED, "displayOrder": 0, "hidden": False, }, { - "value": models.Order.STATE.CANCELED, - "label": models.Order.STATE.CANCELED, + "value": models.OrderStatus.CANCELED, + "label": models.OrderStatus.CANCELED, "displayOrder": 1, "hidden": False, }, { - "value": models.Order.STATE.ERRORED, - "label": models.Order.STATE.ERRORED, + "value": models.OrderStatus.ERRORED, + "label": models.OrderStatus.ERRORED, "displayOrder": 2, "hidden": False, }, { - "value": models.Order.STATE.DECLINED, - "label": models.Order.STATE.DECLINED, + "value": models.OrderStatus.DECLINED, + "label": models.OrderStatus.DECLINED, "displayOrder": 3, "hidden": False, }, { - "value": models.Order.STATE.PENDING, - "label": models.Order.STATE.PENDING, + "value": models.OrderStatus.PENDING, + "label": models.OrderStatus.PENDING, "displayOrder": 4, "hidden": False, }, { - "value": models.Order.STATE.REFUNDED, - "label": models.Order.STATE.REFUNDED, + "value": models.OrderStatus.REFUNDED, + "label": models.OrderStatus.REFUNDED, "displayOrder": 5, "hidden": False, }, { - "value": models.Order.STATE.PARTIALLY_REFUNDED, - "label": models.Order.STATE.PARTIALLY_REFUNDED, + "value": models.OrderStatus.PARTIALLY_REFUNDED, + "label": models.OrderStatus.PARTIALLY_REFUNDED, "displayOrder": 6, "hidden": False, }, { - "value": models.Order.STATE.REVIEW, - "label": models.Order.STATE.REVIEW, + "value": models.OrderStatus.REVIEW, + "label": models.OrderStatus.REVIEW, "displayOrder": 7, "hidden": False, }, diff --git a/hubspot_sync/management/commands/create_order_from_course_run_enrollments.py b/hubspot_sync/management/commands/create_order_from_course_run_enrollments.py index 658f9e5b3f..66b2c1ee3f 100644 --- a/hubspot_sync/management/commands/create_order_from_course_run_enrollments.py +++ b/hubspot_sync/management/commands/create_order_from_course_run_enrollments.py @@ -7,7 +7,7 @@ from reversion.models import Version from courses.models import CourseRun, CourseRunEnrollment -from ecommerce.models import Order, PendingOrder, Product +from ecommerce.models import Order, OrderStatus, PendingOrder, Product from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE @@ -34,7 +34,7 @@ def handle(self, *args, **options): # noqa: ARG002 product_object_id = product.object_id product_content_type = product.content_type_id existing_fulfilled_order = Order.objects.filter( - state__in=[Order.STATE.FULFILLED, Order.STATE.PENDING], + state__in=[OrderStatus.FULFILLED, OrderStatus.PENDING], purchaser=course_run_enrollment.user, lines__purchased_object_id=product_object_id, lines__purchased_content_type_id=product_content_type, diff --git a/hubspot_sync/serializers.py b/hubspot_sync/serializers.py index d614c940a9..34c6482063 100644 --- a/hubspot_sync/serializers.py +++ b/hubspot_sync/serializers.py @@ -24,14 +24,14 @@ 48288390: Processed """ ORDER_STATUS_MAPPING = { - models.Order.STATE.FULFILLED: "48288390", - models.Order.STATE.PENDING: "48288388", - models.Order.STATE.CANCELED: "48288379", - models.Order.STATE.DECLINED: "48288389", - models.Order.STATE.ERRORED: "48288389", - models.Order.STATE.REFUNDED: "48288389", - models.Order.STATE.PARTIALLY_REFUNDED: "48288389", - models.Order.STATE.REVIEW: "48288390", + models.OrderStatus.FULFILLED: "48288390", + models.OrderStatus.PENDING: "48288388", + models.OrderStatus.CANCELED: "48288379", + models.OrderStatus.DECLINED: "48288389", + models.OrderStatus.ERRORED: "48288389", + models.OrderStatus.REFUNDED: "48288389", + models.OrderStatus.PARTIALLY_REFUNDED: "48288389", + models.OrderStatus.REVIEW: "48288390", } @@ -182,7 +182,7 @@ def get_dealstage(self, instance): def get_closedate(self, instance): """Return the updated_on date (as a timestamp in milliseconds) if fulfilled""" - if instance.state == models.Order.STATE.FULFILLED: # noqa: RET503 + if instance.state == models.OrderStatus.FULFILLED: # noqa: RET503 return int(instance.updated_on.timestamp() * 1000) def get_discount_type(self, instance): diff --git a/hubspot_sync/serializers_test.py b/hubspot_sync/serializers_test.py index 570183e420..76b5728699 100644 --- a/hubspot_sync/serializers_test.py +++ b/hubspot_sync/serializers_test.py @@ -28,7 +28,7 @@ DiscountRedemptionFactory, ProductFactory, ) -from ecommerce.models import Order, Product +from ecommerce.models import OrderStatus, Product from hubspot_sync.serializers import ( ORDER_STATUS_MAPPING, HubspotContactSerializer, @@ -110,7 +110,7 @@ def test_serialize_line_no_corresponding_enrollment(hubspot_order): } -@pytest.mark.parametrize("status", [Order.STATE.FULFILLED, Order.STATE.PENDING]) +@pytest.mark.parametrize("status", [OrderStatus.FULFILLED, OrderStatus.PENDING]) def test_serialize_order(settings, hubspot_order, status): """Test that OrderToDealSerializer produces the correct serialized data""" hubspot_order.state = status @@ -124,7 +124,7 @@ def test_serialize_order(settings, hubspot_order, status): "discount_percent": "0", "closedate": ( int(hubspot_order.updated_on.timestamp() * 1000) - if status == Order.STATE.FULFILLED + if status == OrderStatus.FULFILLED else None ), "coupon_code": None, @@ -164,7 +164,7 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 "discount_amount": amount_off, "closedate": ( int(hubspot_order.updated_on.timestamp() * 1000) - if hubspot_order.state == Order.STATE.FULFILLED + if hubspot_order.state == OrderStatus.FULFILLED else None ), "coupon_code": coupon_redemption.redeemed_discount.discount_code, diff --git a/mail/urls.py b/mail/urls.py index cd77fb4db6..55575c4089 100644 --- a/mail/urls.py +++ b/mail/urls.py @@ -1,7 +1,7 @@ """URL configurations for mail""" from django.conf import settings -from django.conf.urls import url +from django.urls import path from mail.views import EmailDebuggerView @@ -9,5 +9,5 @@ if settings.DEBUG and not settings.MITOL_MAIL_ENABLE_EMAIL_DEBUGGER: urlpatterns += [ - url(r"^__emaildebugger__/$", EmailDebuggerView.as_view(), name="email-debugger") + path("__emaildebugger__/", EmailDebuggerView.as_view(), name="email-debugger") ] diff --git a/mail/verification_api_test.py b/mail/verification_api_test.py index f603c7d86b..7f1c5c84af 100644 --- a/mail/verification_api_test.py +++ b/mail/verification_api_test.py @@ -23,7 +23,8 @@ def test_send_verification_email(mocker, rf): email = "test@localhost" request = rf.post(reverse("social:complete", args=("email",)), {"email": email}) # social_django depends on request.session, so use the middleware to set that - SessionMiddleware().process_request(request) + get_response = mocker.MagicMock() + SessionMiddleware(get_response).process_request(request) strategy = load_strategy(request) backend = load_backend(strategy, EmailAuth.name, None) code = mocker.Mock(code="abc") diff --git a/main/settings.py b/main/settings.py index 143361c2b9..ea2c4b0bbb 100644 --- a/main/settings.py +++ b/main/settings.py @@ -157,7 +157,6 @@ "django.contrib.sites", "django_user_agents", "social_django", - "server_status", "oauth2_provider", "rest_framework", "anymail", @@ -180,8 +179,7 @@ "modelcluster", "taggit", # django-fsm-admin - "django_fsm", - "fsm_admin", + "viewflow", # django-robots "robots", # django-reversion @@ -324,7 +322,6 @@ USE_I18N = True -USE_L10N = True USE_TZ = True diff --git a/main/templates/base.html b/main/templates/base.html index 6493c66a33..f14a97d7c2 100644 --- a/main/templates/base.html +++ b/main/templates/base.html @@ -21,7 +21,6 @@ {% render_bundle 'style' %} {% noindex_meta %} {% block title %}{% endblock %} - diff --git a/main/test_utils.py b/main/test_utils.py index b4ee3b0b8a..c873bb5be0 100644 --- a/main/test_utils.py +++ b/main/test_utils.py @@ -137,7 +137,7 @@ def list_of_dicts(specialty_dict_iter): return list(map(dict, specialty_dict_iter)) -def set_request_session(request, session_dict): +def set_request_session(mocker, request, session_dict): """ Sets session variables on a RequestFactory object Args: @@ -147,7 +147,8 @@ def set_request_session(request, session_dict): Returns: RequestFactory: The same request object with session variables set """ - middleware = SessionMiddleware() + get_response = mocker.MagicMock() + middleware = SessionMiddleware(get_response) middleware.process_request(request) for key, value in session_dict.items(): request.session[key] = value diff --git a/main/urls.py b/main/urls.py index c5b28a0075..49a238d23a 100644 --- a/main/urls.py +++ b/main/urls.py @@ -15,10 +15,9 @@ """ from django.conf import settings -from django.conf.urls import include from django.conf.urls.static import static from django.contrib import admin -from django.urls import path, re_path +from django.urls import include, path, re_path from oauth2_provider.urls import base_urlpatterns, oidc_urlpatterns from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls @@ -40,7 +39,6 @@ ), ), path("admin/", admin.site.urls), - path("status/", include("server_status.urls")), path("hijack/", include("hijack.urls")), path("robots.txt", include("robots.urls")), path("", include("authentication.urls")), @@ -57,7 +55,7 @@ re_path(r"^staff-dashboard/.*", refine, name="staff-dashboard"), path("signin/", index, name="login"), path("signin/password/", index, name="login-password"), - re_path(r"^signin/forgot-password/$", index, name="password-reset"), + path("signin/forgot-password/", index, name="password-reset"), re_path( r"^signin/forgot-password/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,32})/$", index, @@ -85,8 +83,8 @@ re_path( r"^cms/login", cms_signin_redirect_to_site_signin, name="wagtailadmin_login" ), - re_path(r"^cms/", include(wagtailadmin_urls)), - re_path(r"^documents/", include(wagtaildocs_urls)), + path("cms/", include(wagtailadmin_urls)), + path("documents/", include(wagtaildocs_urls)), path("", include(wagtail_urls)), path("", include("cms.urls")), # Example view diff --git a/micromasters_import/admin.py b/micromasters_import/admin.py index e0970daa2c..3145b64ff9 100644 --- a/micromasters_import/admin.py +++ b/micromasters_import/admin.py @@ -10,6 +10,7 @@ ) +@admin.register(ProgramId) class ProgramIdAdmin(admin.ModelAdmin): """Admin for ProgramId""" @@ -18,6 +19,7 @@ class ProgramIdAdmin(admin.ModelAdmin): raw_id_fields = ("program_certificate_revision",) +@admin.register(CourseId) class CourseIdAdmin(admin.ModelAdmin): """Admin for CourseId""" @@ -25,6 +27,7 @@ class CourseIdAdmin(admin.ModelAdmin): list_display = ("course", "micromasters_id") +@admin.register(ProgramTierId) class ProgramTieIdAdmin(admin.ModelAdmin): """Admin for ProgramTierId""" @@ -32,15 +35,10 @@ class ProgramTieIdAdmin(admin.ModelAdmin): list_display = ("micromasters_tier_program_id", "flexible_price_tier") +@admin.register(CourseCertificateRevisionId) class CourseCertificateRevisionIdAdmin(admin.ModelAdmin): """Admin for CourseCertificateRevisionId""" model = CourseCertificateRevisionId list_display = ("course", "certificate_page_revision") raw_id_fields = ("certificate_page_revision",) - - -admin.site.register(CourseId, CourseIdAdmin) -admin.site.register(ProgramId, ProgramIdAdmin) -admin.site.register(ProgramTierId, ProgramTieIdAdmin) -admin.site.register(CourseCertificateRevisionId, CourseCertificateRevisionIdAdmin) diff --git a/openedx/__init__.py b/openedx/__init__.py index ac75afcfc1..d835a5f92c 100644 --- a/openedx/__init__.py +++ b/openedx/__init__.py @@ -1,2 +1 @@ # pylint: disable=missing-docstring,invalid-name -default_app_config = "openedx.apps.CoursewareConfig" diff --git a/openedx/admin.py b/openedx/admin.py index 94af275a64..4edba158ca 100644 --- a/openedx/admin.py +++ b/openedx/admin.py @@ -7,6 +7,7 @@ from openedx.models import OpenEdxApiAuth, OpenEdxUser +@admin.register(OpenEdxUser) class OpenEdxUserAdmin(admin.ModelAdmin): """Admin for OpenEdxUser""" @@ -21,6 +22,7 @@ def get_queryset(self, request): return super().get_queryset(request).select_related("user") +@admin.register(OpenEdxApiAuth) class OpenEdxApiAuthAdmin(admin.ModelAdmin): """Admin for OpenEdxApiAuth""" @@ -32,7 +34,3 @@ class OpenEdxApiAuthAdmin(admin.ModelAdmin): def get_queryset(self, request): """Overrides base queryset""" return super().get_queryset(request).select_related("user") - - -admin.site.register(OpenEdxUser, OpenEdxUserAdmin) -admin.site.register(OpenEdxApiAuth, OpenEdxApiAuthAdmin) diff --git a/openedx/migrations/0006_rename_openedxapiauth_user_access_token_expires_on_openedx_ope_user_id_76f6b9_idx.py b/openedx/migrations/0006_rename_openedxapiauth_user_access_token_expires_on_openedx_ope_user_id_76f6b9_idx.py new file mode 100644 index 0000000000..f8c17462ca --- /dev/null +++ b/openedx/migrations/0006_rename_openedxapiauth_user_access_token_expires_on_openedx_ope_user_id_76f6b9_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2 on 2024-09-05 15:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx", "0005_alter_openedxuser_has_been_synced"), + ] + + operations = [ + migrations.RenameIndex( + model_name="openedxapiauth", + new_name="openedx_ope_user_id_76f6b9_idx", + old_fields=("user", "access_token_expires_on"), + ), + ] diff --git a/openedx/models.py b/openedx/models.py index 316d5dc430..9e8dae7340 100644 --- a/openedx/models.py +++ b/openedx/models.py @@ -47,4 +47,4 @@ def __str__(self): return f"OpenEdxApiAuth for {self.user}" class Meta: - index_together = ("user", "access_token_expires_on") + indexes = [models.Index(fields=("user", "access_token_expires_on"))] diff --git a/poetry.lock b/poetry.lock index 8bbf1094cb..f6548dc7a9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -602,37 +602,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "coreapi" -version = "2.3.3" -description = "Python client library for Core API." -optional = false -python-versions = "*" -files = [ - {file = "coreapi-2.3.3-py2.py3-none-any.whl", hash = "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3"}, - {file = "coreapi-2.3.3.tar.gz", hash = "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb"}, -] - -[package.dependencies] -coreschema = "*" -itypes = "*" -requests = "*" -uritemplate = "*" - -[[package]] -name = "coreschema" -version = "0.0.4" -description = "Core Schema." -optional = false -python-versions = "*" -files = [ - {file = "coreschema-0.0.4-py2-none-any.whl", hash = "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f"}, - {file = "coreschema-0.0.4.tar.gz", hash = "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607"}, -] - -[package.dependencies] -jinja2 = "*" - [[package]] name = "coverage" version = "7.5.0" @@ -758,18 +727,21 @@ files = [ [[package]] name = "cssutils" -version = "2.10.2" +version = "2.11.1" description = "A CSS Cascading Style Sheets library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "cssutils-2.10.2-py3-none-any.whl", hash = "sha256:4ad7d2f29270b22cf199f65a6b5e795f2c3130f3b9fb50c3d45e5054ef86e41a"}, - {file = "cssutils-2.10.2.tar.gz", hash = "sha256:93cf92a350b1c123b17feff042e212f94d960975a3ed145743d84ebe8ccec7ab"}, + {file = "cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1"}, + {file = "cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2"}, ] +[package.dependencies] +more-itertools = "*" + [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["cssselect", "importlib-resources", "jaraco.test (>=5.1)", "lxml", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["cssselect", "importlib-resources", "jaraco.test (>=5.1)", "lxml", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [[package]] name = "curtsies" @@ -943,19 +915,19 @@ files = [ [[package]] name = "django" -version = "3.2.25" -description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +version = "4.2" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "Django-3.2.25-py3-none-any.whl", hash = "sha256:a52ea7fcf280b16f7b739cec38fa6d3f8953a5456986944c3ca97e79882b4e38"}, - {file = "Django-3.2.25.tar.gz", hash = "sha256:7ca38a78654aee72378594d63e51636c04b8e28574f5505dff630895b5472777"}, + {file = "Django-4.2-py3-none-any.whl", hash = "sha256:ad33ed68db9398f5dfb33282704925bce044bef4261cd4fb59e4e7f9ae505a78"}, + {file = "Django-4.2.tar.gz", hash = "sha256:c36e2ab12824e2ac36afa8b2515a70c53c7742f0d6eaefa7311ec379558db997"}, ] [package.dependencies] -asgiref = ">=3.3.2,<4" -pytz = "*" -sqlparse = ">=0.2.2" +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] argon2 = ["argon2-cffi (>=19.1.0)"] @@ -1034,58 +1006,35 @@ sqlparse = ">=0.2" [[package]] name = "django-filter" -version = "2.4.0" +version = "24.3" description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." optional = false -python-versions = ">=3.5" -files = [ - {file = "django-filter-2.4.0.tar.gz", hash = "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06"}, - {file = "django_filter-2.4.0-py3-none-any.whl", hash = "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1"}, -] - -[package.dependencies] -Django = ">=2.2" - -[[package]] -name = "django-fsm" -version = "2.8.2" -description = "Django friendly finite state machine support." -optional = false -python-versions = "*" -files = [ - {file = "django-fsm-2.8.2.tar.gz", hash = "sha256:fe3be8fa916470360632b2a86aefcb342d67916fa4c5749caaa7760f88c0c49c"}, - {file = "django_fsm-2.8.2-py2.py3-none-any.whl", hash = "sha256:0b4d346a8de1c2c30c2206ced649e25695f567c072358030544a604fc937d235"}, -] - -[[package]] -name = "django-fsm-admin" -version = "1.2.5" -description = "Integrate django-fsm state transitions into the django admin" -optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "django-fsm-admin-1.2.5.tar.gz", hash = "sha256:74fa2038fdab8072077e18234593fd809d3940216a3e90e3f1ea432ee2992938"}, + {file = "django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64"}, + {file = "django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"}, ] [package.dependencies] -Django = ">=1.6" -django-fsm = ">=2.1.0" +Django = ">=4.2" [[package]] name = "django-hijack" -version = "3.4.5" -description = "django-hijack allows superusers to hijack (=login as) and work on behalf of another user." +version = "3.6.0" +description = "Enable users to hijack (=login as) and work on behalf of another user." optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "django-hijack-3.4.5.tar.gz", hash = "sha256:7e45b1de786bdc130628e4230b359dde6d8744ecd3bcd668d2b27c5d614a071c"}, - {file = "django_hijack-3.4.5-py3-none-any.whl", hash = "sha256:129cbe75444b163135871a947d38ffb72181f4f2583544703fc9efe083c9ddad"}, + {file = "django_hijack-3.6.0-py3-none-any.whl", hash = "sha256:698bbe2bcc95c240e4706d465c3ea6c78c66cdcef357802d171ba7036079b075"}, + {file = "django_hijack-3.6.0.tar.gz", hash = "sha256:4554f7ceb1a5b39aecacd06fee08933a90a357a9c7eeb018d9483d25938dc637"}, ] [package.dependencies] -django = ">=3.2" +django = ">=4.2" [package.extras] +docs = ["mkdocs (==1.6.0)"] +lint = ["msgcheck (==4.0.0)", "ruff (==0.5.0)"] test = ["pytest", "pytest-cov", "pytest-django"] [[package]] @@ -1168,43 +1117,29 @@ redis = ">=3.0.0" [[package]] name = "django-reversion" -version = "5.0.12" +version = "5.1.0" description = "An extension to the Django web framework that provides version control for model instances." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "django-reversion-5.0.12.tar.gz", hash = "sha256:c047cc99a9f1ba4aae6db89c3ac243478d6de98ec8a60c7073fcc875d89c5cdb"}, - {file = "django_reversion-5.0.12-py3-none-any.whl", hash = "sha256:5884e9f77f55c341b3f0a8d3b0af000f060530653776997267c8b1e5349d8fee"}, + {file = "django_reversion-5.1.0-py3-none-any.whl", hash = "sha256:084d4f117d9e2b4e8dfdfaad83ebb34410a03eed6071c96089e6811fdea82ad3"}, + {file = "django_reversion-5.1.0.tar.gz", hash = "sha256:3309821e5b6fceedcce6b6975f1a9c7fab6ae7c7d0e1276a90e345946fa0dcb8"}, ] [package.dependencies] -django = ">=3.2" +django = ">=4.2" [[package]] name = "django-robots" -version = "4.0" +version = "6.1" description = "Robots exclusion application for Django, complementing Sitemaps." optional = false -python-versions = ">1.1.1" +python-versions = ">=3.7" files = [ - {file = "django-robots-4.0.tar.gz", hash = "sha256:4d18ccca0f1fc5bd4a93680e386c3a2fd3bca03fc52f6e3da7b239cf3d456c8c"}, - {file = "django_robots-4.0-py2.py3-none-any.whl", hash = "sha256:71c23147a8619f8b0284476558725d64a4e34ea3972eb4da51271a1537786965"}, + {file = "django-robots-6.1.tar.gz", hash = "sha256:f86bcc3d16d7d7c2a4e37af6063cb4785f50ae16943f82248b48c9e7ac034f1d"}, + {file = "django_robots-6.1-py3-none-any.whl", hash = "sha256:07e11a1bf3ddc08290123ec3c55abb45dbbffb9b38aea0a002e9b4a87bd9abcc"}, ] -[[package]] -name = "django-server-status" -version = "0.7.3" -description = "Monitor server status with a healthcheck." -optional = false -python-versions = "*" -files = [ - {file = "django-server-status-0.7.3.tar.gz", hash = "sha256:9126e20d78c88f62ffe382c1a5c73f1206ba8dca07a8039e81e3f35f1f75941a"}, - {file = "django_server_status-0.7.3-py3-none-any.whl", hash = "sha256:2a018c731504a3c82d45717bca011c4f6b1d547f5384c9b0a04b439e9e5c3552"}, -] - -[package.dependencies] -django = "*" - [[package]] name = "django-storages" version = "1.14.2" @@ -1230,17 +1165,17 @@ sftp = ["paramiko (>=1.15)"] [[package]] name = "django-taggit" -version = "4.0.0" +version = "5.0.1" description = "django-taggit is a reusable Django application for simple tagging." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "django-taggit-4.0.0.tar.gz", hash = "sha256:4d52de9d37245a9b9f98c0ec71fdccf1d2283e38e8866d40a7ae6a3b6787a161"}, - {file = "django_taggit-4.0.0-py3-none-any.whl", hash = "sha256:eb800dabef5f0a4e047ab0751f82cf805bc4a9e972037ef12bf519f52cd92480"}, + {file = "django-taggit-5.0.1.tar.gz", hash = "sha256:edcd7db1e0f35c304e082a2f631ddac2e16ef5296029524eb792af7430cab4cc"}, + {file = "django_taggit-5.0.1-py3-none-any.whl", hash = "sha256:a0ca8a28b03c4b26c2630fd762cb76ec39b5e41abf727a7b66f897a625c5e647"}, ] [package.dependencies] -Django = ">=3.2" +Django = ">=4.1" [[package]] name = "django-templated-mail" @@ -1282,6 +1217,21 @@ files = [ django = "*" user-agents = "*" +[[package]] +name = "django-viewflow" +version = "2.2.7" +description = "Reusable library to build business applications fast" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_viewflow-2.2.7-py3-none-any.whl", hash = "sha256:38c8493dc25efc49df2003777b951980b773b8bb8e31926dd591e9fe0e8acb91"}, + {file = "django_viewflow-2.2.7.tar.gz", hash = "sha256:c81a91d55e235c9bd75dc26bbc26dcfdda5f21eb97a381537040d9b7b07221cc"}, +] + +[package.dependencies] +Django = ">=4.2" +django-filter = ">=2.3.0" + [[package]] name = "django-webpack-loader" version = "1.8.1" @@ -1309,47 +1259,48 @@ django = ">=3.0" [[package]] name = "djangorestframework-simplejwt" -version = "4.8.0" +version = "5.3.1" description = "A minimal JSON Web Token authentication plugin for Django REST Framework" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "djangorestframework_simplejwt-4.8.0-py3-none-any.whl", hash = "sha256:6f09f97cb015265e85d1d02dc6bfc299c72c231eecbe261c5bee5c6b2867f2b4"}, - {file = "djangorestframework_simplejwt-4.8.0.tar.gz", hash = "sha256:153c973c5c154baf566be431de8527c2bd62557fde7373ebcb0f02b73b28e07a"}, + {file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"}, + {file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"}, ] [package.dependencies] -django = "*" -djangorestframework = "*" -pyjwt = ">=2,<3" +django = ">=3.2" +djangorestframework = ">=3.12" +pyjwt = ">=1.7.1,<3" [package.extras] -dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.0.0)", "sphinx-rtd-theme (>=0.1.9)", "tox", "twine", "wheel"] -doc = ["Sphinx (>=1.6.5,<2)", "sphinx-rtd-theme (>=0.1.9)"] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] lint = ["flake8", "isort", "pep8"] -python-jose = ["python-jose (==3.0.0)"] -test = ["cryptography", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] [[package]] name = "djoser" -version = "2.1.0" +version = "2.2.2" description = "REST implementation of Django authentication system." optional = false -python-versions = ">=3.6.1,<4.0.0" +python-versions = ">=3.8,<4.0" files = [ - {file = "djoser-2.1.0-py3-none-any.whl", hash = "sha256:9590378d59eb3243572bcb6b0a45268a3e31bedddc15235ca248a18c7bc0ffe6"}, - {file = "djoser-2.1.0.tar.gz", hash = "sha256:3299073aa5822f9ad02bc872b87e719051c07d36cdc87a05b2afdb2c3bad46d1"}, + {file = "djoser-2.2.2-py3-none-any.whl", hash = "sha256:efb91ad61e4d5b8d664db029b5947df9d34078289ef2680a1ab665e047144b74"}, + {file = "djoser-2.2.2.tar.gz", hash = "sha256:9deb831a1c8781ceff325699e1407b4e1be8b4588e87071621d88ba31c09349f"}, ] [package.dependencies] -asgiref = ">=3.2.10,<4.0.0" -coreapi = ">=2.3.3,<3.0.0" +django = ">=3.0.0" django-templated-mail = ">=1.1.1,<2.0.0" -djangorestframework-simplejwt = ">=4.3.0,<5.0.0" -social-auth-app-django = ">=4.0.0,<5.0.0" +djangorestframework-simplejwt = ">=5.0,<6.0" +social-auth-app-django = ">=5.0.0,<6.0.0" [package.extras] -test = ["codecov (>=2.0.16,<3.0.0)", "coverage (>=5.3,<6.0)", "djet (>=0.2.2,<0.3.0)", "pytest (>=6.0.2,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-django (>=3.10.0,<4.0.0)", "pytest-pythonpath (>=0.7.3,<0.8.0)"] +djet = ["djet (>=0.3.0,<0.4.0)"] +webauthn = ["webauthn (<1.0)"] [[package]] name = "dnspython" @@ -1893,17 +1844,6 @@ qtconsole = ["qtconsole"] test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] -[[package]] -name = "itypes" -version = "1.2.0" -description = "Simple immutable types for python." -optional = false -python-versions = "*" -files = [ - {file = "itypes-1.2.0-py2.py3-none-any.whl", hash = "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6"}, - {file = "itypes-1.2.0.tar.gz", hash = "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1"}, -] - [[package]] name = "jedi" version = "0.19.1" @@ -1923,23 +1863,6 @@ docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alab qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] -[[package]] -name = "jinja2" -version = "3.1.3" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - [[package]] name = "jinxed" version = "1.2.1" @@ -2028,167 +1951,169 @@ files = [ pytz = ">=2020.1" six = "*" +[[package]] +name = "laces" +version = "0.1.1" +description = "Django components that know how to render themselves." +optional = false +python-versions = ">=3.8" +files = [ + {file = "laces-0.1.1-py3-none-any.whl", hash = "sha256:ae2c575b9aaa46154e5518c61c9f86f5a9478f753a51e9c5547c7d275d361242"}, + {file = "laces-0.1.1.tar.gz", hash = "sha256:e45159c46f6adca33010d34e9af869e57201b70675c6dc088e919b16c89456a4"}, +] + +[package.dependencies] +Django = ">=3.2" + +[package.extras] +dev = ["black (==24.1.1)", "blacken-docs (==1.16.0)", "coverage (==7.3.4)", "django-stubs[compatible-mypy] (==4.2.7)", "flake8 (==7.0.0)", "flake8-bugbear", "flake8-comprehensions", "isort (==5.13.2)", "mypy (==1.7.1)", "pre-commit (==3.4.0)", "tox (==4.12.1)", "tox-gh-actions (==3.2.0)", "types-requests (==2.31.0.20240125)", "virtualenv-pyenv (==0.4.0)"] +testing = ["coverage (==7.3.4)", "dj-database-url (==2.1.0)"] + [[package]] name = "lxml" -version = "5.2.1" +version = "5.3.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=3.6" files = [ - {file = "lxml-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1f7785f4f789fdb522729ae465adcaa099e2a3441519df750ebdccc481d961a1"}, - {file = "lxml-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cc6ee342fb7fa2471bd9b6d6fdfc78925a697bf5c2bcd0a302e98b0d35bfad3"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:794f04eec78f1d0e35d9e0c36cbbb22e42d370dda1609fb03bcd7aeb458c6377"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817d420c60a5183953c783b0547d9eb43b7b344a2c46f69513d5952a78cddf3"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2213afee476546a7f37c7a9b4ad4d74b1e112a6fafffc9185d6d21f043128c81"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b070bbe8d3f0f6147689bed981d19bbb33070225373338df755a46893528104a"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e02c5175f63effbd7c5e590399c118d5db6183bbfe8e0d118bdb5c2d1b48d937"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3dc773b2861b37b41a6136e0b72a1a44689a9c4c101e0cddb6b854016acc0aa8"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:d7520db34088c96cc0e0a3ad51a4fd5b401f279ee112aa2b7f8f976d8582606d"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:bcbf4af004f98793a95355980764b3d80d47117678118a44a80b721c9913436a"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a2b44bec7adf3e9305ce6cbfa47a4395667e744097faed97abb4728748ba7d47"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1c5bb205e9212d0ebddf946bc07e73fa245c864a5f90f341d11ce7b0b854475d"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2c9d147f754b1b0e723e6afb7ba1566ecb162fe4ea657f53d2139bbf894d050a"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:3545039fa4779be2df51d6395e91a810f57122290864918b172d5dc7ca5bb433"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a91481dbcddf1736c98a80b122afa0f7296eeb80b72344d7f45dc9f781551f56"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2ddfe41ddc81f29a4c44c8ce239eda5ade4e7fc305fb7311759dd6229a080052"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7baf9ffc238e4bf401299f50e971a45bfcc10a785522541a6e3179c83eabf0a"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31e9a882013c2f6bd2f2c974241bf4ba68c85eba943648ce88936d23209a2e01"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a15438253b34e6362b2dc41475e7f80de76320f335e70c5528b7148cac253a1"}, - {file = "lxml-5.2.1-cp310-cp310-win32.whl", hash = "sha256:6992030d43b916407c9aa52e9673612ff39a575523c5f4cf72cdef75365709a5"}, - {file = "lxml-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:da052e7962ea2d5e5ef5bc0355d55007407087392cf465b7ad84ce5f3e25fe0f"}, - {file = "lxml-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:70ac664a48aa64e5e635ae5566f5227f2ab7f66a3990d67566d9907edcbbf867"}, - {file = "lxml-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1ae67b4e737cddc96c99461d2f75d218bdf7a0c3d3ad5604d1f5e7464a2f9ffe"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f18a5a84e16886898e51ab4b1d43acb3083c39b14c8caeb3589aabff0ee0b270"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6f2c8372b98208ce609c9e1d707f6918cc118fea4e2c754c9f0812c04ca116d"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:394ed3924d7a01b5bd9a0d9d946136e1c2f7b3dc337196d99e61740ed4bc6fe1"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d077bc40a1fe984e1a9931e801e42959a1e6598edc8a3223b061d30fbd26bbc"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:764b521b75701f60683500d8621841bec41a65eb739b8466000c6fdbc256c240"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3a6b45da02336895da82b9d472cd274b22dc27a5cea1d4b793874eead23dd14f"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:5ea7b6766ac2dfe4bcac8b8595107665a18ef01f8c8343f00710b85096d1b53a"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:e196a4ff48310ba62e53a8e0f97ca2bca83cdd2fe2934d8b5cb0df0a841b193a"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:200e63525948e325d6a13a76ba2911f927ad399ef64f57898cf7c74e69b71095"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dae0ed02f6b075426accbf6b2863c3d0a7eacc1b41fb40f2251d931e50188dad"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:ab31a88a651039a07a3ae327d68ebdd8bc589b16938c09ef3f32a4b809dc96ef"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:df2e6f546c4df14bc81f9498bbc007fbb87669f1bb707c6138878c46b06f6510"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5dd1537e7cc06efd81371f5d1a992bd5ab156b2b4f88834ca852de4a8ea523fa"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b9ec9c9978b708d488bec36b9e4c94d88fd12ccac3e62134a9d17ddba910ea9"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8e77c69d5892cb5ba71703c4057091e31ccf534bd7f129307a4d084d90d014b8"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8d5c70e04aac1eda5c829a26d1f75c6e5286c74743133d9f742cda8e53b9c2f"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c94e75445b00319c1fad60f3c98b09cd63fe1134a8a953dcd48989ef42318534"}, - {file = "lxml-5.2.1-cp311-cp311-win32.whl", hash = "sha256:4951e4f7a5680a2db62f7f4ab2f84617674d36d2d76a729b9a8be4b59b3659be"}, - {file = "lxml-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:5c670c0406bdc845b474b680b9a5456c561c65cf366f8db5a60154088c92d102"}, - {file = "lxml-5.2.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:abc25c3cab9ec7fcd299b9bcb3b8d4a1231877e425c650fa1c7576c5107ab851"}, - {file = "lxml-5.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6935bbf153f9a965f1e07c2649c0849d29832487c52bb4a5c5066031d8b44fd5"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d793bebb202a6000390a5390078e945bbb49855c29c7e4d56a85901326c3b5d9"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd5562927cdef7c4f5550374acbc117fd4ecc05b5007bdfa57cc5355864e0a4"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e7259016bc4345a31af861fdce942b77c99049d6c2107ca07dc2bba2435c1d9"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:530e7c04f72002d2f334d5257c8a51bf409db0316feee7c87e4385043be136af"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59689a75ba8d7ffca577aefd017d08d659d86ad4585ccc73e43edbfc7476781a"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f9737bf36262046213a28e789cc82d82c6ef19c85a0cf05e75c670a33342ac2c"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:3a74c4f27167cb95c1d4af1c0b59e88b7f3e0182138db2501c353555f7ec57f4"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:68a2610dbe138fa8c5826b3f6d98a7cfc29707b850ddcc3e21910a6fe51f6ca0"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f0a1bc63a465b6d72569a9bba9f2ef0334c4e03958e043da1920299100bc7c08"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c2d35a1d047efd68027817b32ab1586c1169e60ca02c65d428ae815b593e65d4"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:79bd05260359170f78b181b59ce871673ed01ba048deef4bf49a36ab3e72e80b"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:865bad62df277c04beed9478fe665b9ef63eb28fe026d5dedcb89b537d2e2ea6"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:44f6c7caff88d988db017b9b0e4ab04934f11e3e72d478031efc7edcac6c622f"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71e97313406ccf55d32cc98a533ee05c61e15d11b99215b237346171c179c0b0"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:057cdc6b86ab732cf361f8b4d8af87cf195a1f6dc5b0ff3de2dced242c2015e0"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f3bbbc998d42f8e561f347e798b85513ba4da324c2b3f9b7969e9c45b10f6169"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:491755202eb21a5e350dae00c6d9a17247769c64dcf62d8c788b5c135e179dc4"}, - {file = "lxml-5.2.1-cp312-cp312-win32.whl", hash = "sha256:8de8f9d6caa7f25b204fc861718815d41cbcf27ee8f028c89c882a0cf4ae4134"}, - {file = "lxml-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f2a9efc53d5b714b8df2b4b3e992accf8ce5bbdfe544d74d5c6766c9e1146a3a"}, - {file = "lxml-5.2.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:70a9768e1b9d79edca17890175ba915654ee1725975d69ab64813dd785a2bd5c"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c38d7b9a690b090de999835f0443d8aa93ce5f2064035dfc48f27f02b4afc3d0"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5670fb70a828663cc37552a2a85bf2ac38475572b0e9b91283dc09efb52c41d1"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:958244ad566c3ffc385f47dddde4145088a0ab893504b54b52c041987a8c1863"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6241d4eee5f89453307c2f2bfa03b50362052ca0af1efecf9fef9a41a22bb4f"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2a66bf12fbd4666dd023b6f51223aed3d9f3b40fef06ce404cb75bafd3d89536"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:9123716666e25b7b71c4e1789ec829ed18663152008b58544d95b008ed9e21e9"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:0c3f67e2aeda739d1cc0b1102c9a9129f7dc83901226cc24dd72ba275ced4218"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:5d5792e9b3fb8d16a19f46aa8208987cfeafe082363ee2745ea8b643d9cc5b45"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:88e22fc0a6684337d25c994381ed8a1580a6f5ebebd5ad41f89f663ff4ec2885"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:21c2e6b09565ba5b45ae161b438e033a86ad1736b8c838c766146eff8ceffff9"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:afbbdb120d1e78d2ba8064a68058001b871154cc57787031b645c9142b937a62"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:627402ad8dea044dde2eccde4370560a2b750ef894c9578e1d4f8ffd54000461"}, - {file = "lxml-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:e89580a581bf478d8dcb97d9cd011d567768e8bc4095f8557b21c4d4c5fea7d0"}, - {file = "lxml-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:59565f10607c244bc4c05c0c5fa0c190c990996e0c719d05deec7030c2aa8289"}, - {file = "lxml-5.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:857500f88b17a6479202ff5fe5f580fc3404922cd02ab3716197adf1ef628029"}, - {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56c22432809085b3f3ae04e6e7bdd36883d7258fcd90e53ba7b2e463efc7a6af"}, - {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a55ee573116ba208932e2d1a037cc4b10d2c1cb264ced2184d00b18ce585b2c0"}, - {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:6cf58416653c5901e12624e4013708b6e11142956e7f35e7a83f1ab02f3fe456"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:64c2baa7774bc22dd4474248ba16fe1a7f611c13ac6123408694d4cc93d66dbd"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:74b28c6334cca4dd704e8004cba1955af0b778cf449142e581e404bd211fb619"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:7221d49259aa1e5a8f00d3d28b1e0b76031655ca74bb287123ef56c3db92f213"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3dbe858ee582cbb2c6294dc85f55b5f19c918c2597855e950f34b660f1a5ede6"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:04ab5415bf6c86e0518d57240a96c4d1fcfc3cb370bb2ac2a732b67f579e5a04"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:6ab833e4735a7e5533711a6ea2df26459b96f9eec36d23f74cafe03631647c41"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f443cdef978430887ed55112b491f670bba6462cea7a7742ff8f14b7abb98d75"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9e2addd2d1866fe112bc6f80117bcc6bc25191c5ed1bfbcf9f1386a884252ae8"}, - {file = "lxml-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:f51969bac61441fd31f028d7b3b45962f3ecebf691a510495e5d2cd8c8092dbd"}, - {file = "lxml-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b58fbfa1bf7367dde8a557994e3b1637294be6cf2169810375caf8571a085c"}, - {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:804f74efe22b6a227306dd890eecc4f8c59ff25ca35f1f14e7482bbce96ef10b"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:08802f0c56ed150cc6885ae0788a321b73505d2263ee56dad84d200cab11c07a"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f8c09ed18ecb4ebf23e02b8e7a22a05d6411911e6fabef3a36e4f371f4f2585"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3d30321949861404323c50aebeb1943461a67cd51d4200ab02babc58bd06a86"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:b560e3aa4b1d49e0e6c847d72665384db35b2f5d45f8e6a5c0072e0283430533"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:058a1308914f20784c9f4674036527e7c04f7be6fb60f5d61353545aa7fcb739"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:adfb84ca6b87e06bc6b146dc7da7623395db1e31621c4785ad0658c5028b37d7"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:417d14450f06d51f363e41cace6488519038f940676ce9664b34ebf5653433a5"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a2dfe7e2473f9b59496247aad6e23b405ddf2e12ef0765677b0081c02d6c2c0b"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bf2e2458345d9bffb0d9ec16557d8858c9c88d2d11fed53998512504cd9df49b"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:58278b29cb89f3e43ff3e0c756abbd1518f3ee6adad9e35b51fb101c1c1daaec"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:64641a6068a16201366476731301441ce93457eb8452056f570133a6ceb15fca"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:78bfa756eab503673991bdcf464917ef7845a964903d3302c5f68417ecdc948c"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:11a04306fcba10cd9637e669fd73aa274c1c09ca64af79c041aa820ea992b637"}, - {file = "lxml-5.2.1-cp38-cp38-win32.whl", hash = "sha256:66bc5eb8a323ed9894f8fa0ee6cb3e3fb2403d99aee635078fd19a8bc7a5a5da"}, - {file = "lxml-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:9676bfc686fa6a3fa10cd4ae6b76cae8be26eb5ec6811d2a325636c460da1806"}, - {file = "lxml-5.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cf22b41fdae514ee2f1691b6c3cdeae666d8b7fa9434de445f12bbeee0cf48dd"}, - {file = "lxml-5.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec42088248c596dbd61d4ae8a5b004f97a4d91a9fd286f632e42e60b706718d7"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd53553ddad4a9c2f1f022756ae64abe16da1feb497edf4d9f87f99ec7cf86bd"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feaa45c0eae424d3e90d78823f3828e7dc42a42f21ed420db98da2c4ecf0a2cb"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddc678fb4c7e30cf830a2b5a8d869538bc55b28d6c68544d09c7d0d8f17694dc"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e074d4931dbcba7480d4dcab23d5c56bd9607f92825ab80ee2bd916edea53"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc4691d60512798304acb9207987e7b2b7c44627ea88b9d77489bbe3e6cc3bd4"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:beb72935a941965c52990f3a32d7f07ce869fe21c6af8b34bf6a277b33a345d3"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6588c459c5627fefa30139be4d2e28a2c2a1d0d1c265aad2ba1935a7863a4913"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:588008b8497667f1ddca7c99f2f85ce8511f8f7871b4a06ceede68ab62dff64b"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6787b643356111dfd4032b5bffe26d2f8331556ecb79e15dacb9275da02866e"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c17b64b0a6ef4e5affae6a3724010a7a66bda48a62cfe0674dabd46642e8b54"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:27aa20d45c2e0b8cd05da6d4759649170e8dfc4f4e5ef33a34d06f2d79075d57"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d4f2cc7060dc3646632d7f15fe68e2fa98f58e35dd5666cd525f3b35d3fed7f8"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff46d772d5f6f73564979cd77a4fffe55c916a05f3cb70e7c9c0590059fb29ef"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96323338e6c14e958d775700ec8a88346014a85e5de73ac7967db0367582049b"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:52421b41ac99e9d91934e4d0d0fe7da9f02bfa7536bb4431b4c05c906c8c6919"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7a7efd5b6d3e30d81ec68ab8a88252d7c7c6f13aaa875009fe3097eb4e30b84c"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ed777c1e8c99b63037b91f9d73a6aad20fd035d77ac84afcc205225f8f41188"}, - {file = "lxml-5.2.1-cp39-cp39-win32.whl", hash = "sha256:644df54d729ef810dcd0f7732e50e5ad1bd0a135278ed8d6bcb06f33b6b6f708"}, - {file = "lxml-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:9ca66b8e90daca431b7ca1408cae085d025326570e57749695d6a01454790e95"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9b0ff53900566bc6325ecde9181d89afadc59c5ffa39bddf084aaedfe3b06a11"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd6037392f2d57793ab98d9e26798f44b8b4da2f2464388588f48ac52c489ea1"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9c07e7a45bb64e21df4b6aa623cb8ba214dfb47d2027d90eac197329bb5e94"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3249cc2989d9090eeac5467e50e9ec2d40704fea9ab72f36b034ea34ee65ca98"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f42038016852ae51b4088b2862126535cc4fc85802bfe30dea3500fdfaf1864e"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:533658f8fbf056b70e434dff7e7aa611bcacb33e01f75de7f821810e48d1bb66"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:622020d4521e22fb371e15f580d153134bfb68d6a429d1342a25f051ec72df1c"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efa7b51824aa0ee957ccd5a741c73e6851de55f40d807f08069eb4c5a26b2baa"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c6ad0fbf105f6bcc9300c00010a2ffa44ea6f555df1a2ad95c88f5656104817"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e233db59c8f76630c512ab4a4daf5a5986da5c3d5b44b8e9fc742f2a24dbd460"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6a014510830df1475176466b6087fc0c08b47a36714823e58d8b8d7709132a96"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d38c8f50ecf57f0463399569aa388b232cf1a2ffb8f0a9a5412d0db57e054860"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5aea8212fb823e006b995c4dda533edcf98a893d941f173f6c9506126188860d"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff097ae562e637409b429a7ac958a20aab237a0378c42dabaa1e3abf2f896e5f"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f5d65c39f16717a47c36c756af0fb36144069c4718824b7533f803ecdf91138"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3d0c3dd24bb4605439bf91068598d00c6370684f8de4a67c2992683f6c309d6b"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e32be23d538753a8adb6c85bd539f5fd3b15cb987404327c569dfc5fd8366e85"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cc518cea79fd1e2f6c90baafa28906d4309d24f3a63e801d855e7424c5b34144"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a0af35bd8ebf84888373630f73f24e86bf016642fb8576fba49d3d6b560b7cbc"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8aca2e3a72f37bfc7b14ba96d4056244001ddcc18382bd0daa087fd2e68a354"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ca1e8188b26a819387b29c3895c47a5e618708fe6f787f3b1a471de2c4a94d9"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c8ba129e6d3b0136a0f50345b2cb3db53f6bda5dd8c7f5d83fbccba97fb5dcb5"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e998e304036198b4f6914e6a1e2b6f925208a20e2042563d9734881150c6c246"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d3be9b2076112e51b323bdf6d5a7f8a798de55fb8d95fcb64bd179460cdc0704"}, - {file = "lxml-5.2.1.tar.gz", hash = "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306"}, + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"}, + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"}, + {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"}, + {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"}, + {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"}, + {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"}, + {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"}, + {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"}, + {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"}, + {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"}, + {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"}, + {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"}, + {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"}, + {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"}, + {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"}, + {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"}, + {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"}, + {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"}, + {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"}, + {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"}, + {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"}, + {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"}, + {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"}, ] [package.extras] @@ -2196,76 +2121,7 @@ cssselect = ["cssselect (>=0.7)"] html-clean = ["lxml-html-clean"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.10)"] - -[[package]] -name = "markupsafe" -version = "2.1.5" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, -] +source = ["Cython (>=3.0.11)"] [[package]] name = "matplotlib-inline" @@ -2283,41 +2139,41 @@ traitlets = "*" [[package]] name = "mitol-django-authentication" -version = "1.6.0" +version = "2023.12.19" description = "MIT Open Learning django app extensions for oauth toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mitol-django-authentication-1.6.0.tar.gz", hash = "sha256:bc8ce5b408751b6bc946d2f38a5e2cc061f8a8e6d09d01346ef23b4b19a60d5d"}, - {file = "mitol_django_authentication-1.6.0-py3-none-any.whl", hash = "sha256:84a348d1465263622b0b77d29b2ce6139e66bc4dcf6e3b7aae23909df64c891b"}, + {file = "mitol-django-authentication-2023.12.19.tar.gz", hash = "sha256:67502ec0ec91f5be36affed52e872726d1d9f528a35f8c9fce2179409e516cb7"}, + {file = "mitol_django_authentication-2023.12.19-py3-none-any.whl", hash = "sha256:f925ff2e76cfd93e7610168fcb3cd6b2d0ece1772f0f3af0217c1417b8bd4f84"}, ] [package.dependencies] -django = ">=2.2.12,<4.0" +django = ">=3.0" django-anymail = ">=6.0" djangorestframework = ">=3.0.0" -djoser = "2.1.0" -mitol-django-common = ">=2.7.0,<2.8.0" -mitol-django-mail = ">=3.3.0,<3.4.0" -social-auth-app-django = ">=3.1.0" +djoser = "2.2.2" +mitol-django-common = "*" +mitol-django-mail = "*" +social-auth-app-django = ">=5.4.0" [[package]] name = "mitol-django-common" -version = "2.7.0" +version = "2023.12.19" description = "MIT Open Learning django app extensions for common utilities" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mitol-django-common-2.7.0.tar.gz", hash = "sha256:2ee8d2e0689eb512dbad031de784296ff5648c274ec5c5b89f228dd3576a9c8c"}, - {file = "mitol_django_common-2.7.0-py3-none-any.whl", hash = "sha256:1711cd6437e8b9b97af7853523796fc20cf81be3fa6dda3d4f2e021be4172da6"}, + {file = "mitol-django-common-2023.12.19.tar.gz", hash = "sha256:b7df5f57fcc06d822e7465470e2279ff4ee5c3b9a108513dc10a6979dd92fdda"}, + {file = "mitol_django_common-2023.12.19-py3-none-any.whl", hash = "sha256:5dd54a632dfdb5391287ac573758e19fa55e73e2c853eaaf60e5c8a4143e7776"}, ] [package.dependencies] -django = ">=2.2.12,<4.0" +django = ">=3.0" django-redis = ">=5.0.0,<5.1.0" django-webpack-loader = ">=0.7.0" factory-boy = ">=3.2,<4.0" -pytest = "6.1.2" +pytest = ">=7.0.0" pytz = ">=2020.4" requests = ">=2.20.0" setuptools = "*" @@ -2404,21 +2260,21 @@ urllib3 = ">=1.26.5" [[package]] name = "mitol-django-mail" -version = "3.3.0" +version = "2023.12.19" description = "MIT Open Learning django app extensions for mail" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mitol-django-mail-3.3.0.tar.gz", hash = "sha256:3b7caca7567abb8b7479086b3299cc2f48617936026a83b636e14236dfb48752"}, - {file = "mitol_django_mail-3.3.0-py3-none-any.whl", hash = "sha256:a28422b018487c9a844848417e7c593f794b210d0353e261ab6ecbb5d42f5408"}, + {file = "mitol-django-mail-2023.12.19.tar.gz", hash = "sha256:824f6ff0a7fb7b996962e608c61e5d02d06c174e466d00896eb43fdac864148f"}, + {file = "mitol_django_mail-2023.12.19-py3-none-any.whl", hash = "sha256:a32853bfe7da39d4c34651d7e5bddc547678c1b5b3a54e56e492879276eab371"}, ] [package.dependencies] beautifulsoup4 = ">=4.6.0" -django = ">=2.2.12,<4.0" +django = ">=3.0" django-anymail = ">=6.0" html5lib = ">=1.1" -mitol-django-common = ">=2.7.0,<2.8.0" +mitol-django-common = "*" premailer = ">=3.7.0" toolz = ">=0.10.0" @@ -2482,6 +2338,17 @@ files = [ {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"}, ] +[[package]] +name = "more-itertools" +version = "10.4.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.4.0.tar.gz", hash = "sha256:fe0e63c4ab068eac62410ab05cccca2dc71ec44ba8ef29916a0090df061cf923"}, + {file = "more_itertools-10.4.0-py3-none-any.whl", hash = "sha256:0f7d9f83a0a8dcfa8a2694a770590d98a67ea943e3d9f5298309a484758c4e27"}, +] + [[package]] name = "newrelic" version = "6.10.0.165" @@ -3245,28 +3112,27 @@ files = [ [[package]] name = "pytest" -version = "6.1.2" +version = "7.0.0" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, - {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, + {file = "pytest-7.0.0-py3-none-any.whl", hash = "sha256:42901e6bd4bd4a0e533358a86e848427a49005a3256f657c5c8f8dd35ef137a9"}, + {file = "pytest-7.0.0.tar.gz", hash = "sha256:dad48ffda394e5ad9aa3b7d7ddf339ed502e5e365b1350e0af65f4a602344b11"}, ] [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=17.4.0" +attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0" +pluggy = ">=0.12,<2.0" py = ">=1.8.2" -toml = "*" +tomli = ">=1.0.0" [package.extras] -checkqa-mypy = ["mypy (==0.780)"] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-cov" @@ -3690,19 +3556,18 @@ files = [ [[package]] name = "social-auth-app-django" -version = "4.0.0" +version = "5.4.2" description = "Python Social Authentication, Django integration." optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "social-auth-app-django-4.0.0.tar.gz", hash = "sha256:2c69e57df0b30c9c1823519c5f1992cbe4f3f98fdc7d95c840e091a752708840"}, - {file = "social_auth_app_django-4.0.0-py2-none-any.whl", hash = "sha256:df5212370bd250108987c4748419a1a1d0cec750878856c2644c36aaa0fd3e58"}, - {file = "social_auth_app_django-4.0.0-py3-none-any.whl", hash = "sha256:567ad0e028311541d7dfed51d3bf2c60440a6fd236d5d4d06c5a618b3d6c57c5"}, + {file = "social-auth-app-django-5.4.2.tar.gz", hash = "sha256:c8832c6cf13da6ad76f5613bcda2647d89ae7cfbc5217fadd13477a3406feaa8"}, + {file = "social_auth_app_django-5.4.2-py3-none-any.whl", hash = "sha256:0c041a31707921aef9a930f143183c65d8c7b364381364a50f3f7c6fcc9d62f6"}, ] [package.dependencies] -six = "*" -social-auth-core = ">=3.3.0" +Django = ">=3.2" +social-auth-core = ">=4.4.1" [[package]] name = "social-auth-core" @@ -3828,17 +3693,6 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -4039,37 +3893,37 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "wagtail" -version = "5.1.3" +version = "6.2.1" description = "A Django content management system." optional = false python-versions = ">=3.8" files = [ - {file = "wagtail-5.1.3-py3-none-any.whl", hash = "sha256:f4a6cc2adc40f687d8fb62fed2d4fc0804941dfa45b4624887f260dc6cf6b9f2"}, - {file = "wagtail-5.1.3.tar.gz", hash = "sha256:45fac794e4c20c7e75b018069d7f9761f25faa3619ef30df24013ca24abf9a74"}, + {file = "wagtail-6.2.1-py3-none-any.whl", hash = "sha256:31d073ea8acdc973ef45c5719977a1bb122ad0fc3f01348f37e922128200b42a"}, + {file = "wagtail-6.2.1.tar.gz", hash = "sha256:0f136ef23b157997a44fa46543a320a31437350951cf13add8ea8b69cc5e8385"}, ] [package.dependencies] anyascii = ">=0.1.5" -beautifulsoup4 = ">=4.8,<4.12" -Django = ">=3.2,<4.3" -django-filter = ">=2.2,<24" -django-modelcluster = ">=6.0,<7.0" +beautifulsoup4 = ">=4.8,<4.13" +Django = ">=4.2,<6.0" +django-filter = ">=23.3,<25" +django-modelcluster = ">=6.2.1,<7.0" django-permissionedforms = ">=0.1,<1.0" -django-taggit = ">=2.0,<5.0" +django-taggit = ">=5.0,<5.1" django-treebeard = ">=4.5.1,<5.0" -djangorestframework = ">=3.11.1,<4.0" -draftjs-exporter = ">=2.1.5,<3.0" -html5lib = ">=0.999,<2" +djangorestframework = ">=3.15.1,<4.0" +draftjs-exporter = ">=2.1.5,<6.0" l18n = ">=2018.5" +laces = ">=0.1,<0.2" openpyxl = ">=3.0.10,<4.0" Pillow = ">=9.1.0,<11.0.0" requests = ">=2.11.1,<3.0" -telepath = ">=0.1.1,<1" -Willow = {version = ">=1.6.2,<1.7", extras = ["heif"]} +telepath = ">=0.3.1,<1" +Willow = {version = ">=1.8.0,<2", extras = ["heif"]} [package.extras] -docs = ["Sphinx (>=1.5.2)", "myst-parser (==0.18.1)", "pyenchant (>=3.1.1,<4)", "sphinx-autobuild (>=0.6.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-wagtail-theme (==6.1.1)", "sphinxcontrib-spelling (>=5.4.0,<6)"] -testing = ["Jinja2 (>=3.0,<3.2)", "azure-mgmt-cdn (>=12.0,<13.0)", "azure-mgmt-frontdoor (>=1.0,<1.1)", "black (==22.3.0)", "boto3 (>=1.16,<1.17)", "coverage (>=3.7.0)", "curlylint (==0.13.1)", "django-pattern-library (>=0.7,<0.8)", "djhtml (==1.5.2)", "doc8 (==0.8.1)", "factory-boy (>=3.2)", "freezegun (>=0.3.8)", "polib (>=1.1,<2.0)", "python-dateutil (>=2.7)", "pytz (>=2014.7)", "ruff (==0.0.272)", "semgrep (==1.3.0)"] +docs = ["Sphinx (>=7.0)", "myst-parser (==2.0.0)", "pyenchant (>=3.1.1,<4)", "sphinx-autobuild (>=0.6.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-wagtail-theme (==6.3.0)", "sphinxcontrib-spelling (>=7,<8)"] +testing = ["Jinja2 (>=3.0,<3.2)", "azure-mgmt-cdn (>=12.0,<13.0)", "azure-mgmt-frontdoor (>=1.0,<1.1)", "boto3 (>=1.28,<2)", "coverage (>=3.7.0)", "curlylint (==0.13.1)", "django-pattern-library (>=0.7)", "djhtml (==3.0.6)", "doc8 (==0.8.1)", "factory-boy (>=3.2)", "freezegun (>=0.3.8)", "polib (>=1.1,<2.0)", "python-dateutil (>=2.7)", "pytz (>=2014.7)", "ruff (==0.1.5)", "semgrep (==1.40.0)", "tblib (>=2.0,<3.0)"] [[package]] name = "wagtail-factories" @@ -4128,13 +3982,13 @@ files = [ [[package]] name = "willow" -version = "1.6.3" +version = "1.8.0" description = "A Python image library that sits on top of Pillow, Wand and OpenCV" optional = false python-versions = ">=3.8" files = [ - {file = "willow-1.6.3-py3-none-any.whl", hash = "sha256:f4b17a16c6315864604dadb6cdf2987d0b685e295cca74c6da28b94167a3126e"}, - {file = "willow-1.6.3.tar.gz", hash = "sha256:143cefd30d3bb816cdff857c454da24991dda35a0315ea795101675e0b14262f"}, + {file = "willow-1.8.0-py3-none-any.whl", hash = "sha256:48ccf5ce48ccd29c37a32497cd7af50983f8570543c4de2988b15d583efc66be"}, + {file = "willow-1.8.0.tar.gz", hash = "sha256:ef3df6cde80d4914e719188147bef1d71c240edb118340e0c5957ecc8fe08315"}, ] [package.dependencies] @@ -4143,9 +3997,11 @@ filetype = ">=1.0.10,<1.1.0 || >1.1.0" pillow-heif = {version = ">=0.10.0,<1.0.0", optional = true, markers = "extra == \"heif\" and python_version < \"3.12\""} [package.extras] -docs = ["Sphinx (>=7.0)", "sphinx-wagtail-theme (==6.0.0)", "sphinx_copybutton (>=0.5)", "sphinxcontrib-spelling (>=8.0,<9.0)"] +docs = ["Sphinx (>=7.0)", "sphinx-wagtail-theme (>=6.1.1,<7.0)", "sphinx_copybutton (>=0.5)", "sphinxcontrib-spelling (>=8.0,<9.0)"] heif = ["pillow-heif (>=0.10.0,<1.0.0)", "pillow-heif (>=0.13.0,<1.0.0)"] -testing = ["Pillow (>=9.1.0,<11.0.0)", "Wand (>=0.6,<1.0)", "black (==22.3.0)", "coverage[toml] (>=7.2.7,<8.0)", "mock (>=3.0,<4.0)", "pillow-heif (>=0.10.0,<1.0.0)", "ruff (==0.0.275)"] +pillow = ["Pillow (>=9.1.0,<11.0.0)"] +testing = ["coverage[toml] (>=7.2.7,<8.0)", "pre-commit (>=3.4.0)", "willow[heif,pillow,wand]"] +wand = ["Wand (>=0.6,<1.0)"] [[package]] name = "wmctrl" @@ -4220,4 +4076,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "3.9.18" -content-hash = "bcef460938d1ab6d8be34d5f5d159ef8d3d46900023593500c5a26f667aac5d7" +content-hash = "c2b6c40a445724288257bb16afa6a2c1e503520d7b8662cb447fbd89207a2d38" diff --git a/pyproject.toml b/pyproject.toml index 215ad9d3d7..24b69bf4ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,20 +18,14 @@ celery = "^5.2.2" celery-redbeat = "^2.0.0" deepdiff = "^6.6.1" dj-database-url = "^0.5.0" -django = "^3.2.18" +django = "4.2" django-anymail = {extras = ["mailgun"], version = "^11.1"} django-cors-headers = "^3.11.0" django-countries = "^7.2.1" -django-filter = "^2.4.0" -django-fsm = "^2.8.0" -django-fsm-admin = "^1.2.4" -django-hijack = "^3.0.0" django-ipware = "^4.0.0" django-oauth-toolkit = "^1.7.0" django-redis = "^5.0.0" -django-reversion = "^5.0.0" -django-robots = "^4.0.0" -django-server-status = "^0.7.3" +django-robots = "6.1" django-storages = "^1.11.1" django-treebeard = "^4.5.1" django-user-agents = "^0.4.0" @@ -43,12 +37,9 @@ edx-api-client = "^1.9.0" hubspot-api-client = "^6.1.0" hypothesis = "4.23.9" ipython = "^8.0.0" -mitol-django-authentication = "1.6.0" -mitol-django-common = "2.7.0" mitol-django-google-sheets-deferrals = "2023.12.19" mitol-django-google-sheets-refunds = "2023.12.19" mitol-django-hubspot-api = "2023.12.19" -mitol-django-mail = "3.3.0" mitol-django-openedx = "2023.12.19" mitol-django-payment-gateway = "2023.12.19" newrelic = "^6.4.1.158" @@ -59,15 +50,22 @@ pycountry = "^19.7.15" redis = "^4.4.4" requests = "^2.28.2" sentry-sdk = "^2.0.0" -social-auth-app-django = "^4.0.0" +social-auth-app-django = "5.4.2" ulid-py = "^1.1.0" user-util = "^0.1.5" uwsgi = "^2.0.19" uwsgitop = "^0.12" -wagtail = "^5.0" wagtail-metadata = "^5.0.0" mitol-django-olposthog = "^2024.5.14" mitol-django-google-sheets = "^2024.7.3" +mitol-django-mail = "2023.12.19" +mitol-django-common = "2023.12.19" +mitol-django-authentication = "2023.12.19" +django-hijack = "^3.6.0" +django-viewflow = "^2.2.7" +django-reversion = "^5.1.0" +django-filter = "^24.3" +wagtail = "^6.2.1" [tool.poetry.group.dev.dependencies] @@ -82,7 +80,7 @@ ipdb = "^0.13.13" nplusone = "^1.0.0" pdbpp = "^0.10.3" pre-commit = "^3.7.0" -pytest = "^6.1.2" +pytest = "7.0.0" pytest-cov = "^4.1.0" pytest-django = "^4.5.2" pytest-env = "^0.6.2" diff --git a/users/admin.py b/users/admin.py index becb7a3d83..a06081b558 100644 --- a/users/admin.py +++ b/users/admin.py @@ -51,6 +51,7 @@ def has_delete_permission(self, request, obj=None): # noqa: ARG002 """ +@admin.register(User) class UserAdmin(ContribUserAdmin, HijackUserAdminMixin, TimestampedModelAdmin): """Admin views for user""" @@ -97,6 +98,7 @@ class UserAdmin(ContribUserAdmin, HijackUserAdminMixin, TimestampedModelAdmin): inlines = [UserLegalAddressInline, UserProfileInline] +@admin.register(BlockList) class BlockListAdmin(admin.ModelAdmin): """Admin for BlockList""" @@ -105,7 +107,3 @@ class BlockListAdmin(admin.ModelAdmin): def has_add_permission(self, request): # noqa: ARG002 return False - - -admin.site.register(User, UserAdmin) -admin.site.register(BlockList, BlockListAdmin) diff --git a/users/migrations/0024_rename_changeemailrequest_expires_on_confirmed_code_users_chang_expires_dbd4e5_idx.py b/users/migrations/0024_rename_changeemailrequest_expires_on_confirmed_code_users_chang_expires_dbd4e5_idx.py new file mode 100644 index 0000000000..cd36d36bed --- /dev/null +++ b/users/migrations/0024_rename_changeemailrequest_expires_on_confirmed_code_users_chang_expires_dbd4e5_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2 on 2024-09-05 15:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0023_user_hubspot_sync_datetime"), + ] + + operations = [ + migrations.RenameIndex( + model_name="changeemailrequest", + new_name="users_chang_expires_dbd4e5_idx", + old_fields=("expires_on", "confirmed", "code"), + ), + ] diff --git a/users/models.py b/users/models.py index b9811d546e..2214137427 100644 --- a/users/models.py +++ b/users/models.py @@ -308,7 +308,7 @@ class ChangeEmailRequest(TimestampedModel): expires_on = models.DateTimeField(default=generate_change_email_expires) class Meta: - index_together = ("expires_on", "confirmed", "code") + indexes = [models.Index(fields=("expires_on", "confirmed", "code"))] def validate_iso_3166_1_code(value): diff --git a/users/urls.py b/users/urls.py index 030f04aa51..d17fada9fe 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,7 +1,6 @@ """User url routes""" -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from rest_framework import routers from users.views import ( diff --git a/wget-log b/wget-log new file mode 100644 index 0000000000..e69de29bb2