From e170a4e71366436e75a0fc8921165e0d177acfd4 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Mon, 27 Nov 2023 12:28:12 -0500 Subject: [PATCH] feat: allow redemption at requested prices Assignments (in enterprise-access) contain a field that represents the price of the content at the time of assignment. We need to allow for that price to be passed through to the transaction creation view. ENT-8035 --- enterprise_subsidy/apps/api/exceptions.py | 1 + enterprise_subsidy/apps/api/v1/serializers.py | 22 ++- .../api/v2/tests/test_transaction_views.py | 81 ++++++++++- .../apps/api/v2/views/transaction.py | 21 ++- enterprise_subsidy/apps/subsidy/models.py | 58 +++++++- .../apps/subsidy/tests/test_models.py | 134 +++++++++++++++++- enterprise_subsidy/settings/base.py | 8 ++ pylintrc | 8 +- pylintrc_tweaks | 1 + 9 files changed, 318 insertions(+), 16 deletions(-) diff --git a/enterprise_subsidy/apps/api/exceptions.py b/enterprise_subsidy/apps/api/exceptions.py index 82f27360..f51077b6 100644 --- a/enterprise_subsidy/apps/api/exceptions.py +++ b/enterprise_subsidy/apps/api/exceptions.py @@ -11,6 +11,7 @@ class ErrorCodes: """ ENROLLMENT_ERROR = 'enrollment_error' CONTENT_NOT_FOUND = 'content_not_found' + INVALID_REQUESTED_PRICE = 'invalid_requested_price' TRANSACTION_CREATION_ERROR = 'transaction_creation_error' LEDGER_LOCK_ERROR = 'ledger_lock_error' INACTIVE_SUBSIDY_CREATION_ERROR = 'inactive_subsidy_creation_error' diff --git a/enterprise_subsidy/apps/api/v1/serializers.py b/enterprise_subsidy/apps/api/v1/serializers.py index f8e3c1d7..33358a5f 100644 --- a/enterprise_subsidy/apps/api/v1/serializers.py +++ b/enterprise_subsidy/apps/api/v1/serializers.py @@ -12,7 +12,12 @@ from rest_framework import serializers from enterprise_subsidy.apps.fulfillment.api import FulfillmentException -from enterprise_subsidy.apps.subsidy.models import ContentNotFoundForCustomerException, RevenueCategoryChoices, Subsidy +from enterprise_subsidy.apps.subsidy.models import ( + ContentNotFoundForCustomerException, + PriceValidationError, + RevenueCategoryChoices, + Subsidy +) logger = getLogger(__name__) @@ -194,6 +199,14 @@ class TransactionCreationRequestSerializer(serializers.ModelSerializer): required=False, allow_null=True, ) + requested_price_cents = serializers.IntegerField( + required=False, + allow_null=True, + help_text=( + 'The price, in USD cents, at which the caller requests the redemption be made. Must be >= 0.' + ), + min_value=0, + ) class Meta: """ @@ -206,6 +219,7 @@ class Meta: 'content_key', 'subsidy_access_policy_uuid', 'metadata', + 'requested_price_cents', ] # Override lms_user_id, content_key, and subsidy_access_policy_uuid to each be required; # their model field definitions have `required=False`. @@ -247,6 +261,7 @@ def create(self, validated_data): validated_data['content_key'], validated_data['subsidy_access_policy_uuid'], idempotency_key=validated_data.get('idempotency_key'), + requested_price_cents=validated_data.get('requested_price_cents'), metadata=validated_data.get('metadata'), ) except LedgerLockAttemptFailed as exc: @@ -263,6 +278,11 @@ def create(self, validated_data): f'in subsidy {subsidy.uuid}' ) raise exc + except PriceValidationError as exc: + logger.error( + f'Invalid price requested for {validated_data} in subsidy {subsidy.uuid}' + ) + raise exc except FulfillmentException as exc: logger.error( f'Error fulfilling transactions for {validated_data} in subsidy {subsidy.uuid}' diff --git a/enterprise_subsidy/apps/api/v2/tests/test_transaction_views.py b/enterprise_subsidy/apps/api/v2/tests/test_transaction_views.py index ef1bdeb3..72696cf6 100644 --- a/enterprise_subsidy/apps/api/v2/tests/test_transaction_views.py +++ b/enterprise_subsidy/apps/api/v2/tests/test_transaction_views.py @@ -704,6 +704,7 @@ def test_operator_creation_with_lock_failure_gets_429(self): self.content_key_2, uuid.UUID(self.subsidy_access_policy_1_uuid), idempotency_key='my-idempotency-key', + requested_price_cents=None, metadata=None, ) assert response.json() == {'detail': 'Attempt to lock the Ledger failed, please try again.'} @@ -755,6 +756,7 @@ def test_operator_creation_expected_422_errors(self, exception_to_raise, expecte self.content_key_2, uuid.UUID(self.subsidy_access_policy_1_uuid), idempotency_key='my-idempotency-key', + requested_price_cents=None, metadata=None, ) assert response.json() == { @@ -762,6 +764,65 @@ def test_operator_creation_expected_422_errors(self, exception_to_raise, expecte 'code': expected_error_code, } + @mock.patch("enterprise_subsidy.apps.subsidy.models.Subsidy.enterprise_client") + @mock.patch("enterprise_subsidy.apps.subsidy.models.Subsidy.price_for_content") + @mock.patch("enterprise_subsidy.apps.content_metadata.api.ContentMetadataApi.get_content_summary") + @mock.patch("enterprise_subsidy.apps.subsidy.models.Subsidy.lms_user_client") + def test_operator_creation_requested_price_invalid( + self, + mock_lms_user_client, + mock_get_content_summary, + mock_price_for_content, + mock_enterprise_client + ): + """ + Tests that the admin transaction creation endpoint responds with a 422 + when creating a transaction for an invalid requested price. + """ + self.set_up_operator() + + canonical_price_cents = 1000 + # request only half of the canonical price, which falls outside default allowable interval + requested_price_cents = 500 + mock_lms_user_client.return_value.best_effort_user_data.return_value = { + 'email': self.lms_user_email, + } + mock_enterprise_client.enroll.return_value = 'my-fulfillment-id' + mock_price_for_content.return_value = canonical_price_cents + mock_get_content_summary.return_value = { + 'content_uuid': self.content_key_1, + 'content_key': self.content_key_1, + 'content_title': self.content_title_1, + 'source': 'edX', + 'mode': 'verified', + 'content_price': canonical_price_cents, + 'geag_variant_id': None, + } + url = reverse("api:v2:transaction-admin-list-create", args=[self.subsidy_1.uuid]) + # use the same inputs as existing_transaction + request_data = { + 'lms_user_id': STATIC_LMS_USER_ID, + 'content_key': self.content_key_1, + 'subsidy_access_policy_uuid': self.subsidy_access_policy_1_uuid, + 'idempotency_key': 'my-idempotency-key', + 'requested_price_cents': requested_price_cents, + 'metadata': { + 'foo': 'bar', + }, + } + + response = self.client.post(url, request_data) + + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + expected_error_detail = [ + f'Requested price {requested_price_cents} for {self.content_key_1} outside of ' + f'acceptable interval on canonical course price of {canonical_price_cents}.' + ] + assert response.json() == { + 'detail': str(expected_error_detail), + 'code': ErrorCodes.INVALID_REQUESTED_PRICE, + } + def test_operator_creation_required_fields_validation_eror(self): """ Tests that an authenticated operator receives a 400 response @@ -770,14 +831,19 @@ def test_operator_creation_required_fields_validation_eror(self): self.set_up_operator() url = reverse("api:v2:transaction-admin-list-create", args=[self.subsidy_1.uuid]) + payload = { + 'anything': 'goes', + 'requested_price_cents': -100, + } - response = self.client.post(url, {'anything': 'goes'}) + response = self.client.post(url, payload) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == { 'content_key': ['This field is required.'], 'lms_user_id': ['This field is required.'], 'subsidy_access_policy_uuid': ['This field is required.'], + 'requested_price_cents': ['Ensure this value is greater than or equal to 0.'], } @mock.patch("enterprise_subsidy.apps.subsidy.models.Subsidy.enterprise_client") @@ -832,8 +898,10 @@ def test_operator_creation_happy_path_transaction_exists(self, mock_price_for_co @mock.patch("enterprise_subsidy.apps.subsidy.models.Subsidy.price_for_content") @mock.patch("enterprise_subsidy.apps.content_metadata.api.ContentMetadataApi.get_content_summary") @mock.patch("enterprise_subsidy.apps.subsidy.models.Subsidy.lms_user_client") + @ddt.data(True, False) def test_operator_creation_happy_path_201( self, + use_requested_price, mock_lms_user_client, mock_get_content_summary, mock_price_for_content, @@ -845,18 +913,20 @@ def test_operator_creation_happy_path_201( """ self.set_up_operator() + canonical_price_cents = 1000 + requested_price_cents = 900 # only in use if use_requested_price is True mock_lms_user_client.return_value.best_effort_user_data.return_value = { 'email': self.lms_user_email, } mock_enterprise_client.enroll.return_value = 'my-fulfillment-id' - mock_price_for_content.return_value = 1000 + mock_price_for_content.return_value = canonical_price_cents mock_get_content_summary.return_value = { 'content_uuid': self.content_key_1, 'content_key': self.content_key_1, 'content_title': self.content_title_1, 'source': 'edX', 'mode': 'verified', - 'content_price': 10000, + 'content_price': canonical_price_cents, 'geag_variant_id': None, } url = reverse("api:v2:transaction-admin-list-create", args=[self.subsidy_1.uuid]) @@ -870,6 +940,8 @@ def test_operator_creation_happy_path_201( 'foo': 'bar', }, } + if use_requested_price: + request_data['requested_price_cents'] = requested_price_cents response = self.client.post(url, request_data) @@ -891,7 +963,8 @@ def test_operator_creation_happy_path_201( assert response_data["subsidy_access_policy_uuid"] == request_data["subsidy_access_policy_uuid"] assert response_data["metadata"] == {'foo': 'bar'} assert response_data["unit"] == self.subsidy_1.ledger.unit - assert response_data["quantity"] == -1000 assert response_data["fulfillment_identifier"] == 'my-fulfillment-id' assert response_data["reversal"] is None assert response_data["state"] == TransactionStateChoices.COMMITTED + expected_quantity = -1 * (requested_price_cents if use_requested_price else canonical_price_cents) + assert response_data["quantity"] == expected_quantity diff --git a/enterprise_subsidy/apps/api/v2/views/transaction.py b/enterprise_subsidy/apps/api/v2/views/transaction.py index 8c2816fd..6f4c100d 100644 --- a/enterprise_subsidy/apps/api/v2/views/transaction.py +++ b/enterprise_subsidy/apps/api/v2/views/transaction.py @@ -30,7 +30,7 @@ PERMISSION_CAN_READ_ALL_TRANSACTIONS, PERMISSION_CAN_READ_TRANSACTIONS ) -from enterprise_subsidy.apps.subsidy.models import ContentNotFoundForCustomerException, Subsidy +from enterprise_subsidy.apps.subsidy.models import ContentNotFoundForCustomerException, PriceValidationError, Subsidy logger = logging.getLogger(__name__) @@ -156,8 +156,20 @@ def list(self, request, subsidy_uuid): def post(self, *args, **kwargs): """ A create view that is accessible only to operators of the system. + It creates (or just gets, if a matching Transaction is found with same ledger and idempotency_key) a - transaction via the `Subsidy.redeem()` method. + transaction via the `Subsidy.redeem()` method. Normally, the logic of this view + is responsible for determining the price of the requested content key, with which + the redeemed transaction's quantity will be valued. + + Note that, under some circumstances (for example, assigned learner content), it is + appropriate and allowable for the *caller* of this view to request a specific price + at which a redeemed transaction should occur. In these circumstances, this service + still does some validation of the requested price to ensure that it falls within + a reasonable interval around the *true* price of the related content key. See: + + https://github.com/openedx/enterprise-access/blob/main/docs/decisions/0012-assignment-based-policies.rst + https://github.com/openedx/enterprise-access/blob/main/docs/decisions/0014-assignment-price-validation.rst """ return super().post(*args, **kwargs) @@ -191,6 +203,11 @@ def create(self, request, subsidy_uuid): detail=str(exc), code=ErrorCodes.CONTENT_NOT_FOUND, ) + except PriceValidationError as exc: + raise TransactionCreationAPIException( + detail=str(exc), + code=ErrorCodes.INVALID_REQUESTED_PRICE, + ) except FulfillmentException as exc: raise TransactionCreationAPIException( detail=str(exc), diff --git a/enterprise_subsidy/apps/subsidy/models.py b/enterprise_subsidy/apps/subsidy/models.py index 2ae374a8..595d50f4 100644 --- a/enterprise_subsidy/apps/subsidy/models.py +++ b/enterprise_subsidy/apps/subsidy/models.py @@ -13,6 +13,7 @@ from unittest import mock from uuid import uuid4 +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils.functional import cached_property @@ -50,6 +51,13 @@ class ContentNotFoundForCustomerException(Exception): """ +class PriceValidationError(ValidationError): + """ + Raised in cases related to requested prices, when the requested price + fails our validation checks. + """ + + class SubsidyReferenceChoices: """ Enumerate different choices for the type of object that the subsidy's reference_id points to. This is the type of @@ -391,7 +399,15 @@ def rollback_transaction(self, ledger_transaction): ledger_transaction.state = TransactionStateChoices.FAILED ledger_transaction.save() - def redeem(self, lms_user_id, content_key, subsidy_access_policy_uuid, idempotency_key=None, metadata=None): + def redeem( + self, + lms_user_id, + content_key, + subsidy_access_policy_uuid, + idempotency_key=None, + requested_price_cents=None, + metadata=None, + ): """ Redeem this subsidy and enroll the learner. @@ -414,7 +430,7 @@ def redeem(self, lms_user_id, content_key, subsidy_access_policy_uuid, idempoten if existing_transaction := self.get_committed_transaction_no_reversal(lms_user_id, content_key): return (existing_transaction, False) - is_redeemable, content_price = self.is_redeemable(content_key) + is_redeemable, content_price = self.is_redeemable(content_key, requested_price_cents) base_exception_msg = ( f'{self} cannot redeem {content_key} with price {content_price} ' @@ -430,6 +446,7 @@ def redeem(self, lms_user_id, content_key, subsidy_access_policy_uuid, idempoten transaction = self._create_redemption( lms_user_id, content_key, + content_price, subsidy_access_policy_uuid, lms_user_email=lms_user_email, content_title=content_title, @@ -459,6 +476,7 @@ def _create_redemption( self, lms_user_id, content_key, + content_price, subsidy_access_policy_uuid, content_title=None, lms_user_email=None, @@ -499,7 +517,7 @@ def _create_redemption( All other exceptions raised during the creation of an enrollment. This should have already triggered the rollback of a pending transaction. """ - quantity = -1 * self.price_for_content(content_key) + quantity = -1 * content_price if not idempotency_key: idempotency_key = create_idempotency_key_for_transaction( self.ledger, @@ -549,7 +567,25 @@ def _create_redemption( return ledger_transaction - def is_redeemable(self, content_key): + def validate_requested_price(self, content_key, requested_price_cents, canonical_price_cents): + """ + Validates that the requested redemption price (in USD cents) + is within some acceptable error bound interval. + """ + if requested_price_cents < 0: + raise PriceValidationError('Can only redeem non-negative content prices in cents.') + + lower_bound = settings.ALLOCATION_PRICE_VALIDATION_LOWER_BOUND_RATIO * canonical_price_cents + upper_bound = settings.ALLOCATION_PRICE_VALIDATION_UPPER_BOUND_RATIO * canonical_price_cents + if not (lower_bound <= requested_price_cents <= upper_bound): + raise PriceValidationError( + f'Requested price {requested_price_cents} for {content_key} ' + f'outside of acceptable interval on canonical course price of {canonical_price_cents}.' + ) + + return requested_price_cents + + def is_redeemable(self, content_key, requested_price_cents=None): """ Check if this subsidy is redeemable (by anyone) at a given time. @@ -558,11 +594,23 @@ def is_redeemable(self, content_key): Args: content_key (str): content key of content we may try to redeem. redemption_datetime (datetime.datetime): The point in time to check for redemability. + requested_price_cents (int): An optional "override" price for the given content. + If present, we'll compare this quantity against the current balance, + instead of the price read from our catalog service. An override *must* + be within some reasonable bound of the real price. Returns: 2-tuple of (bool: True if redeemable, int: price of content) """ - content_price = self.price_for_content(content_key) + canonical_price_cents = self.price_for_content(content_key) + content_price = canonical_price_cents + if requested_price_cents: + content_price = self.validate_requested_price( + content_key, + requested_price_cents, + canonical_price_cents, + ) + redeemable = False if content_price is not None: redeemable = self.current_balance() >= content_price diff --git a/enterprise_subsidy/apps/subsidy/tests/test_models.py b/enterprise_subsidy/apps/subsidy/tests/test_models.py index 17714daf..7532c02e 100644 --- a/enterprise_subsidy/apps/subsidy/tests/test_models.py +++ b/enterprise_subsidy/apps/subsidy/tests/test_models.py @@ -22,7 +22,7 @@ from enterprise_subsidy.apps.fulfillment.api import InvalidFulfillmentMetadataException from test_utils.utils import MockResponse -from ..models import ContentNotFoundForCustomerException, Subsidy +from ..models import ContentNotFoundForCustomerException, PriceValidationError, Subsidy from .factories import SubsidyFactory @@ -46,6 +46,10 @@ def setUpTestData(cls): cls.subsidy.content_metadata_api = mock.MagicMock() super().setUpTestData() + def tearDown(self): + super().tearDown() + self.subsidy.content_metadata_api.reset_mock() + def test_price_for_content(self): """ Tests that Subsidy.price_for_content returns the price of a piece @@ -187,6 +191,62 @@ def test_is_redeemable(self, expected_to_be_redeemable): self.assertEqual(is_redeemable, expected_to_be_redeemable) self.assertEqual(content_price, actual_content_price) + @ddt.data(True, False) + def test_is_redeemable_override(self, expected_to_be_redeemable): + """ + Tests that Subsidy.is_redeemable() returns true when the subsidy + has enough remaining balance to cover a requested redemption price from the caller, + and false otherwise. + """ + # Mock the override price to be slightly too expensive if + # expected_to_be_redeemable is false; + # mock it to be slightly affordable if true. + constant = -123 if expected_to_be_redeemable else 123 + canonical_content_price = self.subsidy.current_balance() + constant + requested_price = canonical_content_price - 10 + + self.subsidy.content_metadata_api().get_course_price.return_value = canonical_content_price + + is_redeemable, price_for_redemption = self.subsidy.is_redeemable('some-content-key', requested_price) + + self.assertEqual(is_redeemable, expected_to_be_redeemable) + self.assertEqual(requested_price, price_for_redemption) + self.subsidy.content_metadata_api().get_course_price.assert_called_once_with( + self.subsidy.enterprise_customer_uuid, + 'some-content-key', + ) + + def test_validate_requested_price_lt_zero(self): + """ + Requested price validation should fail for requested prices < 0. + """ + with self.assertRaisesRegex(PriceValidationError, 'non-negative'): + self.subsidy.validate_requested_price('content-key', -1, 100) + + def test_validate_requested_price_too_high(self): + """ + Requested price validation should fail for requested prices that are too high + """ + with self.assertRaisesRegex(PriceValidationError, 'outside of acceptable interval'): + self.subsidy.validate_requested_price('content-key', 121, 100) + + def test_validate_requested_price_too_low(self): + """ + Requested price validation should fail for requested prices that are too low + """ + with self.assertRaisesRegex(PriceValidationError, 'outside of acceptable interval'): + self.subsidy.validate_requested_price('content-key', 79, 100) + + @ddt.data(80, 120) # these numbers align exactly to the validation thresholds defined in base settings + def test_validate_requested_price_just_right(self, requested_price_cents): + """ + Requested price validation should not fail on requested prices that are just right. + """ + self.assertEqual( + self.subsidy.validate_requested_price('content-key', requested_price_cents, 100), + requested_price_cents, + ) + class SubsidyModelRedemptionTestCase(TestCase): """ @@ -287,6 +347,78 @@ def test_redeem_not_existing(self, mock_get_content_summary, mock_enterprise_cli assert new_transaction.state == TransactionStateChoices.COMMITTED assert new_transaction.quantity == -mock_content_price + @mock.patch('enterprise_subsidy.apps.subsidy.models.Subsidy.price_for_content') + @mock.patch('enterprise_subsidy.apps.subsidy.models.Subsidy.enterprise_client') + @mock.patch("enterprise_subsidy.apps.content_metadata.api.ContentMetadataApi.get_content_summary") + def test_redeem_with_requested_price( + self, mock_get_content_summary, mock_enterprise_client, mock_price_for_content + ): + """ + Test Subsidy.redeem() happy path with an acceptable requested price. + """ + lms_user_id = 1 + content_key = "course-v1:edX+test+course" + subsidy_access_policy_uuid = str(uuid4()) + mock_enterprise_fulfillment_uuid = str(uuid4()) + mock_content_price = 1000 + mock_get_content_summary.return_value = { + 'content_uuid': 'course-v1:edX+test+course', + 'content_key': 'course-v1:edX+test+course', + 'source': 'edX', + 'mode': 'verified', + 'content_price': 1000, + 'geag_variant_id': None, + } + mock_price_for_content.return_value = mock_content_price + mock_enterprise_client.enroll.return_value = mock_enterprise_fulfillment_uuid + new_transaction, transaction_created = self.subsidy.redeem( + lms_user_id, + content_key, + subsidy_access_policy_uuid, + requested_price_cents=990, + ) + assert transaction_created + assert new_transaction.state == TransactionStateChoices.COMMITTED + assert new_transaction.quantity == -990 + + @mock.patch('enterprise_subsidy.apps.subsidy.models.Subsidy.price_for_content') + @mock.patch('enterprise_subsidy.apps.subsidy.models.Subsidy.enterprise_client') + @mock.patch("enterprise_subsidy.apps.content_metadata.api.ContentMetadataApi.get_content_summary") + def test_redeem_with_requested_price_validation_error( + self, mock_get_content_summary, mock_enterprise_client, mock_price_for_content + ): + """ + Test Subsidy.redeem() with an unacceptable requested price. + """ + lms_user_id = 1 + content_key = "course-v1:edX+test+course" + subsidy_access_policy_uuid = str(uuid4()) + mock_enterprise_fulfillment_uuid = str(uuid4()) + mock_content_price = 1000 + mock_get_content_summary.return_value = { + 'content_uuid': 'course-v1:edX+test+course', + 'content_key': 'course-v1:edX+test+course', + 'source': 'edX', + 'mode': 'verified', + 'content_price': 1000, + 'geag_variant_id': None, + } + + # we'll later assert that no transaction was created during this redemption attempt + num_txs_before = Transaction.objects.all().count() + + mock_price_for_content.return_value = mock_content_price + mock_enterprise_client.enroll.return_value = mock_enterprise_fulfillment_uuid + with self.assertRaisesRegex(PriceValidationError, 'outside of acceptable interval'): + self.subsidy.redeem( + lms_user_id, + content_key, + subsidy_access_policy_uuid, + requested_price_cents=500, + ) + + self.assertEqual(num_txs_before, Transaction.objects.all().count()) + @mock.patch('enterprise_subsidy.apps.subsidy.models.Subsidy.price_for_content') @mock.patch('enterprise_subsidy.apps.subsidy.models.Subsidy.enterprise_client') @mock.patch("enterprise_subsidy.apps.content_metadata.api.ContentMetadataApi.get_content_summary") diff --git a/enterprise_subsidy/settings/base.py b/enterprise_subsidy/settings/base.py index c020dfaa..f8c417fb 100644 --- a/enterprise_subsidy/settings/base.py +++ b/enterprise_subsidy/settings/base.py @@ -369,3 +369,11 @@ def root(*path_fragments): # How long we keep API Client data in cache. (seconds) ONE_HOUR = 60 * 60 LMS_USER_DATA_CACHE_TIMEOUT = ONE_HOUR + +# Defines error bounds for requested redemption price validation +# See https://github.com/openedx/enterprise-access/blob/main/docs/decisions/0014-assignment-price-validation.rst +# We use a wider default allowed interval in this service, because +# generally only operators are allowed to make calls to redeem, and there may +# be more drift between the time of allocation and the time of redemption. +ALLOCATION_PRICE_VALIDATION_LOWER_BOUND_RATIO = .80 +ALLOCATION_PRICE_VALIDATION_UPPER_BOUND_RATIO = 1.20 diff --git a/pylintrc b/pylintrc index 6652456e..9c560e10 100644 --- a/pylintrc +++ b/pylintrc @@ -64,7 +64,7 @@ # SERIOUSLY. # # ------------------------------ -# Generated by edx-lint version: 5.3.0 +# Generated by edx-lint version: 5.3.4 # ------------------------------ [MASTER] ignore = ,migrations, settings, wsgi.py @@ -259,6 +259,7 @@ enable = useless-suppression, disable = bad-indentation, + broad-exception-raised, consider-using-f-string, duplicate-code, file-ignored, @@ -290,6 +291,7 @@ disable = django-not-configured, consider-using-with, bad-option-value, + superfluous-parens, [REPORTS] output-format = text @@ -384,6 +386,6 @@ ext-import-graph = int-import-graph = [EXCEPTIONS] -overgeneral-exceptions = Exception +overgeneral-exceptions = builtins.Exception -# 63003c68c5cf2093b49c8d28ad1d344ee19b9783 +# fde14ff2b7c1587153eb26f978301341091e9e23 diff --git a/pylintrc_tweaks b/pylintrc_tweaks index a82ef2f8..74285735 100644 --- a/pylintrc_tweaks +++ b/pylintrc_tweaks @@ -10,3 +10,4 @@ DISABLE+= django-not-configured, consider-using-with, bad-option-value, + superfluous-parens,