├── .annotation_safe_list.yml ├── .bowerrc ├── .coveragerc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── depr-ticket.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── docker-compose-github.yml └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-remove-label-on-comment.yml │ ├── ci.yml │ ├── commitlint.yml │ ├── mysql8-migrations-check.yml │ ├── self-assign-issue.yml │ └── upgrade-python-requirements.yml ├── .gitignore ├── .pii_annotations.yml ├── .tx └── config ├── LICENSE.txt ├── Makefile ├── README.rst ├── api-compact.yaml ├── api.yaml ├── catalog-info.yaml ├── codecov.yml ├── docker-compose.yml ├── docs ├── Makefile ├── __init__.py ├── _static │ └── theme_overrides.css ├── conf.py ├── decisions │ ├── 0001-service-purpose.rst │ ├── 0002-customer-agreements.rst │ ├── 0003-subscription-renewals.rst │ ├── 0004-renewal-processing.rst │ ├── 0005-unrevoking-licenses.rst │ ├── 0006-freezing-unused-licenses.rst │ ├── 0007-plan-type-behaviour.rst │ ├── 0008-automated-renewal-processing.rst │ ├── 0009-sending-notification-emails.rst │ ├── 0010-auto-assign-licenses-upon-login.rst │ ├── 0011-product-model.rst │ ├── 0012-assigning-new-license-to-revoked-user.rst │ ├── 0013-keeping-license-emails-up-to-date.rst │ ├── 0014-linearizable-license-assignment.rst │ └── 0015-license-transfer-job.rst ├── diagrams │ └── src │ │ ├── plan_expiration.puml │ │ ├── plan_expiration │ │ └── License Expiration.png │ │ └── renewals │ │ ├── renewal-basic-models.svg │ │ ├── renewal-chain.svg │ │ ├── renewal-disallowed-states.svg │ │ └── renewal-processing.svg ├── features.rst ├── getting_started.rst ├── index.rst ├── internationalization.rst ├── references │ └── renewals.rst ├── segment_events.rst └── testing.rst ├── license_manager ├── __init__.py ├── apps │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── filters.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── mixins.py │ │ ├── models.py │ │ ├── pagination.py │ │ ├── permissions.py │ │ ├── serializers.py │ │ ├── tasks.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── factories.py │ │ │ ├── test_serializers.py │ │ │ ├── test_tasks.py │ │ │ └── test_utils.py │ │ ├── urls.py │ │ ├── utils.py │ │ └── v1 │ │ │ ├── __init__.py │ │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── test_api_eventing.py │ │ │ ├── test_license_activation_view.py │ │ │ └── test_views.py │ │ │ ├── urls.py │ │ │ └── views.py │ ├── api_client │ │ ├── __init__.py │ │ ├── base_oauth.py │ │ ├── braze.py │ │ ├── enterprise.py │ │ ├── enterprise_catalog.py │ │ ├── lms.py │ │ └── tests │ │ │ ├── test_enterprise_catalog_client.py │ │ │ └── test_enterprise_client.py │ ├── core │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── constants.py │ │ ├── context_processors.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_alter_user_first_name.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_context_processors.py │ │ │ ├── test_models.py │ │ │ └── test_views.py │ │ ├── throttles.py │ │ └── views.py │ └── subscriptions │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── api.py │ │ ├── apps.py │ │ ├── constants.py │ │ ├── event_utils.py │ │ ├── exceptions.py │ │ ├── forms.py │ │ ├── management │ │ └── commands │ │ │ ├── expire_subscriptions.py │ │ │ ├── manufacture_data.py │ │ │ ├── process_auto_scalable_plans.py │ │ │ ├── process_renewals.py │ │ │ ├── retire_old_licenses.py │ │ │ ├── seed_development_data.py │ │ │ ├── seed_enterprise_devstack_data.py │ │ │ ├── send_license_utilization_emails.py │ │ │ ├── tests │ │ │ ├── test_expire_subscriptions.py │ │ │ ├── test_process_auto_scalable_plans.py │ │ │ ├── test_process_renewals.py │ │ │ ├── test_retire_old_licenses.py │ │ │ ├── test_send_license_utilization_emails.py │ │ │ ├── test_trigger_event_for_licenses.py │ │ │ ├── test_unlink_expired_licenses.py │ │ │ └── test_validate_num_catalog_queries.py │ │ │ ├── trigger_event_for_licenses.py │ │ │ ├── unlink_expired_licenses.py │ │ │ └── validate_num_catalog_queries.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200529_1510.py │ │ ├── 0003_require_date_fields_subscriptionplan.py │ │ ├── 0004_add_title_field.py │ │ ├── 0005_subscriptions_feature_roles.py │ │ ├── 0006_create_subscription_learner_role.py │ │ ├── 0007_add_netsuite_and_salesforce_fields.py │ │ ├── 0008_add_subsc_for_internal_use_only.py │ │ ├── 0009_add_activation_key_to_license.py │ │ ├── 0010_add_assigned_date_revoked_date_to_license.py │ │ ├── 0011_change_deactivated_status_to_revoked.py │ │ ├── 0012_remove_purchase_date.py │ │ ├── 0013_add_revoke_cap_fields.py │ │ ├── 0014_add_customer_agreement_and_renewals.py │ │ ├── 0015_create_customer_agreements.py │ │ ├── 0016_require_enterprise_catalog_uuid.py │ │ ├── 0017_support_customer_agreements.py │ │ ├── 0018_remove_enterprise_customer_uuid_subscriptionplan.py │ │ ├── 0019_add_expiration_processed_to_subscriptions.py │ │ ├── 0020_help_text_for_default_catalog_uuid.py │ │ ├── 0021_subscriptionsroleassignment_applies_to_all_contexts.py │ │ ├── 0022_plantype.py │ │ ├── 0023_auto_20210527_1848.py │ │ ├── 0024_auto_20210528_1953.py │ │ ├── 0025_new_renewals_fields.py │ │ ├── 0026_auto_20210608_1910.py │ │ ├── 0027_renewal_cap_toggle.py │ │ ├── 0028_plan_type_help_text.py │ │ ├── 0029_populate_plan_types.py │ │ ├── 0030_add_license_renewal_field.py │ │ ├── 0031_planemailtemplates.py │ │ ├── 0032_populate_email_templates.py │ │ ├── 0033_auto_20210726_1234.py │ │ ├── 0034_auto_20210729_1444.py │ │ ├── 0035_freeze_unused_licenses.py │ │ ├── 0036_add_license_duration_before_purge_field.py │ │ ├── 0037_alter_agreement_slug_allow_null.py │ │ ├── 0038_alter_datefield_to_datetimefield.py │ │ ├── 0039_auto_20210920_1759.py │ │ ├── 0040_add_enterprise_customer_name_to_customer_agreement.py │ │ ├── 0041_historicalnotification_notification.py │ │ ├── 0042_auto_20211022_1904.py │ │ ├── 0043_auto_20211022_2122.py │ │ ├── 0044_auto_20211104_1451.py │ │ ├── 0045_auto_20211109_1429.py │ │ ├── 0046_add_product_and_association_to_subscription_plan.py │ │ ├── 0047_populate_subscription_product.py │ │ ├── 0048_delete_planemailtemplates.py │ │ ├── 0049_add_disable_onboarding_notifications.py │ │ ├── 0050_make_subscriptionplan_plan_type_nullable.py │ │ ├── 0051_remove_subscriptionplan_deprecated_columns.py │ │ ├── 0052_remove_license_unique_constraints.py │ │ ├── 0053_auto_20220301_1642.py │ │ ├── 0054_auto_20220908_1747.py │ │ ├── 0055_auto_20220916_1840.py │ │ ├── 0056_auto_20230530_1901.py │ │ ├── 0057_auto_20230915_0722.py │ │ ├── 0058_subscriptionlicensesource_subscriptionlicensesourcetype.py │ │ ├── 0059_add_subscriptionlicensesourcetypes.py │ │ ├── 0060_historicalsubscriptionlicensesource.py │ │ ├── 0061_auto_20230927_1119.py │ │ ├── 0062_add_license_transfer_job.py │ │ ├── 0063_transfer_all_licenses.py │ │ ├── 0064_subscriptionplan_desired_num_licenses.py │ │ ├── 0065_subscriptionplan_desired_num_licenses_not_editable.py │ │ ├── 0066_license_subscription_plan_status_idx.py │ │ ├── 0067_editable_desired_num_licenses.py │ │ ├── 0068_licenseevent.py │ │ ├── 0069_alter_customeragreement_disable_expiration_notifications_and_more.py │ │ ├── 0070_customeragreement_expired_subscription_modal_messaging_and_more.py │ │ ├── 0071_customeragreement_enable_auto_applied_subscriptions_with_universal_link_and_more.py │ │ ├── 0072_customeragreement_button_label_in_modal_and_more.py │ │ ├── 0073_remove_customeragreement_hyper_link_text_for_expired_modal_and_more.py │ │ ├── 0074_historicalcustomsubscriptionexpirationmessaging_and_more.py │ │ ├── 0075_license_auto_scaling.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── rules.py │ │ ├── sanitize.py │ │ ├── tasks.py │ │ ├── templates │ │ └── admin │ │ │ └── bulk_delete.html │ │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ ├── test_admin.py │ │ ├── test_api.py │ │ ├── test_event_utils.py │ │ ├── test_factories.py │ │ ├── test_forms.py │ │ ├── test_models.py │ │ ├── test_tasks.py │ │ ├── test_utils.py │ │ └── utils.py │ │ ├── urls_admin.py │ │ └── utils.py ├── celery.py ├── conf │ └── locale │ │ └── config.yaml ├── docker_gunicorn_configuration.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── devstack.py │ ├── local.py │ ├── private.py.example │ ├── production.py │ ├── test.py │ └── utils.py ├── static │ ├── .keep │ ├── filtered_subscription_admin.js │ └── img │ │ ├── edX_Icon_1000courses.png │ │ ├── edX_Icon_CGvirtualproctor_2.png │ │ └── edX_Icon_LearningNeuroscience.png ├── test_utils.py ├── urls.py └── wsgi.py ├── manage.py ├── provision-license-manager.sh ├── pylintrc ├── pylintrc_tweaks ├── pytest.ini ├── pytest.local.ini ├── requirements.txt ├── requirements ├── base.in ├── base.txt ├── common_constraints.txt ├── constraints.txt ├── dev.in ├── dev.txt ├── doc.in ├── doc.txt ├── monitoring │ └── requirements.txt ├── optional.txt ├── pip-tools.in ├── pip-tools.txt ├── pip.in ├── pip.txt ├── private.readme ├── production.in ├── production.txt ├── quality.in ├── quality.txt ├── test.in ├── test.txt ├── validation.in └── validation.txt ├── scripts ├── assignment_validation.py ├── generate_csvs.py ├── local_assignment.py ├── local_assignment_multi.py ├── local_assignment_requirements.txt ├── local_license_enrollment.py └── local_license_enrollment_requirements.txt └── setup.cfg /.annotation_safe_list.yml: -------------------------------------------------------------------------------- 1 | # This is a Code Annotations automatically-generated Django model safelist file. 2 | # These models must be annotated as follows in order to be counted in the coverage report. 3 | # See https://code-annotations.readthedocs.io/en/latest/safelist.html for more information. 4 | # 5 | # fake_app_1.FakeModelName: 6 | # ".. no_pii:": "This model has no PII" 7 | # fake_app_2.FakeModel2: 8 | # ".. choice_annotation:": foo, bar, baz 9 | 10 | admin.LogEntry: 11 | ".. no_pii:": "This model has no PII" 12 | auth.Group: 13 | ".. no_pii:": "This model has no PII" 14 | auth.Permission: 15 | ".. no_pii:": "This model has no PII" 16 | contenttypes.ContentType: 17 | ".. no_pii:": "This model has no PII" 18 | django_celery_results.ChordCounter: 19 | ".. no_pii:": "This model has no PII" 20 | django_celery_results.GroupResult: 21 | ".. no_pii:": "This model has no PII" 22 | django_celery_results.TaskResult: 23 | ".. no_pii:": "This model has no PII" 24 | sessions.Session: 25 | ".. no_pii:": "This model has no PII" 26 | social_django.Association: 27 | ".. no_pii:": "This model has no PII" 28 | social_django.Code: 29 | ".. pii:": "Email address" 30 | ".. pii_types:": other 31 | ".. pii_retirement:": local_api 32 | social_django.Nonce: 33 | ".. no_pii:": "This model has no PII" 34 | social_django.Partial: 35 | ".. no_pii:": "This model has no PII" 36 | social_django.UserSocialAuth: 37 | ".. no_pii:": "This model has no PII" 38 | subscriptions.HistoricalCustomerAgreement: 39 | ".. no_pii:": "This model has no PII" 40 | subscriptions.HistoricalLicense: 41 | ".. no_pii:": "This model has no PII" 42 | subscriptions.HistoricalLicenseTransferJob: 43 | ".. no_pii:": "This model has no PII" 44 | subscriptions.HistoricalNotification: 45 | ".. no_pii:": "This model has no PII" 46 | subscriptions.HistoricalSubscriptionPlan: 47 | ".. no_pii:": "This model has no PII" 48 | subscriptions.HistoricalSubscriptionPlanRenewal: 49 | ".. no_pii:": "This model has no PII" 50 | subscriptions.HistoricalProduct: 51 | ".. no_pii:": "This model has no PII" 52 | waffle.Flag: 53 | ".. no_pii:": "This model has no PII" 54 | waffle.Sample: 55 | ".. no_pii:": "This model has no PII" 56 | waffle.Switch: 57 | ".. no_pii:": "This model has no PII" 58 | subscriptions.HistoricalSubscriptionLicenseSource: 59 | ".. no_pii:": "This model has no PII" 60 | subscriptions.HistoricalCustomSubscriptionExpirationMessaging: 61 | ".. no_pii:": "This model has no PII" 62 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "license_manager/static/bower_components", 3 | "interactive": false 4 | } 5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | data_file = .coverage 4 | source=license_manager 5 | omit = 6 | license_manager/settings* 7 | license_manager/conf* 8 | *wsgi.py 9 | *migrations* 10 | *admin.py 11 | *static* 12 | *templates* 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # *************************** 2 | # ** DO NOT EDIT THIS FILE ** 3 | # *************************** 4 | # 5 | # This file was generated by edx-lint: http://github.com/edx/edx-lint 6 | # 7 | # If you want to change this file, you have two choices, depending on whether 8 | # you want to make a local change that applies only to this repo, or whether 9 | # you want to make a central change that applies to all repos using edx-lint. 10 | # 11 | # LOCAL CHANGE: 12 | # 13 | # 1. Edit the local .editorconfig_tweaks file to add changes just to this 14 | # repo's file. 15 | # 16 | # 2. Run: 17 | # 18 | # $ edx_lint write .editorconfig 19 | # 20 | # 3. This will modify the local file. Submit a pull request to get it 21 | # checked in so that others will benefit. 22 | # 23 | # 24 | # CENTRAL CHANGE: 25 | # 26 | # 1. Edit the .editorconfig file in the edx-lint repo at 27 | # https://github.com/edx/edx-lint/blob/master/edx_lint/files/.editorconfig 28 | # 29 | # 2. install the updated version of edx-lint (in edx-lint): 30 | # 31 | # $ pip install . 32 | # 33 | # 3. Run (in edx-lint): 34 | # 35 | # # uses .editorconfig_tweaks from edx-lint for linting in edx-lint 36 | # # NOTE: Use Python 3.x, which no longer includes comments in the output file 37 | # $ edx_lint write .editorconfig 38 | # 39 | # 4. Make a new version of edx_lint, submit and review a pull request with the 40 | # .editorconfig update, and after merging, update the edx-lint version by 41 | # creating a new tag in the repo (uses pbr). 42 | # 43 | # 5. In your local repo, install the newer version of edx-lint. 44 | # 45 | # 6. Run: 46 | # 47 | # # uses local .editorconfig_tweaks 48 | # $ edx_lint write .editorconfig 49 | # 50 | # 7. This will modify the local file. Submit a pull request to get it 51 | # checked in so that others will benefit. 52 | # 53 | # 54 | # 55 | # 56 | # 57 | # STAY AWAY FROM THIS FILE! 58 | # 59 | # 60 | # 61 | # 62 | # 63 | # SERIOUSLY. 64 | # 65 | # ------------------------------ 66 | [*] 67 | end_of_line = lf 68 | insert_final_newline = true 69 | charset = utf-8 70 | indent_style = space 71 | indent_size = 4 72 | max_line_length = 120 73 | trim_trailing_whitespace = true 74 | 75 | [{Makefile, *.mk}] 76 | indent_style = tab 77 | indent_size = 8 78 | 79 | [*.{yml,yaml,json}] 80 | indent_size = 2 81 | 82 | [*.js] 83 | indent_size = 2 84 | 85 | [*.diff] 86 | trim_trailing_whitespace = false 87 | 88 | [.git/*] 89 | trim_trailing_whitespace = false 90 | 91 | [*.rst] 92 | max_line_length = 79 93 | 94 | # 01d24285b953f74272f86b1e42a0235315085e59 95 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | ## This configuration file overrides the inherited configuration file defined 2 | ## in openedx/.github/.github/ISSUE_TEMPLATE because this repo currently does 3 | ## not have Issues turned on, so we create this override to *only* show DEPR 4 | ## issues to users creating Issues. Once Issues are turned on and the repo is 5 | ## ready to accept Issues of all types, this file must be deleted so inheritance 6 | ## of standard openedx configuration works properly. 7 | 8 | blank_issues_enabled: false 9 | contact_links: 10 | - name: Open edX Community Support 11 | url: https://discuss.openedx.org/ 12 | about: Please ask all questions, suggest all enhancements, and report all bugs here. 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Description of changes made 4 | 5 | Link to the associated ticket: https://openedx.atlassian.net/browse/ENT-XXXX 6 | 7 | ## Testing considerations 8 | 9 | - Include instructions for any required manual tests, and any manual testing that has 10 | already been performed. 11 | - Include unit and a11y tests as appropriate 12 | - Consider performance issues. 13 | - Check that Database migrations are backwards-compatible 14 | 15 | ## Post-review 16 | 17 | Squash commits into discrete sets of changes 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Adding new check for github-actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/docker-compose-github.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | mysql: 4 | image: mysql:5.7 5 | container_name: license-manager.mysql 6 | environment: 7 | MYSQL_ROOT_PASSWORD: "" 8 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 9 | MYSQL_DATABASE: "license_manager" 10 | volumes: 11 | - license_manager_mysql:/var/lib/mysql 12 | 13 | app: 14 | image: edxops/license-manager-dev 15 | container_name: license-manager.app 16 | volumes: 17 | - ..:/edx/app/license_manager 18 | # Use the Django devserver, so that we can hot-reload code changes 19 | command: bash -c 'while true; do python /edx/app/license_manager/manage.py runserver 0.0.0.0:18170; sleep 2; done' 20 | ports: 21 | - "18170:18170" 22 | depends_on: 23 | - mysql 24 | # Allows attachment to this container using 'docker attach '. 25 | stdin_open: true 26 | tty: true 27 | environment: 28 | CELERY_ALWAYS_EAGER: 'true' 29 | DJANGO_SETTINGS_MODULE: license_manager.settings.test 30 | 31 | volumes: 32 | license_manager_mysql: 33 | driver: local 34 | -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.12"] 15 | django-version: ["pinned"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | architecture: x64 22 | - name: Install requirements 23 | run: make requirements 24 | - name: Upgrade packages 25 | run: | 26 | pip install -U pip wheel codecov 27 | if [[ "${{ matrix.django-version }}" != "pinned" ]]; then 28 | pip install "django~=${{ matrix.django-version }}.0" 29 | pip check # fail if this test-reqs/Django combination is broken 30 | fi 31 | - name: Validate translations 32 | run: make validate_translations 33 | - name: Run tests 34 | run: make test 35 | - name: Codecov 36 | run: codecov 37 | quality: 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | python-version: ["3.12"] 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | architecture: x64 48 | - name: Install requirements 49 | run: make requirements 50 | - name: Upgrade packages 51 | run: pip install -U pip wheel codecov 52 | - name: Run pylint 53 | run: make lint 54 | - name: Run pycodestyle 55 | run: make style 56 | - name: Run isort 57 | run: make isort_check 58 | - name: Run pii check 59 | run: make pii_check 60 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/mysql8-migrations-check.yml: -------------------------------------------------------------------------------- 1 | name: Migration check on MySql8 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | check_migrations: 13 | name: check migration for MySql8 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ ubuntu-latest ] 18 | python-version: [ 3.12 ] 19 | 20 | steps: 21 | - name: Checkout repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install system packages 30 | run: | 31 | sudo apt-get update 32 | sudo apt-get install -y libxmlsec1-dev 33 | - name: Get pip cache dir 34 | id: pip-cache-dir 35 | run: | 36 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 37 | - name: Cache pip dependencies 38 | id: cache-dependencies 39 | uses: actions/cache@v4 40 | with: 41 | path: ${{ steps.pip-cache-dir.outputs.dir }} 42 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/pip_tools.txt') }} 43 | restore-keys: ${{ runner.os }}-pip- 44 | 45 | - name: Ubuntu and sql versions 46 | run: | 47 | lsb_release -a 48 | mysql -V 49 | - name: Install Python Dependencies 50 | run: | 51 | pip install -r requirements/pip-tools.txt 52 | pip install -r requirements/production.txt 53 | pip uninstall -y mysqlclient 54 | pip install --no-binary mysqlclient mysqlclient 55 | pip uninstall -y xmlsec 56 | pip install --no-binary xmlsec xmlsec==1.3.13 57 | - name: Initiate services 58 | run: | 59 | sudo /etc/init.d/mysql start 60 | - name: Reset mysql password 61 | run: | 62 | cat </LC_MESSAGES/django.po 6 | source_file = license-manager/conf/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | type = PO 9 | 10 | [o:open-edx:p:edx-platform:r:license-manager-js] 11 | file_filter = license-manager/conf/locale//LC_MESSAGES/djangojs.po 12 | source_file = license-manager/conf/locale/en/LC_MESSAGES/djangojs.po 13 | source_lang = en 14 | type = PO 15 | 16 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | 4 | apiVersion: backstage.io/v1alpha1 5 | # (Required) Acceptable Values: Component, Resource, System 6 | # Use `Component` unless you know how backstage works and what the other kinds mean. 7 | kind: Component 8 | metadata: 9 | # (Required) Must be the name of the repo, without the owning organization. 10 | name: 'license-manager' 11 | description: "Django backend for managing licenses and subscriptions for enterprise customer." 12 | annotations: 13 | # (Optional) Annotation keys and values can be whatever you want. 14 | # We use it in Open edX repos to have a comma-separated list of GitHub user 15 | # names that might be interested in changes to the architecture of this 16 | # component. 17 | openedx.org/arch-interest-groups: "" 18 | # (Optional) We use the below annotation to indicate whether or not this 19 | # repository should be tagged for openedx releases and which branch is tagged. 20 | openedx.org/release: "master" 21 | spec: 22 | 23 | # (Required) This can be a group (`group:`) or a user (`user:`). 24 | # Don't forget the "user:" or "group:" prefix. Groups must be GitHub team 25 | # names in the openedx GitHub organization: https://github.com/orgs/openedx/teams 26 | # 27 | # If you need a new team created, create an issue with Axim engineering: 28 | # https://github.com/openedx/axim-engineering/issues/new/choose 29 | owner: group:2u-enterprise 30 | 31 | # (Required) Acceptable Type Values: service, website, library 32 | type: 'service' 33 | 34 | # (Required) Acceptable Lifecycle Values: experimental, production, deprecated 35 | lifecycle: 'production' 36 | 37 | # (Optional) The value can be the name of any known component. 38 | subcomponentOf: '' 39 | 40 | # (Optional) An array of different components or resources. 41 | dependsOn: 42 | - '' 43 | - '' 44 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | target: 90 7 | project: 8 | default: 9 | target: 90 10 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- 1 | # Included so Django's startproject command runs against the docs directory 2 | -------------------------------------------------------------------------------- /docs/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* override table width restrictions */ 2 | .wy-table-responsive table td, .wy-table-responsive table th { 3 | /* !important prevents the common CSS stylesheets from 4 | overriding this as on RTD they are loaded after this stylesheet */ 5 | white-space: normal !important; 6 | } 7 | 8 | .wy-table-responsive { 9 | overflow: visible !important; 10 | } 11 | -------------------------------------------------------------------------------- /docs/decisions/0008-automated-renewal-processing.rst: -------------------------------------------------------------------------------- 1 | 8. Automated Subscription Plan Renewals 2 | ====================== 3 | 4 | Status 5 | ====== 6 | 7 | * Accepted September 2021 8 | 9 | Context 10 | ======= 11 | 12 | Currently, subscription renewals must be processed manually by an administrator through the Django Admin using the "Process selected renewal records" action. 13 | We'd like to automate this process so that renewals with an upcoming effective date are automatically processed. 14 | The decision below describes the automated process. Refer to ADR 0004 for more details on the renewal process itself. 15 | 16 | Decision 17 | ======== 18 | 19 | A renewal with an upcoming (within the next 12 hours by default) effective date will be in its renewal processing window. 20 | Subscription plans with a renewal within its renewal processing window will be locked, and admins will not be able to take 21 | actions related to the plan (i.e. invite learners, revoke licenses). 22 | A cron job will run every 6 hours to execute the process_renewals command, which will fetch and process renewals. 23 | Failure to process a renewal will trigger an OpsGenie P1 Alert, but the job will continue to process other renewals normally. 24 | The subscription renewal process itself is atomic, and changes will not be commited in the event of a failure. 25 | 26 | Consequences 27 | ============ 28 | 29 | * We assume that a renewal record will not be created until business approval is granted. 30 | The prescence of a renewal record indicates that it can be automatically processed. 31 | -------------------------------------------------------------------------------- /docs/decisions/0011-product-model.rst: -------------------------------------------------------------------------------- 1 | 11. Product model 2 | ====================== 3 | 4 | Status 5 | ====== 6 | 7 | Accepted 8 | 9 | Context 10 | ======= 11 | 12 | It is critical that subscription plans be created with the correct Netsuite product id as it determines how our reporting and financial accounting systems interpret and treat a subscription license enrollment. 13 | Currently the Netsuite product id is a numeric field that is manually entered during the creation of a subscription plan and errors have been common during this creation process. 14 | The data team has a test for the validity of the Netsuite product id entered, but a failure in this test causes a disruption in all our normal data transformation steps 15 | We want to eliminate the manual entry process and prevent errors in the future. 16 | 17 | Decision 18 | ======== 19 | 20 | In order to represent the relationship between subscription plans and our backend business products, we will create a new model ``Product`` which 21 | details the type of product that was sold to a customer to access a subscription plan. 22 | 23 | The ``Product`` model will have the following fields: 24 | 25 | * ``name`` 26 | * short name as designated by Product/ECS, i.e. 'B2B', 'OC' 27 | * ``description`` 28 | * a description of the product 29 | * ``netsuite_id`` 30 | * The netsuite_id of the product that was sold to the customer 31 | * ``plan_type`` 32 | * The plan type that the product falls under (as of writing plan types include 'OCE', 'Trials', 'Standard Paid') 33 | 34 | The transition to using the new Product model will be a multi-step process. 35 | 36 | 1. The ``Product`` model is created. The ``netsuite_product_id`` and ``plan_type`` fields on the ``SubscriptionPlan`` model will be marked as deprecated and 37 | a ``product`` field will be added to reference the new ``Product`` model. 38 | 39 | 2. Entries for current products will be created in all environments. 40 | 41 | 3. A migration will be applied to back-populate the ``product_id`` field (must be optional initially) on all subscription plans. 42 | 43 | 4. Remove references to the now deprecated ``netsuite_product_id`` and ``plan_type`` fields on subscription plans and use the fields 44 | on ``Product`` instead. 45 | 46 | 5. Notify the data team of these changes and how to adjust their queries. 47 | 48 | 6. Another migration will be applied to remove the deprecated columns and make ``product_id`` required on ``SubscriptionPlan``. 49 | 50 | After these steps a product_id will have to be selected during the subscription plan creation process. -------------------------------------------------------------------------------- /docs/decisions/0012-assigning-new-license-to-revoked-user.rst: -------------------------------------------------------------------------------- 1 | 12. Assigning new license to revoked Users 2 | ======================= 3 | 4 | Status 5 | ====== 6 | 7 | Supersedes 0005-unrevoking-licenses.rst 8 | 9 | 10 | Context 11 | ======= 12 | 13 | As documented in `adr 0005 `_, when a user whose license has been revoked 14 | is assigned a license within the same subscription plan, the previously revoked license is reassigned to the user. 15 | An unassigned license is then removed from the plan to account for the license that was added during the revocation process. 16 | 17 | Unrevoking a license involves changing data for a license and deleting a license; we would like to simplify this process 18 | and preserve the state of revoked licenses. 19 | Assigning a new license rather than unrevoking was an alternative that was considered, and we want to move 20 | forward with this behavior in the future. 21 | 22 | 23 | Decision 24 | ======== 25 | 26 | The concept of `unrevoking` a license will be deprecated and a new license will always be assigned to a user. 27 | 28 | The following addresses the points that were brought up previously: 29 | 30 | 1. It would look like a learner has multiple licenses in the same plan if we assign a new license. 31 | 32 | Proposed solutions: 33 | * We will return all of the license and let the UI handle the logic of hiding revoked licenses if needed. 34 | 35 | 2. We have unique constraints on ``(subscription_plan, user_email)`` and ``(subscription_plan, lms_user_id)``. 36 | 37 | Proposed solution: 38 | * We will drop the unique constraint and instead rely on application logic to prevent multiple active/assigned licenses. 39 | The `/assign` endpoint already validates that a user does not have an activated license before assigning a new license. 40 | Unfortunately MySQL does not support conditional indexes and thus we can't just add a constraint similar to 41 | 42 | ``` 43 | models.UniqueConstraint( 44 | fields=('subscription_plan', 'user_email',), 45 | condition=Q(status__in=[ASSIGNED, ACTIVATED]), 46 | name='unique_email_if_assigned_or_activated' 47 | ) 48 | ``` 49 | 50 | Consequences 51 | ============ 52 | 53 | * We will no longer `unrevoke` any licenses. 54 | * A user can have multiple declined licenses within a subscription plan. 55 | * Admins will be able to maintain a more accurate history of an individual's license states in the admin portal. 56 | * Revoked licenses will be unmodified and we won't need to dig through the history table to see the original license state. 57 | * There will never be the scenario where some LicensedEnterpriseCourseEnrollments are revoked 58 | but others are not for the same license UUID. This can occur today if a user enrolls in a course with a license that was unrevoked. -------------------------------------------------------------------------------- /docs/decisions/0013-keeping-license-emails-up-to-date.rst: -------------------------------------------------------------------------------- 1 | 13. Keeping license emails up to date 2 | ===================================== 3 | 4 | Status 5 | ====== 6 | 7 | Accepted 8 | 9 | Context 10 | ======= 11 | 12 | Currently, the `/learner-licenses/` endpoint returns licenses that match with the user's email provided in the JWT. 13 | However a user's email could change, and the endpoint would fail to return the user's licenses if that occurs. 14 | 15 | We want to return the correct licenses even if a user has changed their email and also update the `user_email` field 16 | on their licenses to reflect the new email. 17 | 18 | Decision 19 | ======== 20 | 21 | In an event driven architecture, an event would be dispatched by the LMS when a user changes their information. License 22 | Manager would consume this event and update all of the licenses associated with the user. However we do not have 23 | such an infrastructure set up yet and will have to rely on the JWT passed in with each request to determine if a user 24 | has changed their email. 25 | 26 | Whenever a user fetches their learner licenses, we will query for licenses that are associated with **both** 27 | the email and the lms_user_id that is present in the JWT payload. This will ensure that even if a user has changed their email, 28 | licenses associated with the lms_user_id will still be returned. We have to query by email as well because an assigned license might not have 29 | an lms_user_id yet. If we detect that the user's email has changed (i.e. the `user_email` field on the licenses do not match with the one in the JWT), 30 | we will update the `user_email` field on all of the user's licenses. 31 | 32 | Consequences 33 | ============ 34 | * If a user has an assigned license but changes their email before activating their license, there is no way for us to update 35 | the unassigned license because the lms_user_id has not been set. Until we have access to a better solution (i.e. consuming an event), there is no workaround 36 | for this problem. The admin will have to reassign a new license in this case. 37 | 38 | Alternatives Considered 39 | ======================= 40 | * We could also set the lms_user_id on unassigned licenses whenever the user hits the `/learner-licenses/` endpoint. 41 | However it is unlikely that the user will visit the learner portal but change their email before activating their license in the same session. 42 | -------------------------------------------------------------------------------- /docs/decisions/0015-license-transfer-job.rst: -------------------------------------------------------------------------------- 1 | 15. License Transfer Jobs 2 | ######################### 3 | 4 | Status 5 | ****** 6 | Accepted (October 2023) 7 | 8 | Context 9 | ******* 10 | There are some customer agreements for which we want to support transferring 11 | licenses between Subscription Plans, particularly in the following scenario: 12 | 13 | * A learner is assigned (and activates) a license in Plan A. 14 | * By some threshold date for Plan A, like a "lock" or "cutoff" time, 15 | the plan is closed (meaning no more licenses will be assigned from that plan). 16 | * There’s a new, rolling Subscription Plan B that starts directly 17 | after the lock time of Plan A. 18 | 19 | In this scenario, We want to give the learner an opportunity to 20 | continue learning via a subscription license under Plan B. 21 | Furthermore, we want the enrollment records to continue to be associated 22 | with the original license, but for the license to now be associated with plan B 23 | (which may be necessary for back-office reporting purposes). 24 | 25 | Decision 26 | ******** 27 | We've introuduced a ``LicenseTransferJob`` model that, given a set of 28 | activated or assigned license UUIDs, will transfer the licenses from 29 | an old plan to a new plan via a ``process()`` method. This method 30 | has several important properties: 31 | 32 | 1. It support dry-runs, so that we can see which licenses **would** be 33 | transferred without actually transferring them. 34 | 2. It's idempotent: calling ``process()`` twice on the same input 35 | will leave the licenses in the same output state (provided no other 36 | rouge process has mutated the licenses outside of these ``process()`` calls.). 37 | 3. It's reversible: If you transfer licenses from plan A to plan B, you 38 | can reverse that action by creating a new job to transfer from plan B 39 | back to plan A. 40 | 41 | The Django Admin site supports creation, management, and processing of 42 | ``LicenseTransferJobs``. 43 | 44 | Consequences 45 | ************ 46 | Supporting the scenario above via LicenseTransferJobs allows us 47 | to some degree to satisfy agreements for rolling-window subscription access; 48 | that is, subscriptions where the license expiration time is determined 49 | from the perspective of the license's activation time, **not** the plan's 50 | effective date. 51 | 52 | Alternatives Considered 53 | *********************** 54 | None in particular. 55 | -------------------------------------------------------------------------------- /docs/diagrams/src/plan_expiration.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | title License Expiration 3 | 4 | 5 | (*) --> "Commands.expire_subscriptions()" 6 | --> "bulk_licensed_enrollments_expiration()[edx-enterprise]" 7 | --> "get_course_overviews()[edx-platform]" 8 | 9 | if "has user earned a certificate?" then 10 | ->[true] "LicensedEnterpriseCourseEnrollment.revoke()[edx-enterprise]" 11 | else 12 | if "has course run ended?" then 13 | ->[true] "LicensedEnterpriseCourseEnrollment.revoke()[edx-enterprise]" 14 | else 15 | -> [false] if "does course have audit mode?" then 16 | ---> [true] "update_course_enrollment_mode_for_user()[edx-platform]" 17 | ---> "LicensedEnterpriseCourseEnrollment.revoke()[edx-enterprise]" 18 | else 19 | ---> [false] "unenroll_user_from_course()[edx-platform]" 20 | ---> "LicensedEnterpriseCourseEnrollment.revoke()[edx-enterprise]" 21 | endif 22 | 23 | @enduml -------------------------------------------------------------------------------- /docs/diagrams/src/plan_expiration/License Expiration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/docs/diagrams/src/plan_expiration/License Expiration.png -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | Feature Toggling 2 | ================ 3 | All new features/functionality should be released behind a feature gate. This allows us to easily disable features 4 | in the event that an issue is discovered in production. This project uses the 5 | `Waffle `_ library for feature gating. 6 | 7 | Waffle supports three types of feature gates (listed below). We typically use flags and switches since samples are 8 | random, and not ideal for our needs. 9 | 10 | Flag 11 | Enable a feature for specific users, groups, users meeting certain criteria (e.g. authenticated or staff), 12 | or a certain percentage of visitors. 13 | 14 | Switch 15 | Simple boolean, toggling a feature for all users. 16 | 17 | Sample 18 | Toggle the feature for a specified percentage of the time. 19 | 20 | 21 | For information on creating or updating features, refer to the 22 | `Waffle documentation `_. 23 | 24 | Permanent Feature Rollout 25 | ------------------------- 26 | Over time some features may become permanent and no longer need a feature gate around them. In such instances, the 27 | relevant code and tests should be updated to remove the feature gate. Once the code is released, the feature flag/switch 28 | should be deleted. 29 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. license_manager documentation master file, created by 2 | sphinx-quickstart on Sun Feb 17 11:46:20 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | License Manager 7 | ======================================================================= 8 | Django backend for managing licenses and subscriptions 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | getting_started 14 | testing 15 | features 16 | internationalization 17 | -------------------------------------------------------------------------------- /docs/internationalization.rst: -------------------------------------------------------------------------------- 1 | Internationalization 2 | ==================== 3 | All user-facing text content should be marked for translation. Even if this application is only run in English, our 4 | open source users may choose to use another language. Marking content for translation ensures our users have 5 | this choice. 6 | 7 | Follow the `internationalization coding guidelines`_ in the edX Developer's Guide when developing new features. 8 | 9 | .. _internationalization coding guidelines: https://docs.openedx.org/en/latest/developers/references/developer_guide/internationalization/i18n.html 10 | 11 | Updating Translations 12 | ~~~~~~~~~~~~~~~~~~~~~ 13 | This project uses `Transifex`_ to translate content. After new features are developed the translation source files 14 | should be pushed to Transifex. Our translation community will translate the content, after which we can retrieve the 15 | translations. 16 | 17 | .. _Transifex: https://www.transifex.com/ 18 | 19 | Pushing source translation files to Transifex requires access to the edx-platform. Request access from the Open Source 20 | Team if you will be pushing translation files. You should also `configure the Transifex client`_ if you have not done so 21 | already. 22 | 23 | .. _configure the Transifex client: http://docs.transifex.com/client/config/ 24 | 25 | The `make` targets listed below can be used to push or pull translations. 26 | 27 | .. list-table:: 28 | :widths: 25 75 29 | :header-rows: 1 30 | 31 | * - Target 32 | - Description 33 | * - pull_translations 34 | - Pull translations from Transifex 35 | * - push_translations 36 | - Push source translation files to Transifex 37 | 38 | Fake Translations 39 | ~~~~~~~~~~~~~~~~~ 40 | As you develop features it may be helpful to know which strings have been marked for translation, and which are not. 41 | Use the `fake_translations` make target for this purpose. This target will extract all strings marked for translation, 42 | generate fake translations in the Esperanto (eo) language directory, and compile the translations. 43 | 44 | You can trigger the display of the translations by setting your browser's language to Esperanto (eo), and navigating to 45 | a page on the site. Instead of plain English strings, you should see specially-accented English strings that look like this: 46 | 47 | Thé Fütüré øf Ønlïné Édüçätïøn Ⱡσяєм ι# Før änýøné, änýwhéré, änýtïmé Ⱡσяєм # 48 | 49 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | The command below runs the Python tests and code quality validation—Pylint and Pycodestyle. 5 | 6 | .. code-block:: bash 7 | 8 | $ make validate 9 | 10 | Code quality validation can be run independently with: 11 | 12 | .. code-block:: bash 13 | 14 | $ make quality 15 | 16 | Python tests can be run independently with: 17 | 18 | .. code-block:: bash 19 | 20 | $ make test 21 | -------------------------------------------------------------------------------- /license_manager/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This will make sure the app is always imported when 3 | Django starts so that shared_task will use this app. 4 | """ 5 | from .celery import app as celery_app 6 | 7 | 8 | __all__ = ('celery_app',) 9 | -------------------------------------------------------------------------------- /license_manager/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/api/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/api/filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Filters for the License API. 3 | """ 4 | 5 | from django.db.models import Q 6 | from django_filters import rest_framework as filters 7 | 8 | from license_manager.apps.subscriptions.constants import UNASSIGNED 9 | from license_manager.apps.subscriptions.models import License 10 | 11 | 12 | class LicenseFilter(filters.FilterSet): 13 | """ 14 | Filter for License. 15 | 16 | Supports filtering by license status and whether null emails are included. 17 | """ 18 | status = filters.CharFilter(method='filter_by_status') 19 | ignore_null_emails = filters.BooleanFilter(method='filter_by_ignore_null_emails') 20 | 21 | class Meta: 22 | model = License 23 | fields = ['status'] 24 | 25 | def filter_by_status(self, queryset, name, value): # pylint: disable=unused-argument 26 | status_values = value.strip().split(',') 27 | return queryset.filter(status__in=status_values).distinct() 28 | 29 | # ignores revoked licenses that have been cleared of PII 30 | def filter_by_ignore_null_emails(self, queryset, name, value): # pylint: disable=unused-argument 31 | if not value: 32 | return queryset 33 | return queryset.exclude(Q(user_email__isnull=True) & ~Q(status=UNASSIGNED)) 34 | -------------------------------------------------------------------------------- /license_manager/apps/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-15 21:08 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import model_utils.fields 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='BulkEnrollmentJob', 19 | fields=[ 20 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 21 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 22 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), 23 | ('enterprise_customer_uuid', models.UUIDField()), 24 | ('lms_user_id', models.IntegerField(blank=True, null=True)), 25 | ('results_s3_object_name', models.CharField(blank=True, max_length=255, null=True)), 26 | ], 27 | options={ 28 | 'abstract': False, 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /license_manager/apps/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/api/migrations/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/api/mixins.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | 3 | from rest_framework.exceptions import ParseError 4 | 5 | from license_manager.apps.api import utils 6 | from license_manager.apps.api_client.lms import LMSApiClient 7 | 8 | 9 | class UserDetailsFromJwtMixin: 10 | """ 11 | Mixin for retrieving user information from the jwt. 12 | """ 13 | 14 | @cached_property 15 | def decoded_jwt(self): 16 | """ 17 | Expects `self.request` to be explicitly defined. 18 | """ 19 | if not getattr(self, 'request', None): 20 | raise Exception(f'{self.__class__} must have a request field.') 21 | 22 | return utils.get_decoded_jwt(self.request) 23 | 24 | @cached_property 25 | def lms_user_id(self): 26 | """ 27 | Retrieve the LMS user ID. 28 | """ 29 | try: 30 | return utils.get_key_from_jwt(self.decoded_jwt, 'user_id') 31 | except ParseError: 32 | lms_client = LMSApiClient() 33 | user_id = lms_client.fetch_lms_user_id(self.request.user.email) 34 | return user_id 35 | 36 | @property 37 | def user_email(self): 38 | return utils.get_key_from_jwt(self.decoded_jwt, 'email') 39 | -------------------------------------------------------------------------------- /license_manager/apps/api/permissions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Permission classes for Subscriptions API 3 | """ 4 | from django.conf import settings 5 | from rest_framework import permissions 6 | 7 | 8 | class CanRetireUser(permissions.BasePermission): 9 | """ 10 | Grant access to the user retirement API for the service user, and to superusers. This mimics the 11 | retirement permissions check in edx-platform. 12 | """ 13 | 14 | def has_permission(self, request, view): 15 | return request.user.username == settings.RETIREMENT_SERVICE_WORKER_USERNAME or request.user.is_superuser 16 | -------------------------------------------------------------------------------- /license_manager/apps/api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Create your tests in sub-packages prefixed with "test_" (e.g. test_models). 2 | -------------------------------------------------------------------------------- /license_manager/apps/api/tests/factories.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import factory 4 | 5 | from license_manager.apps.api.models import BulkEnrollmentJob 6 | 7 | 8 | class BulkEnrollmentJobFactory(factory.django.DjangoModelFactory): 9 | """ 10 | Test factory for the `BulkEnrollmentJob` model. 11 | """ 12 | class Meta: 13 | model = BulkEnrollmentJob 14 | 15 | uuid = factory.LazyFunction(uuid4) 16 | enterprise_customer_uuid = factory.LazyFunction(uuid4) 17 | lms_user_id = factory.Faker('random_int') 18 | -------------------------------------------------------------------------------- /license_manager/apps/api/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | Root API URLs. 3 | 4 | All API URLs should be versioned, so urlpatterns should only 5 | contain namespaces for the active versions of the API. 6 | """ 7 | import re 8 | 9 | from django.urls import include, path 10 | 11 | from license_manager.apps.api.v1 import urls as v1_urls 12 | 13 | 14 | def optional_trailing_slash(urls): 15 | for url in urls[0].urlpatterns: 16 | url.pattern.regex = re.compile(url.pattern.regex.pattern.replace('/$', '/?$')) 17 | return urls 18 | 19 | 20 | app_name = 'api' 21 | urlpatterns = [ 22 | path('v1/', optional_trailing_slash(include(v1_urls))), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/api/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/api/v1/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/api/v1/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Create your tests in sub-packages prefixed with "test_" (e.g. test_views). 2 | -------------------------------------------------------------------------------- /license_manager/apps/api/v1/tests/constants.py: -------------------------------------------------------------------------------- 1 | from license_manager.apps.subscriptions import constants 2 | 3 | 4 | # Constants for subscriptions API tests 5 | SUBSCRIPTION_RENEWAL_DAYS_OFFSET = 500 6 | 7 | ADMIN_ROLES = { 8 | 'system_role': constants.SYSTEM_ENTERPRISE_ADMIN_ROLE, 9 | 'subscriptions_role': constants.SUBSCRIPTIONS_ADMIN_ROLE, 10 | } 11 | LEARNER_ROLES = { 12 | 'system_role': constants.SYSTEM_ENTERPRISE_LEARNER_ROLE, 13 | 'subscriptions_role': constants.SUBSCRIPTIONS_LEARNER_ROLE, 14 | } 15 | -------------------------------------------------------------------------------- /license_manager/apps/api_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/api_client/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/api_client/base_oauth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from edx_rest_api_client.client import OAuthAPIClient 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class BaseOAuthClient: 11 | """ 12 | API client for calls to the enterprise service. 13 | """ 14 | 15 | def __init__(self): 16 | self.client = OAuthAPIClient( 17 | settings.SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT.strip('/'), 18 | self.oauth2_client_id, 19 | self.oauth2_client_secret 20 | ) 21 | 22 | @property 23 | def oauth2_client_id(self): 24 | return settings.BACKEND_SERVICE_EDX_OAUTH2_KEY 25 | 26 | @property 27 | def oauth2_client_secret(self): 28 | return settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET 29 | -------------------------------------------------------------------------------- /license_manager/apps/api_client/braze.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from braze.client import BrazeClient 4 | from django.conf import settings 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class BrazeApiClient(BrazeClient): 11 | def __init__(self): 12 | 13 | required_settings = ['BRAZE_API_KEY', 'BRAZE_API_URL', 'BRAZE_APP_ID'] 14 | 15 | for setting in required_settings: 16 | if not getattr(settings, setting, None): 17 | msg = f'Missing {setting} in settings required for Braze API Client.' 18 | logger.error(msg) 19 | raise ValueError(msg) 20 | 21 | super().__init__( 22 | api_key=settings.BRAZE_API_KEY, 23 | api_url=settings.BRAZE_API_URL, 24 | app_id=settings.BRAZE_APP_ID 25 | ) 26 | -------------------------------------------------------------------------------- /license_manager/apps/api_client/enterprise_catalog.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from license_manager.apps.api_client.base_oauth import BaseOAuthClient 4 | 5 | 6 | class EnterpriseCatalogApiClient(BaseOAuthClient): 7 | """ 8 | API client for calls to the enterprise catalog service. 9 | """ 10 | api_base_url = settings.ENTERPRISE_CATALOG_URL + '/api/v1/' 11 | enterprise_catalog_endpoint = api_base_url + 'enterprise-catalogs/' 12 | distinct_catalog_queries_endpoint = api_base_url + 'distinct-catalog-queries/' 13 | 14 | def contains_content_items(self, catalog_uuid, content_ids): 15 | """ 16 | Check whether the specified enterprise catalog contains the given content. 17 | 18 | Arguments: 19 | catalog_uuid (UUID): UUID of the enterprise catalog to check. 20 | content_ids (list of str): List of content ids to check whether the catalog contains. The endpoint does not 21 | differentiate between course_run_ids and program_uuids so they can be used interchangeably. The two 22 | query parameters are left in for backwards compatability with edx-enterprise. 23 | 24 | Returns: 25 | bool: Whether the given content_ids were found in the specified enterprise catalog. 26 | """ 27 | query_params = {'course_run_ids': content_ids} 28 | endpoint = self.enterprise_catalog_endpoint + str(catalog_uuid) + '/contains_content_items/' 29 | response = self.client.get(endpoint, params=query_params) 30 | response.raise_for_status() 31 | response_json = response.json() 32 | return response_json.get('contains_content_items', False) 33 | 34 | def get_distinct_catalog_queries(self, enterprise_catalog_uuids): 35 | """ 36 | Make a request to the distinct-catalog-queries endpoint to determine 37 | the number of distinct catalog queries used by SubscriptionPlans. 38 | 39 | Arguments: 40 | enterprise_catalog_uuids (list[UUID]): The list of EnterpriseCatalog UUIDs 41 | 42 | Returns: 43 | response (dict): 44 | count (int): number of distinct catalog query UUIDs used 45 | catalog_query_ids (list[int]): IDs of the catalog queries 46 | """ 47 | request_data = { 48 | 'enterprise_catalog_uuids': enterprise_catalog_uuids, 49 | } 50 | response = self.client.post( 51 | self.distinct_catalog_queries_endpoint, 52 | json=request_data, 53 | ) 54 | response.raise_for_status() 55 | return response.json() 56 | 57 | def get_enterprise_catalog(self, catalog_uuid): 58 | """ 59 | Fetch the enterprise catalog with the given uuid. 60 | 61 | Arguments: 62 | catalog_uuid (UUID): UUID of the enterprise catalog 63 | 64 | Returns: 65 | A dictionary representing the enterprise catalog. 66 | """ 67 | endpoint = self.enterprise_catalog_endpoint + str(catalog_uuid) 68 | response = self.client.get(endpoint) 69 | response.raise_for_status() 70 | return response.json() 71 | -------------------------------------------------------------------------------- /license_manager/apps/api_client/lms.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from django.conf import settings 5 | 6 | from license_manager.apps.api_client.base_oauth import BaseOAuthClient 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class LMSApiClient(BaseOAuthClient): 13 | """ 14 | API client for calls to the LMS. 15 | """ 16 | api_base_url = settings.LMS_URL 17 | user_details_endpoint = api_base_url + '/api/user/v1/accounts' 18 | 19 | def fetch_lms_user_id(self, email): 20 | """ 21 | Fetch user details for the specified user email. 22 | 23 | Arguments: 24 | email (str): Email of the user for which we want to fetch details for. 25 | 26 | Returns: 27 | str: lms_user_id of the user. 28 | """ 29 | # {base_api_url}/api/user/v1/accounts?email=edx@example.com 30 | try: 31 | query_params = {'email': email} 32 | response = self.client.get(self.user_details_endpoint, params=query_params) 33 | response.raise_for_status() 34 | response_json = response.json() 35 | return response_json[0].get('id') 36 | except requests.exceptions.HTTPError as exc: 37 | logger.error( 38 | 'Failed to fetch user details for user {email} because {reason}'.format( 39 | email=email, 40 | reason=str(exc), 41 | ) 42 | ) 43 | raise exc 44 | -------------------------------------------------------------------------------- /license_manager/apps/api_client/tests/test_enterprise_catalog_client.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from uuid import uuid4 3 | 4 | import ddt 5 | from django.test import TestCase 6 | 7 | from license_manager.apps.api_client.enterprise_catalog import ( 8 | EnterpriseCatalogApiClient, 9 | ) 10 | 11 | 12 | @ddt.ddt 13 | class EnterpriseCatalogApiClientTests(TestCase): 14 | """ 15 | Tests for the enterprise catalog api client. 16 | """ 17 | 18 | @classmethod 19 | def setUpTestData(cls): 20 | super().setUpTestData() 21 | 22 | cls.uuid = uuid4() 23 | cls.content_ids = ['demoX', 'testX'] 24 | 25 | @mock.patch('license_manager.apps.api_client.base_oauth.OAuthAPIClient', return_value=mock.MagicMock()) 26 | def test_contains_content_items_defaults_false(self, mock_oauth_client): 27 | """ 28 | Verify the `contains_content_items` method returns False if the response does not contain the expected key. 29 | """ 30 | # Mock out the response from the enterprise catalog service 31 | mock_oauth_client().get.return_value.json.return_value = {'0': 'Bad response'} 32 | client = EnterpriseCatalogApiClient() 33 | assert client.contains_content_items(self.uuid, self.content_ids) is False 34 | 35 | @mock.patch('license_manager.apps.api_client.base_oauth.OAuthAPIClient', return_value=mock.MagicMock()) 36 | @ddt.data(True, False) 37 | def test_contains_content_items(self, contains_content, mock_oauth_client): 38 | """ 39 | Verify the `contains_content_items` method returns the value given by the response. 40 | """ 41 | # Mock out the response from the enterprise catalog service 42 | mock_oauth_client().get.return_value.json.return_value = {'contains_content_items': contains_content} 43 | client = EnterpriseCatalogApiClient() 44 | assert client.contains_content_items(self.uuid, self.content_ids) is contains_content 45 | 46 | @mock.patch('license_manager.apps.api_client.base_oauth.OAuthAPIClient', return_value=mock.MagicMock()) 47 | def test_get_enterprise_catalog(self, mock_oauth_client): 48 | """ 49 | Verify the `test_get_enterprise_catalog` method returns the value given by the response. 50 | """ 51 | # Mock out the response from the enterprise catalog service 52 | mock_json_response = {"enterprise_customer": str(self.uuid)} 53 | mock_oauth_client().get.return_value.json.return_value = mock_json_response 54 | client = EnterpriseCatalogApiClient() 55 | assert client.get_enterprise_catalog(self.uuid) == mock_json_response 56 | -------------------------------------------------------------------------------- /license_manager/apps/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/core/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/core/admin.py: -------------------------------------------------------------------------------- 1 | """ Admin configuration for core models. """ 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth.admin import UserAdmin 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from license_manager.apps.core.models import User 8 | 9 | 10 | @admin.register(User) 11 | class CustomUserAdmin(UserAdmin): 12 | """ Admin configuration for the custom User model. """ 13 | list_display = ('username', 'email', 'full_name', 'first_name', 'last_name', 'is_staff') 14 | fieldsets = ( 15 | (None, {'fields': ('username', 'password')}), 16 | (_('Personal info'), {'fields': ('full_name', 'first_name', 'last_name', 'email')}), 17 | (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 18 | 'groups', 'user_permissions')}), 19 | (_('Important dates'), {'fields': ('last_login', 'date_joined')}), 20 | ) 21 | -------------------------------------------------------------------------------- /license_manager/apps/core/constants.py: -------------------------------------------------------------------------------- 1 | """ Constants for the core app. """ 2 | 3 | 4 | class Status: 5 | """Health statuses.""" 6 | OK = "OK" 7 | UNAVAILABLE = "UNAVAILABLE" 8 | -------------------------------------------------------------------------------- /license_manager/apps/core/context_processors.py: -------------------------------------------------------------------------------- 1 | """ Core context processors. """ 2 | from django.conf import settings 3 | 4 | 5 | def core(_request): 6 | """ Site-wide context processor. """ 7 | return { 8 | 'platform_name': settings.PLATFORM_NAME 9 | } 10 | -------------------------------------------------------------------------------- /license_manager/apps/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-04-07 16:41 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0011_update_proxy_permissions'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='User', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 26 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 30 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 32 | ('full_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Full Name')), 33 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 34 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 35 | ], 36 | options={ 37 | 'get_latest_by': 'date_joined', 38 | }, 39 | managers=[ 40 | ('objects', django.contrib.auth.models.UserManager()), 41 | ], 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /license_manager/apps/core/migrations/0002_alter_user_first_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-22 19:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('core', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='first_name', 16 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /license_manager/apps/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/core/migrations/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/core/models.py: -------------------------------------------------------------------------------- 1 | """ Core models. """ 2 | 3 | from django.contrib.auth.models import AbstractUser 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | class User(AbstractUser): 9 | """ 10 | Custom user model for use with python-social-auth via edx-auth-backends. 11 | 12 | .. pii: Stores full name, username, and email address for a user. 13 | .. pii_types: name, username, email_address 14 | .. pii_retirement: local_api 15 | 16 | """ 17 | full_name = models.CharField(_('Full Name'), max_length=255, blank=True, null=True) 18 | 19 | @property 20 | def access_token(self): 21 | """ 22 | Returns an OAuth2 access token for this user, if one exists; otherwise None. 23 | Assumes user has authenticated at least once with the OAuth2 provider (LMS). 24 | """ 25 | try: 26 | return self.social_auth.first().extra_data['access_token'] # pylint: disable=no-member 27 | except Exception: # pylint: disable=broad-except 28 | return None 29 | 30 | class Meta: 31 | get_latest_by = 'date_joined' 32 | 33 | def get_full_name(self): 34 | return self.full_name or super().get_full_name() 35 | 36 | def __str__(self): 37 | return str(self.username) 38 | -------------------------------------------------------------------------------- /license_manager/apps/core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/core/tests/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/core/tests/test_context_processors.py: -------------------------------------------------------------------------------- 1 | """ Context processor tests. """ 2 | 3 | from django.test import RequestFactory, TestCase, override_settings 4 | 5 | from license_manager.apps.core.context_processors import core 6 | 7 | 8 | PLATFORM_NAME = 'Test Platform' 9 | 10 | 11 | class CoreContextProcessorTests(TestCase): 12 | """ Tests for core.context_processors.core """ 13 | 14 | @override_settings(PLATFORM_NAME=PLATFORM_NAME) 15 | def test_core(self): 16 | request = RequestFactory().get('/') 17 | self.assertDictEqual(core(request), {'platform_name': PLATFORM_NAME}) 18 | -------------------------------------------------------------------------------- /license_manager/apps/core/tests/test_models.py: -------------------------------------------------------------------------------- 1 | """ Tests for core models. """ 2 | 3 | from django.test import TestCase 4 | from django_dynamic_fixture import G 5 | from social_django.models import UserSocialAuth 6 | 7 | from license_manager.apps.core.models import User 8 | 9 | 10 | class UserTests(TestCase): 11 | """ User model tests. """ 12 | TEST_CONTEXT = {'foo': 'bar', 'baz': None} 13 | 14 | def test_access_token(self): 15 | user = G(User) 16 | self.assertIsNone(user.access_token) 17 | 18 | social_auth = G(UserSocialAuth, user=user) 19 | self.assertIsNone(user.access_token) 20 | 21 | access_token = 'My voice is my passport. Verify me.' 22 | social_auth.extra_data['access_token'] = access_token 23 | social_auth.save() 24 | self.assertEqual(user.access_token, access_token) 25 | 26 | def test_get_full_name(self): 27 | """ Test that the user model concatenates first and last name if the full name is not set. """ 28 | full_name = 'George Costanza' 29 | user = G(User, full_name=full_name) 30 | self.assertEqual(user.get_full_name(), full_name) 31 | 32 | first_name = 'Jerry' 33 | last_name = 'Seinfeld' 34 | user = G(User, full_name=None, first_name=first_name, last_name=last_name) 35 | expected = f'{first_name} {last_name}' 36 | self.assertEqual(user.get_full_name(), expected) 37 | 38 | user = G(User, full_name=full_name, first_name=first_name, last_name=last_name) 39 | self.assertEqual(user.get_full_name(), full_name) 40 | 41 | def test_string(self): 42 | """Verify that the model's string method returns the user's username.""" 43 | username = 'Bob' 44 | user = G(User, username=username) 45 | self.assertEqual(str(user), username) 46 | -------------------------------------------------------------------------------- /license_manager/apps/core/throttles.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom DRF user-throttling classes 3 | so that we can throttle both bursty and sustained 4 | throughtput. 5 | """ 6 | from django.conf import settings 7 | from rest_framework.throttling import UserRateThrottle 8 | 9 | 10 | class PrivelegedUserThrottle(UserRateThrottle): 11 | """ 12 | Skips throttling is the requesting authenticated user 13 | is in the list of priveleged user ids. 14 | is staff or superuser. 15 | """ 16 | def allow_request(self, request, view): 17 | user = request.user 18 | 19 | if user and user.is_authenticated and user.id in settings.PRIVELEGED_USER_IDS: 20 | return True 21 | 22 | return super().allow_request(request, view) 23 | 24 | 25 | class UserBurstRateThrottle(PrivelegedUserThrottle): 26 | scope = 'user_burst' 27 | 28 | 29 | class UserSustainedRateThrottle(PrivelegedUserThrottle): 30 | scope = 'user_sustained' 31 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/subscriptions/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the Django app config for subscriptions. 3 | """ 4 | import logging 5 | 6 | import analytics 7 | from django.apps import AppConfig 8 | from django.conf import settings 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class SubscriptionsConfig(AppConfig): 15 | """ 16 | The app config for subscriptions. 17 | """ 18 | name = 'license_manager.apps.subscriptions' 19 | default = False 20 | 21 | def ready(self): 22 | if getattr(settings, 'SEGMENT_KEY', None): 23 | logger.debug("Found segment key, setting up") 24 | analytics.write_key = settings.SEGMENT_KEY 25 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/management/commands/manufacture_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Management command for making instances of models with test factories. 3 | """ 4 | 5 | from edx_django_utils.data_generation.management.commands.manufacture_data import \ 6 | Command as BaseCommand 7 | 8 | from license_manager.apps.subscriptions.tests.factories import * 9 | 10 | 11 | class Command(BaseCommand): 12 | """ 13 | Management command for generating Django records from factories with custom attributes 14 | 15 | Example usage: 16 | TODO 17 | $ ./manage.py manufacture_data --model license_manager.apps.subscriptions.models.SubscriptionPlan / 18 | --title "Test Subscription Plan" 19 | """ 20 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/management/commands/seed_development_data.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from license_manager.apps.subscriptions.models import PlanType, Product 4 | 5 | 6 | class Command(BaseCommand): 7 | """ 8 | Management command for populating License Manager data required for development. 9 | """ 10 | 11 | help = 'Seeds all the data required for development.' 12 | 13 | def _create_products(self): 14 | Product.objects.get_or_create( 15 | name='B2B Paid', 16 | description='B2B Catalog', 17 | plan_type=PlanType.objects.get(label="Standard Paid"), 18 | netsuite_id=106 19 | ) 20 | Product.objects.get_or_create( 21 | name='OC Paid', 22 | description='OC Catalog', 23 | plan_type=PlanType.objects.get(label="Standard Paid"), 24 | netsuite_id=110 25 | ) 26 | Product.objects.get_or_create( 27 | name='Trial', 28 | description='Trial Catalog', 29 | plan_type=PlanType.objects.get(label="Trial") 30 | ) 31 | Product.objects.get_or_create( 32 | name='Test', 33 | description='Test Catalog', 34 | plan_type=PlanType.objects.get(label="Test") 35 | ) 36 | Product.objects.get_or_create( 37 | name='OCE', 38 | description='OCE Catalog', 39 | plan_type=PlanType.objects.get(label="OCE") 40 | ) 41 | 42 | def handle(self, *args, **options): 43 | self._create_products() 44 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/management/commands/send_license_utilization_emails.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from license_manager.apps.api.tasks import send_initial_utilization_email_task 6 | from license_manager.apps.subscriptions.models import SubscriptionPlan 7 | from license_manager.apps.subscriptions.utils import localized_utcnow 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Command(BaseCommand): 14 | help = ( 15 | 'Send email alerts to enterprise admins about license utilization of plans with auto-applied licenses.' 16 | ) 17 | 18 | def handle(self, *args, **options): 19 | now = localized_utcnow() 20 | 21 | subscriptions = SubscriptionPlan.objects.filter( 22 | should_auto_apply_licenses=True, 23 | is_active=True, 24 | start_date__lte=now, 25 | expiration_date__gte=now 26 | ).select_related('customer_agreement') 27 | 28 | if not subscriptions: 29 | logger.info('No subscriptions with auto-applied licenses found, skipping license-utilization emails.') 30 | return 31 | 32 | for subscription in subscriptions: 33 | send_initial_utilization_email_task.delay(subscription.uuid) 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/management/commands/tests/test_send_license_utilization_emails.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | from django.test import TestCase 6 | 7 | from license_manager.apps.subscriptions.models import ( 8 | CustomerAgreement, 9 | SubscriptionPlan, 10 | ) 11 | from license_manager.apps.subscriptions.tests.factories import ( 12 | CustomerAgreementFactory, 13 | SubscriptionPlanFactory, 14 | ) 15 | from license_manager.apps.subscriptions.utils import localized_utcnow 16 | 17 | 18 | @pytest.mark.django_db 19 | class SendLicenseUtilizationEmailsTests(TestCase): 20 | command_name = 'send_license_utilization_emails' 21 | now = localized_utcnow() 22 | 23 | @classmethod 24 | def setUpTestData(cls): 25 | super().setUpTestData() 26 | 27 | customer_agreement = CustomerAgreementFactory() 28 | subscription_plan_1 = SubscriptionPlanFactory(customer_agreement=customer_agreement) 29 | subscription_plan_2 = SubscriptionPlanFactory(customer_agreement=customer_agreement) 30 | cls.customer_agreement = customer_agreement 31 | cls.subscription_plan_1 = subscription_plan_1 32 | cls.subscription_plan_2 = subscription_plan_2 33 | 34 | def tearDown(self): 35 | """ 36 | Deletes all renewals, licenses, and subscription after each test method is run. 37 | """ 38 | super().tearDown() 39 | CustomerAgreement.objects.all().delete() 40 | SubscriptionPlan.objects.all().delete() 41 | 42 | def test_send_emails_no_auto_apply_subscriptions(self): 43 | """ 44 | Tests that the rest of the command is skipped if there are no subscriptions with auto-applied licenses. 45 | """ 46 | with self.assertLogs(level='INFO') as log: 47 | call_command(self.command_name) 48 | assert 'No subscriptions with auto-applied licenses found, skipping license-utilization emails.' in log.output[0] 49 | 50 | @mock.patch('license_manager.apps.subscriptions.management.commands.send_license_utilization_emails.send_initial_utilization_email_task') 51 | def test_send_emails_success( 52 | self, 53 | mock_send_initial_utilization_email_task 54 | ): 55 | """ 56 | Tests that send_initial_utilization_email_task is called. 57 | """ 58 | self.subscription_plan_1.should_auto_apply_licenses = True 59 | self.subscription_plan_1.save() 60 | 61 | call_command(self.command_name) 62 | mock_send_initial_utilization_email_task.delay.assert_called_once_with(self.subscription_plan_1.uuid) 63 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0002_auto_20200529_1510.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-05-29 15:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='is_active', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplan', 20 | name='is_active', 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0003_require_date_fields_subscriptionplan.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-06-05 14:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0002_auto_20200529_1510'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicalsubscriptionplan', 15 | name='expiration_date', 16 | field=models.DateField(default='2021-06-05'), 17 | preserve_default=False, 18 | ), 19 | migrations.AlterField( 20 | model_name='historicalsubscriptionplan', 21 | name='purchase_date', 22 | field=models.DateField(default='2020-06-05'), 23 | preserve_default=False, 24 | ), 25 | migrations.AlterField( 26 | model_name='historicalsubscriptionplan', 27 | name='start_date', 28 | field=models.DateField(default='2020-06-05'), 29 | preserve_default=False, 30 | ), 31 | migrations.AlterField( 32 | model_name='subscriptionplan', 33 | name='expiration_date', 34 | field=models.DateField(default='2021-06-05'), 35 | preserve_default=False, 36 | ), 37 | migrations.AlterField( 38 | model_name='subscriptionplan', 39 | name='purchase_date', 40 | field=models.DateField(default='2020-06-05'), 41 | preserve_default=False, 42 | ), 43 | migrations.AlterField( 44 | model_name='subscriptionplan', 45 | name='start_date', 46 | field=models.DateField(default='2020-06-05'), 47 | preserve_default=False, 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0004_add_title_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-06-05 19:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0003_require_date_fields_subscriptionplan'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='title', 16 | field=models.CharField(default='Title', max_length=128), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='subscriptionplan', 21 | name='title', 22 | field=models.CharField(default='Title', max_length=128), 23 | preserve_default=False, 24 | ), 25 | migrations.AlterUniqueTogether( 26 | name='subscriptionplan', 27 | unique_together={('title', 'enterprise_customer_uuid')}, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0006_create_subscription_learner_role.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-06-30 18:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | from license_manager.apps.subscriptions.constants import SUBSCRIPTIONS_LEARNER_ROLE 7 | 8 | 9 | def create_roles(apps, schema_editor): 10 | """ 11 | Create the enterprise subscriptions roles if they do not already exist. 12 | """ 13 | SubscriptionsFeatureRole = apps.get_model('subscriptions', 'SubscriptionsFeatureRole') 14 | SubscriptionsFeatureRole.objects.update_or_create(name=SUBSCRIPTIONS_LEARNER_ROLE) 15 | 16 | 17 | def delete_roles(apps, schema_editor): 18 | """ 19 | Delete the enterprise subscriptions roles. 20 | """ 21 | SubscriptionsFeatureRole = apps.get_model('subscriptions', 'SubscriptionsFeatureRole') 22 | SubscriptionsFeatureRole.objects.filter(name=SUBSCRIPTIONS_LEARNER_ROLE).delete() 23 | 24 | class Migration(migrations.Migration): 25 | 26 | dependencies = [ 27 | ('subscriptions', '0005_subscriptions_feature_roles'), 28 | ] 29 | 30 | operations = [ 31 | migrations.RunPython(create_roles, delete_roles), 32 | ] 33 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0007_add_netsuite_and_salesforce_fields.py: -------------------------------------------------------------------------------- 1 | import django.core.validators 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('subscriptions', '0006_create_subscription_learner_role'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='historicalsubscriptionplan', 14 | name='netsuite_product_id', 15 | field=models.IntegerField(default=1, help_text='Locate the Sales Order record in NetSuite and copy the Product ID field (numeric).'), 16 | preserve_default=False, 17 | ), 18 | migrations.AddField( 19 | model_name='historicalsubscriptionplan', 20 | name='salesforce_opportunity_id', 21 | field=models.CharField(default='000000000000ABCABC', help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters).', max_length=18, validators=[django.core.validators.MinLengthValidator(18)]), 22 | preserve_default=False, 23 | ), 24 | migrations.AddField( 25 | model_name='subscriptionplan', 26 | name='netsuite_product_id', 27 | field=models.IntegerField(default=1, help_text='Locate the Sales Order record in NetSuite and copy the Product ID field (numeric).'), 28 | preserve_default=False, 29 | ), 30 | migrations.AddField( 31 | model_name='subscriptionplan', 32 | name='salesforce_opportunity_id', 33 | field=models.CharField(default='000000000000ABCABC', help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters).', max_length=18, validators=[django.core.validators.MinLengthValidator(18)]), 34 | preserve_default=False, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0008_add_subsc_for_internal_use_only.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-07-08 19:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0007_add_netsuite_and_salesforce_fields'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='for_internal_use_only', 16 | field=models.BooleanField(default=False, help_text='Whether this SubscriptionPlan is only for internal use (e.g. a test Subscription record).'), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplan', 20 | name='for_internal_use_only', 21 | field=models.BooleanField(default=False, help_text='Whether this SubscriptionPlan is only for internal use (e.g. a test Subscription record).'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0009_add_activation_key_to_license.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-07-13 15:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0008_add_subsc_for_internal_use_only'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicallicense', 15 | name='activation_key', 16 | field=models.UUIDField(blank=True, default=None, editable=False, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='license', 20 | name='activation_key', 21 | field=models.UUIDField(blank=True, default=None, editable=False, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0010_add_assigned_date_revoked_date_to_license.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.15 on 2020-08-18 18:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0009_add_activation_key_to_license'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicallicense', 15 | name='assigned_date', 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='historicallicense', 20 | name='revoked_date', 21 | field=models.DateTimeField(blank=True, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name='license', 25 | name='assigned_date', 26 | field=models.DateTimeField(blank=True, null=True), 27 | ), 28 | migrations.AddField( 29 | model_name='license', 30 | name='revoked_date', 31 | field=models.DateTimeField(blank=True, null=True), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0011_change_deactivated_status_to_revoked.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.15 on 2020-08-27 12:38 2 | 3 | from django.db import migrations, models 4 | 5 | from license_manager.apps.subscriptions.constants import ( 6 | DEACTIVATED, 7 | REVOKED, 8 | ) 9 | 10 | 11 | def flip_deactivated_to_revoked(apps, schema_editor): 12 | """ 13 | Change all licenses with a status of `DEACTIVATED` to have a status of `REVOKED`. 14 | """ 15 | License = apps.get_model('subscriptions', 'License') 16 | License.objects.filter(status=DEACTIVATED).update(status=REVOKED) 17 | 18 | 19 | class Migration(migrations.Migration): 20 | 21 | dependencies = [ 22 | ('subscriptions', '0010_add_assigned_date_revoked_date_to_license'), 23 | ] 24 | 25 | operations = [ 26 | migrations.AlterField( 27 | model_name='historicallicense', 28 | name='status', 29 | field=models.CharField(choices=[('activated', 'Activated'), ('assigned', 'Assigned'), ('unassigned', 'Unassigned'), ('revoked', 'Revoked')], default='unassigned', help_text='The status fields has the following options and definitions:\nActive: A license which has been created, assigned to a learner, and the learner has activated the license. The license also must not have expired.\nAssigned: A license which has been created and assigned to a learner, but which has not yet been activated by that learner.\nUnassigned: A license which has been created but does not have a learner assigned to it.\nRevoked: A license which has been created but is no longer active (intentionally revoked or has expired). A license in this state may or may not have a learner assigned.', max_length=25), 30 | ), 31 | migrations.AlterField( 32 | model_name='license', 33 | name='status', 34 | field=models.CharField(choices=[('activated', 'Activated'), ('assigned', 'Assigned'), ('unassigned', 'Unassigned'), ('revoked', 'Revoked')], default='unassigned', help_text='The status fields has the following options and definitions:\nActive: A license which has been created, assigned to a learner, and the learner has activated the license. The license also must not have expired.\nAssigned: A license which has been created and assigned to a learner, but which has not yet been activated by that learner.\nUnassigned: A license which has been created but does not have a learner assigned to it.\nRevoked: A license which has been created but is no longer active (intentionally revoked or has expired). A license in this state may or may not have a learner assigned.', max_length=25), 35 | ), 36 | # There is no reverse function as we can't flip the status back to Deactivated as it's no longer a valid choice 37 | migrations.RunPython(flip_deactivated_to_revoked, migrations.RunPython.noop), 38 | ] 39 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0012_remove_purchase_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2020-09-14 18:48 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0011_change_deactivated_status_to_revoked'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='historicalsubscriptionplan', 15 | name='purchase_date', 16 | ), 17 | migrations.RemoveField( 18 | model_name='subscriptionplan', 19 | name='purchase_date', 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0013_add_revoke_cap_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2020-09-29 22:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0012_remove_purchase_date'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='num_revocations_applied', 16 | field=models.PositiveIntegerField(blank=True, default=0, help_text='Number of revocations applied to Licenses for this SubscriptionPlan.', verbose_name='Number of Revocations Applied'), 17 | ), 18 | migrations.AddField( 19 | model_name='historicalsubscriptionplan', 20 | name='revoke_max_percentage', 21 | field=models.PositiveSmallIntegerField(blank=True, default=5, help_text='Percentage of Licenses that can be revoked for this SubscriptionPlan.'), 22 | ), 23 | migrations.AddField( 24 | model_name='subscriptionplan', 25 | name='num_revocations_applied', 26 | field=models.PositiveIntegerField(blank=True, default=0, help_text='Number of revocations applied to Licenses for this SubscriptionPlan.', verbose_name='Number of Revocations Applied'), 27 | ), 28 | migrations.AddField( 29 | model_name='subscriptionplan', 30 | name='revoke_max_percentage', 31 | field=models.PositiveSmallIntegerField(blank=True, default=5, help_text='Percentage of Licenses that can be revoked for this SubscriptionPlan.'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0015_create_customer_agreements.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | from license_manager.apps.api_client.enterprise import EnterpriseApiClient 4 | 5 | 6 | def create_relationships(apps, schema_editor): 7 | """ 8 | Create new CustomerAgreements from all existing SubscriptionPlans with enterprise customers. 9 | """ 10 | CustomerAgreement = apps.get_model('subscriptions', 'CustomerAgreement') 11 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 12 | 13 | subscriptions_with_customers = SubscriptionPlan.objects.exclude(enterprise_customer_uuid=None) 14 | for plan in subscriptions_with_customers: 15 | customer_uuid = plan.enterprise_customer_uuid 16 | enterprise_slug = EnterpriseApiClient().get_enterprise_customer_data(customer_uuid).get('slug') 17 | customer_agreement, _ = CustomerAgreement.objects.get_or_create( 18 | enterprise_customer_uuid=customer_uuid, 19 | defaults={ 20 | 'enterprise_customer_slug': enterprise_slug, 21 | } 22 | ) 23 | plan.customer_agreement = customer_agreement 24 | plan.save() 25 | 26 | 27 | def delete_relationships(apps, schema_editor): 28 | """ 29 | Delete every CustomerAgreement and any links from plans to CustomerAgreements. 30 | """ 31 | # This step needs to happen first so that all subscriptions aren't removed by the cascading delete. 32 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 33 | SubscriptionPlan.objects.all().update(customer_agreement=None) 34 | 35 | CustomerAgreement = apps.get_model('subscriptions', 'CustomerAgreement') 36 | CustomerAgreement.objects.all().delete() 37 | 38 | 39 | class Migration(migrations.Migration): 40 | 41 | dependencies = [ 42 | ('subscriptions', '0014_add_customer_agreement_and_renewals'), 43 | ] 44 | 45 | operations = [ 46 | migrations.RunPython(create_relationships, delete_relationships), 47 | ] 48 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0016_require_enterprise_catalog_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2020-12-01 14:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0015_create_customer_agreements'), 10 | ] 11 | 12 | operations = [ 13 | # We don't have any subscriptions in stage or production with NULL `enterprise_catalog_uuid` so this one time 14 | # default is for setting old history rows and providing a local development convenience 15 | migrations.AlterField( 16 | model_name='historicalsubscriptionplan', 17 | name='enterprise_catalog_uuid', 18 | field=models.UUIDField(default='00000000-0000-0000-0000-000000000000'), 19 | preserve_default=False, 20 | ), 21 | migrations.AlterField( 22 | model_name='subscriptionplan', 23 | name='enterprise_catalog_uuid', 24 | field=models.UUIDField(default='00000000-0000-0000-0000-000000000000'), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0017_support_customer_agreements.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2020-12-04 14:47 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0016_require_enterprise_catalog_uuid'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='historicalsubscriptionplan', 16 | name='enterprise_catalog_uuid', 17 | field=models.UUIDField(blank=True, help_text="If you do not explicitly set an Enterprise Catalog UUID, it will be set from the Subscription's Customer Agreement `default_enterprise_catalog_uuid`."), 18 | ), 19 | migrations.AlterField( 20 | model_name='subscriptionplan', 21 | name='customer_agreement', 22 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='subscriptions.CustomerAgreement'), 23 | ), 24 | migrations.AlterField( 25 | model_name='subscriptionplan', 26 | name='enterprise_catalog_uuid', 27 | field=models.UUIDField(blank=True, help_text="If you do not explicitly set an Enterprise Catalog UUID, it will be set from the Subscription's Customer Agreement `default_enterprise_catalog_uuid`."), 28 | ), 29 | migrations.AlterUniqueTogether( 30 | name='subscriptionplan', 31 | unique_together={('title', 'customer_agreement')}, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0018_remove_enterprise_customer_uuid_subscriptionplan.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('subscriptions', '0017_support_customer_agreements'), 8 | ] 9 | 10 | operations = [ 11 | migrations.RemoveField( 12 | model_name='historicalsubscriptionplan', 13 | name='enterprise_customer_uuid', 14 | ), 15 | migrations.RemoveField( 16 | model_name='subscriptionplan', 17 | name='enterprise_customer_uuid', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0019_add_expiration_processed_to_subscriptions.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2021-01-08 17:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0018_remove_enterprise_customer_uuid_subscriptionplan'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='expiration_processed', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplan', 20 | name='expiration_processed', 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0020_help_text_for_default_catalog_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2021-02-24 16:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0019_add_expiration_processed_to_subscriptions'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='customeragreement', 15 | name='default_enterprise_catalog_uuid', 16 | field=models.UUIDField(blank=True, help_text='The default enterprise catalog UUID must be from a catalog associated with the above Enterprise Customer UUID.', null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='historicalcustomeragreement', 20 | name='default_enterprise_catalog_uuid', 21 | field=models.UUIDField(blank=True, help_text='The default enterprise catalog UUID must be from a catalog associated with the above Enterprise Customer UUID.', null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0021_subscriptionsroleassignment_applies_to_all_contexts.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.19 on 2021-03-24 19:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0020_help_text_for_default_catalog_uuid'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='subscriptionsroleassignment', 15 | name='applies_to_all_contexts', 16 | field=models.BooleanField(default=False, help_text='If true, indicates that the user is effectively assigned their role for any and all contexts. Defaults to False.'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0022_plantype.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.19 on 2021-05-27 17:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0021_subscriptionsroleassignment_applies_to_all_contexts'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='PlanType', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('label', models.CharField(max_length=128)), 18 | ('description', models.CharField(max_length=255)), 19 | ('is_paid_subscription', models.BooleanField(default=True)), 20 | ('ns_id_required', models.BooleanField(default=True)), 21 | ('sf_id_required', models.BooleanField(default=True)), 22 | ('internal_use_only', models.BooleanField(default=False)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0023_auto_20210527_1848.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.19 on 2021-05-27 18:48 2 | 3 | from django.db import migrations 4 | 5 | 6 | def generate_base_plan_types(apps, schema_editor): 7 | SubscriptionPlanType = apps.get_model("subscriptions", "PlanType") 8 | SubscriptionPlanType.objects.get_or_create( 9 | label='Standard Paid', 10 | description='A paid subscription plan', 11 | is_paid_subscription=True, 12 | ns_id_required=True, 13 | sf_id_required=True, 14 | internal_use_only=False, 15 | ) 16 | SubscriptionPlanType.objects.get_or_create( 17 | label='OCE', 18 | description='Online Campus Essentials, unpaid subscription plan for academic institutions', 19 | is_paid_subscription=False, 20 | ns_id_required=False, 21 | sf_id_required=True, 22 | internal_use_only=False, 23 | ) 24 | SubscriptionPlanType.objects.get_or_create( 25 | label='Trial', 26 | description='Limited free subscription plan for prospective customers', 27 | is_paid_subscription=False, 28 | ns_id_required=False, 29 | sf_id_required=True, 30 | internal_use_only=False, 31 | ) 32 | SubscriptionPlanType.objects.get_or_create( 33 | label='Test', 34 | description='Internal edX subscription testing', 35 | is_paid_subscription=False, 36 | ns_id_required=False, 37 | sf_id_required=False, 38 | internal_use_only=True, 39 | ) 40 | 41 | 42 | class Migration(migrations.Migration): 43 | 44 | dependencies = [ 45 | ('subscriptions', '0022_plantype'), 46 | ] 47 | 48 | operations = [ 49 | migrations.RunPython(generate_base_plan_types), 50 | ] 51 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0024_auto_20210528_1953.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.19 on 2021-05-28 19:53 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0023_auto_20210527_1848'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='historicalsubscriptionplan', 16 | name='plan_type', 17 | field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.PlanType'), 18 | ), 19 | migrations.AddField( 20 | model_name='subscriptionplan', 21 | name='plan_type', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='subscriptions.PlanType'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0026_auto_20210608_1910.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.23 on 2021-06-08 19:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0025_new_renewals_fields'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='customeragreement', 15 | name='disable_expiration_notifications', 16 | field=models.BooleanField(default=False, help_text='Used in MFEs to disable subscription expiration notifications'), 17 | ), 18 | migrations.AddField( 19 | model_name='historicalcustomeragreement', 20 | name='disable_expiration_notifications', 21 | field=models.BooleanField(default=False, help_text='Used in MFEs to disable subscription expiration notifications'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0027_renewal_cap_toggle.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.23 on 2021-06-09 13:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0026_auto_20210608_1910'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='is_revocation_cap_enabled', 16 | field=models.BooleanField(default=False, help_text='Determines whether there is a maximum cap on the number of license revocations for this SubscriptionPlan. Defaults to False.'), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplan', 20 | name='is_revocation_cap_enabled', 21 | field=models.BooleanField(default=False, help_text='Determines whether there is a maximum cap on the number of license revocations for this SubscriptionPlan. Defaults to False.'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0028_plan_type_help_text.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.23 on 2021-06-15 19:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0027_renewal_cap_toggle'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='plantype', 15 | name='internal_use_only', 16 | field=models.BooleanField(default=False, help_text='Marking this indicates this subscription is only used internally by edX employees.'), 17 | ), 18 | migrations.AlterField( 19 | model_name='plantype', 20 | name='is_paid_subscription', 21 | field=models.BooleanField(default=True, help_text='Marking this indicates that the plan is a paid subscription.'), 22 | ), 23 | migrations.AlterField( 24 | model_name='plantype', 25 | name='ns_id_required', 26 | field=models.BooleanField(default=True, help_text='Marking this indicates the NetSuite ID is required.'), 27 | ), 28 | migrations.AlterField( 29 | model_name='plantype', 30 | name='sf_id_required', 31 | field=models.BooleanField(default=True, help_text='Marking this indicates the Salesforce ID is required.'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0029_populate_plan_types.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | def populate_plan(apps, schema_editor): 4 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 5 | PlanType = apps.get_model("subscriptions", "PlanType") 6 | for row in SubscriptionPlan.objects.all(): 7 | if row.netsuite_product_id == 0: 8 | plan = PlanType.objects.get(label='OCE') 9 | elif row.netsuite_product_id == 106 or row.netsuite_product_id == 110: 10 | plan = PlanType.objects.get(label='Standard Paid') 11 | else: 12 | plan = PlanType.objects.get(label='Test') 13 | row.plan_type = plan 14 | row.save() 15 | 16 | def depopulate_plan(apps, schema_editor): 17 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 18 | for row in SubscriptionPlan.objects.all(): 19 | row.plan_type = None 20 | row.save() 21 | 22 | class Migration(migrations.Migration): 23 | dependencies = [ 24 | ('subscriptions', '0028_plan_type_help_text'), 25 | ] 26 | 27 | operations = [ 28 | migrations.RunPython(populate_plan, depopulate_plan), 29 | ] 30 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0030_add_license_renewal_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.23 on 2021-06-22 13:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0029_populate_plan_types'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='historicallicense', 16 | name='renewed_to', 17 | field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.License'), 18 | ), 19 | migrations.AddField( 20 | model_name='license', 21 | name='renewed_to', 22 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_renewed_from', to='subscriptions.License'), 23 | ), 24 | migrations.AlterField( 25 | model_name='historicallicense', 26 | name='status', 27 | field=models.CharField(choices=[('activated', 'Activated'), ('assigned', 'Assigned'), ('unassigned', 'Unassigned'), ('revoked', 'Revoked')], default='unassigned', help_text="The status fields has the following options and definitions:\nActive: A license which has been created, assigned to a learner, and the learner has activated the license. The license also must not have expired.\nAssigned: A license which has been created and assigned to a learner, but which has not yet been activated by that learner.\nUnassigned: A license which has been created but does not have a learner assigned to it.\nRevoked: A license which has been created but is no longer active (intentionally revoked or has expired). A license in this state may or may not have a learner assigned.\nTransferred for renwal: The license's subscription plan was renewed into a new plan, and the license transferred to a new, active license in the renewed plan.", max_length=25), 28 | ), 29 | migrations.AlterField( 30 | model_name='license', 31 | name='status', 32 | field=models.CharField(choices=[('activated', 'Activated'), ('assigned', 'Assigned'), ('unassigned', 'Unassigned'), ('revoked', 'Revoked')], default='unassigned', help_text="The status fields has the following options and definitions:\nActive: A license which has been created, assigned to a learner, and the learner has activated the license. The license also must not have expired.\nAssigned: A license which has been created and assigned to a learner, but which has not yet been activated by that learner.\nUnassigned: A license which has been created but does not have a learner assigned to it.\nRevoked: A license which has been created but is no longer active (intentionally revoked or has expired). A license in this state may or may not have a learner assigned.\nTransferred for renwal: The license's subscription plan was renewed into a new plan, and the license transferred to a new, active license in the renewed plan.", max_length=25), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0031_planemailtemplates.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.23 on 2021-06-24 17:45 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0030_add_license_renewal_field'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='PlanEmailTemplates', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('plaintext_template', models.TextField()), 19 | ('html_template', models.TextField()), 20 | ('subject_line', models.CharField(max_length=100)), 21 | ('template_type', models.TextField()), 22 | ('plan_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='subscriptions.PlanType')), 23 | ], 24 | ), 25 | ] -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0033_auto_20210726_1234.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-07-26 12:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | # currently rerunning the 0029_populate_plan_types to populate subscription plans 7 | # that were created since then with null plantypes 8 | 9 | def populate_plan(apps, schema_editor): 10 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 11 | PlanType = apps.get_model("subscriptions", "PlanType") 12 | for row in SubscriptionPlan.objects.all(): 13 | if row.netsuite_product_id == 0: 14 | plan = PlanType.objects.get(label='OCE') 15 | elif row.netsuite_product_id == 106 or row.netsuite_product_id == 110: 16 | plan = PlanType.objects.get(label='Standard Paid') 17 | else: 18 | plan = PlanType.objects.get(label='Test') 19 | row.plan_type = plan 20 | row.save() 21 | 22 | def depopulate_plan(apps, schema_editor): 23 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 24 | for row in SubscriptionPlan.objects.all(): 25 | row.plan_type = None 26 | row.save() 27 | 28 | class Migration(migrations.Migration): 29 | 30 | dependencies = [ 31 | ('subscriptions', '0032_populate_email_templates'), 32 | ] 33 | 34 | operations = [ 35 | migrations.RunPython(populate_plan, depopulate_plan), 36 | migrations.AlterField( 37 | model_name='subscriptionplan', 38 | name='plan_type', 39 | field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING,to='subscriptions.PlanType') 40 | ) 41 | ] 42 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0034_auto_20210729_1444.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-07-29 14:44 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0033_auto_20210726_1234'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='historicalsubscriptionplan', 16 | name='netsuite_product_id', 17 | field=models.IntegerField(blank=True, help_text='Locate the Sales Order record in NetSuite and copy the Product ID field (numeric).', null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='historicalsubscriptionplan', 21 | name='salesforce_opportunity_id', 22 | field=models.CharField(blank=True, help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters).', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 23 | ), 24 | migrations.AlterField( 25 | model_name='subscriptionplan', 26 | name='netsuite_product_id', 27 | field=models.IntegerField(blank=True, help_text='Locate the Sales Order record in NetSuite and copy the Product ID field (numeric).', null=True), 28 | ), 29 | migrations.AlterField( 30 | model_name='subscriptionplan', 31 | name='salesforce_opportunity_id', 32 | field=models.CharField(blank=True, help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters).', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0035_freeze_unused_licenses.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-08-03 12:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0034_auto_20210729_1444'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='can_freeze_unused_licenses', 16 | field=models.BooleanField(default=False, help_text='Whether this Subscription Plan supports freezing licenses, where unused licenses (not including previously revoked licenses) are deleted.'), 17 | ), 18 | migrations.AddField( 19 | model_name='historicalsubscriptionplan', 20 | name='last_freeze_timestamp', 21 | field=models.DateTimeField(blank=True, help_text='The time at which the Subscription Plan was last frozen.', null=True), 22 | ), 23 | migrations.AddField( 24 | model_name='subscriptionplan', 25 | name='can_freeze_unused_licenses', 26 | field=models.BooleanField(default=False, help_text='Whether this Subscription Plan supports freezing licenses, where unused licenses (not including previously revoked licenses) are deleted.'), 27 | ), 28 | migrations.AddField( 29 | model_name='subscriptionplan', 30 | name='last_freeze_timestamp', 31 | field=models.DateTimeField(blank=True, help_text='The time at which the Subscription Plan was last frozen.', null=True), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0036_add_license_duration_before_purge_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-08-04 18:39 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0035_freeze_unused_licenses'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='customeragreement', 16 | name='license_duration_before_purge', 17 | field=models.DurationField(default=datetime.timedelta(days=90), help_text='The number of days after which unclaimed, revoked, or expired (due to plan expiration) licenses associated with this customer agreement will have user data retired and the license status reset to UNASSIGNED.'), 18 | ), 19 | migrations.AddField( 20 | model_name='historicalcustomeragreement', 21 | name='license_duration_before_purge', 22 | field=models.DurationField(default=datetime.timedelta(days=90), help_text='The number of days after which unclaimed, revoked, or expired (due to plan expiration) licenses associated with this customer agreement will have user data retired and the license status reset to UNASSIGNED.'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0037_alter_agreement_slug_allow_null.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-08-09 17:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0036_add_license_duration_before_purge_field'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='customeragreement', 15 | name='enterprise_customer_slug', 16 | field=models.CharField(blank=True, max_length=128, null=True, unique=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='historicalcustomeragreement', 20 | name='enterprise_customer_slug', 21 | field=models.CharField(blank=True, db_index=True, max_length=128, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0038_alter_datefield_to_datetimefield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-08-30 13:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0037_alter_agreement_slug_allow_null'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicalsubscriptionplan', 15 | name='expiration_date', 16 | field=models.DateTimeField(), 17 | ), 18 | migrations.AlterField( 19 | model_name='historicalsubscriptionplan', 20 | name='start_date', 21 | field=models.DateTimeField(), 22 | ), 23 | migrations.AlterField( 24 | model_name='historicalsubscriptionplanrenewal', 25 | name='effective_date', 26 | field=models.DateTimeField(help_text='The date that the subscription renewal will take place on.'), 27 | ), 28 | migrations.AlterField( 29 | model_name='historicalsubscriptionplanrenewal', 30 | name='renewed_expiration_date', 31 | field=models.DateTimeField(help_text='The date that the renewed subscription should expire on.'), 32 | ), 33 | migrations.AlterField( 34 | model_name='subscriptionplan', 35 | name='expiration_date', 36 | field=models.DateTimeField(), 37 | ), 38 | migrations.AlterField( 39 | model_name='subscriptionplan', 40 | name='start_date', 41 | field=models.DateTimeField(), 42 | ), 43 | migrations.AlterField( 44 | model_name='subscriptionplanrenewal', 45 | name='effective_date', 46 | field=models.DateTimeField(help_text='The date that the subscription renewal will take place on.'), 47 | ), 48 | migrations.AlterField( 49 | model_name='subscriptionplanrenewal', 50 | name='renewed_expiration_date', 51 | field=models.DateTimeField(help_text='The date that the renewed subscription should expire on.'), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0039_auto_20210920_1759.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-09-20 17:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0038_alter_datefield_to_datetimefield'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicallicense', 15 | name='status', 16 | field=models.CharField(choices=[('activated', 'Activated'), ('assigned', 'Assigned'), ('unassigned', 'Unassigned'), ('revoked', 'Revoked')], default='unassigned', help_text='The status fields has the following options and definitions:\nActive: A license which has been created, assigned to a learner, and the learner has activated the license. The license also must not have expired.\nAssigned: A license which has been created and assigned to a learner, but which has not yet been activated by that learner.\nUnassigned: A license which has been created but does not have a learner assigned to it.\nRevoked: A license which has been created but is no longer active (intentionally revoked or has expired). A license in this state may or may not have a learner assigned.', max_length=25), 17 | ), 18 | migrations.AlterField( 19 | model_name='license', 20 | name='status', 21 | field=models.CharField(choices=[('activated', 'Activated'), ('assigned', 'Assigned'), ('unassigned', 'Unassigned'), ('revoked', 'Revoked')], default='unassigned', help_text='The status fields has the following options and definitions:\nActive: A license which has been created, assigned to a learner, and the learner has activated the license. The license also must not have expired.\nAssigned: A license which has been created and assigned to a learner, but which has not yet been activated by that learner.\nUnassigned: A license which has been created but does not have a learner assigned to it.\nRevoked: A license which has been created but is no longer active (intentionally revoked or has expired). A license in this state may or may not have a learner assigned.', max_length=25), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0040_add_enterprise_customer_name_to_customer_agreement.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-10-05 16:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0039_auto_20210920_1759'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='customeragreement', 15 | name='enterprise_customer_name', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='historicalcustomeragreement', 20 | name='enterprise_customer_name', 21 | field=models.CharField(blank=True, max_length=255, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0042_auto_20211022_1904.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-22 19:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0041_historicalnotification_notification'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicallicense', 15 | name='auto_applied', 16 | field=models.BooleanField(blank=True, help_text='Whether or not License was auto-applied.', null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='license', 20 | name='auto_applied', 21 | field=models.BooleanField(blank=True, help_text='Whether or not License was auto-applied.', null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0043_auto_20211022_2122.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-22 21:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0042_auto_20211022_1904'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='should_auto_apply_licenses', 16 | field=models.BooleanField(blank=True, help_text='Whether licenses from this Subscription Plan should be auto applied.', null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplan', 20 | name='should_auto_apply_licenses', 21 | field=models.BooleanField(blank=True, help_text='Whether licenses from this Subscription Plan should be auto applied.', null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0044_auto_20211104_1451.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-04 14:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0043_auto_20211022_2122'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplanrenewal', 15 | name='disable_auto_apply_licenses', 16 | field=models.BooleanField(default=False, help_text='Whether auto-applied licenses should be disabled for the future plan. If the original plan was not auto applying licenses, modifying this field will have no effect.'), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplanrenewal', 20 | name='disable_auto_apply_licenses', 21 | field=models.BooleanField(default=False, help_text='Whether auto-applied licenses should be disabled for the future plan. If the original plan was not auto applying licenses, modifying this field will have no effect.'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0045_auto_20211109_1429.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-09 14:29 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('subscriptions', '0044_auto_20211104_1451'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='historicalnotification', 17 | name='subscripton_plan', 18 | field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.subscriptionplan'), 19 | ), 20 | migrations.AddField( 21 | model_name='notification', 22 | name='subscripton_plan', 23 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='subscriptions.subscriptionplan'), 24 | ), 25 | migrations.AlterField( 26 | model_name='historicalnotification', 27 | name='last_sent', 28 | field=models.DateTimeField(default=django.utils.timezone.now, help_text='Date of the last time a notifcation was sent.'), 29 | ), 30 | migrations.AlterField( 31 | model_name='notification', 32 | name='last_sent', 33 | field=models.DateTimeField(default=django.utils.timezone.now, help_text='Date of the last time a notifcation was sent.'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0047_populate_subscription_product.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from collections import defaultdict 3 | 4 | def populate_product(apps, schema_editor): 5 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 6 | Product = apps.get_model("subscriptions", "Product") 7 | 8 | product_by_ns_id = {} 9 | products_by_plan_type_id = defaultdict(list) 10 | 11 | for product in Product.objects.all(): 12 | if product.netsuite_id: 13 | # Netsuite Id is unique amongst plans 14 | product_by_ns_id[product.netsuite_id] = product 15 | 16 | products_by_plan_type_id[product.plan_type_id].append(product) 17 | 18 | for plan in SubscriptionPlan.objects.all().iterator(): 19 | plan_uuid = plan.uuid 20 | ns_id = plan.netsuite_product_id 21 | plan_type_id = plan.plan_type_id 22 | products_with_plan_type = products_by_plan_type_id.get(plan_type_id) 23 | 24 | if ns_id: 25 | # Netsuite Id must be correct for each plan before this migration is run 26 | product_to_associate = product_by_ns_id.get(ns_id) 27 | 28 | if not product_to_associate: 29 | product_to_associate = products_with_plan_type[0] 30 | print(f"Associated subscription plan {plan_uuid} to {product_to_associate}, Netsuite Id {ns_id} was ignored.") 31 | 32 | plan.product = product_to_associate 33 | plan.save() 34 | else: 35 | product_to_associate = products_with_plan_type[0] 36 | plan.product = product_to_associate 37 | plan.save() 38 | 39 | 40 | def depopulate_product(apps, schema_editor): 41 | SubscriptionPlan = apps.get_model('subscriptions', 'SubscriptionPlan') 42 | for row in SubscriptionPlan.objects.all(): 43 | row.product = None 44 | row.save() 45 | 46 | class Migration(migrations.Migration): 47 | dependencies = [ 48 | ('subscriptions', '0046_add_product_and_association_to_subscription_plan'), 49 | ] 50 | 51 | operations = [ 52 | migrations.RunPython(populate_product, depopulate_product), 53 | ] 54 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0048_delete_planemailtemplates.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-14 14:48 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0047_populate_subscription_product'), 10 | ] 11 | 12 | operations = [ 13 | migrations.DeleteModel( 14 | name='PlanEmailTemplates', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0049_add_disable_onboarding_notifications.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-15 17:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0048_delete_planemailtemplates'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='customeragreement', 15 | name='disable_onboarding_notifications', 16 | field=models.BooleanField(default=False, help_text='Used to disable onboarding notifications, i.e. license assignment and post-activation emails.'), 17 | ), 18 | migrations.AddField( 19 | model_name='historicalcustomeragreement', 20 | name='disable_onboarding_notifications', 21 | field=models.BooleanField(default=False, help_text='Used to disable onboarding notifications, i.e. license assignment and post-activation emails.'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0050_make_subscriptionplan_plan_type_nullable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-16 19:54 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0049_add_disable_onboarding_notifications'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='subscriptionplan', 16 | name='plan_type', 17 | field=models.ForeignKey(blank=True, help_text='DEPRECATED in favor product.plan_type. Locate the Sales Order record in NetSuite and copy the Product ID field (numeric).', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='subscriptions.plantype'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0051_remove_subscriptionplan_deprecated_columns.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-16 20:02 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0050_make_subscriptionplan_plan_type_nullable'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='historicalsubscriptionplan', 15 | name='netsuite_product_id', 16 | ), 17 | migrations.RemoveField( 18 | model_name='historicalsubscriptionplan', 19 | name='plan_type', 20 | ), 21 | migrations.RemoveField( 22 | model_name='subscriptionplan', 23 | name='netsuite_product_id', 24 | ), 25 | migrations.RemoveField( 26 | model_name='subscriptionplan', 27 | name='plan_type', 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0052_remove_license_unique_constraints.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-15 14:09 2 | 3 | from django.db import migrations, models 4 | import license_manager.apps.subscriptions.utils 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0051_remove_subscriptionplan_deprecated_columns'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='historicalnotification', 16 | name='last_sent', 17 | field=models.DateTimeField(default=license_manager.apps.subscriptions.utils.localized_utcnow, help_text='Date of the last time a notifcation was sent.'), 18 | ), 19 | migrations.AlterField( 20 | model_name='notification', 21 | name='last_sent', 22 | field=models.DateTimeField(default=license_manager.apps.subscriptions.utils.localized_utcnow, help_text='Date of the last time a notifcation was sent.'), 23 | ), 24 | migrations.AlterUniqueTogether( 25 | name='license', 26 | unique_together=set(), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0053_auto_20220301_1642.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-03-01 16:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0052_remove_license_unique_constraints'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicallicense', 15 | name='lms_user_id', 16 | field=models.IntegerField(blank=True, db_index=True, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='historicallicense', 20 | name='user_email', 21 | field=models.EmailField(blank=True, db_index=True, max_length=254, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name='license', 25 | name='lms_user_id', 26 | field=models.IntegerField(blank=True, db_index=True, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='license', 30 | name='user_email', 31 | field=models.EmailField(blank=True, db_index=True, max_length=254, null=True), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0054_auto_20220908_1747.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-08 17:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0053_auto_20220301_1642'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalproduct', 15 | name='salesforce_product_id', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='product', 20 | name='salesforce_product_id', 21 | field=models.CharField(blank=True, max_length=255, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0055_auto_20220916_1840.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-16 18:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0054_auto_20220908_1747'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicalproduct', 15 | name='netsuite_id', 16 | field=models.IntegerField(blank=True, help_text='(Deprecated) The Product ID field (numeric) of what was sold to the customer.', null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='product', 20 | name='netsuite_id', 21 | field=models.IntegerField(blank=True, help_text='(Deprecated) The Product ID field (numeric) of what was sold to the customer.', null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0056_auto_20230530_1901.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.19 on 2023-05-30 19:01 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0055_auto_20220916_1840'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='historicalsubscriptionplan', 16 | name='salesforce_opportunity_line_item', 17 | field=models.CharField(blank=True, help_text='Locate the appropriate Salesforce Opportunity Line Item record and copy the Opportunity Product field (18 characters).', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 18 | ), 19 | migrations.AddField( 20 | model_name='subscriptionplan', 21 | name='salesforce_opportunity_line_item', 22 | field=models.CharField(blank=True, help_text='Locate the appropriate Salesforce Opportunity Line Item record and copy the Opportunity Product field (18 characters).', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 23 | ), 24 | migrations.AlterField( 25 | model_name='historicalsubscriptionplan', 26 | name='salesforce_opportunity_id', 27 | field=models.CharField(blank=True, help_text='Deprecated 18 character value, derived from Salesforce Opportunity record and copied to the Opportunity ID field.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 28 | ), 29 | migrations.AlterField( 30 | model_name='subscriptionplan', 31 | name='salesforce_opportunity_id', 32 | field=models.CharField(blank=True, help_text='Deprecated 18 character value, derived from Salesforce Opportunity record and copied to the Opportunity ID field.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0057_auto_20230915_0722.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.21 on 2023-09-15 07:22 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('subscriptions', '0056_auto_20230530_1901'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='historicalsubscriptionplan', 16 | name='salesforce_opportunity_id', 17 | field=models.CharField(blank=True, help_text='Deprecated -- 18 character value, derived from Salesforce Opportunity record.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 18 | ), 19 | migrations.AlterField( 20 | model_name='historicalsubscriptionplan', 21 | name='salesforce_opportunity_line_item', 22 | field=models.CharField(blank=True, help_text='18 character value -- Locate the appropriate Salesforce Opportunity Line Item record and copy it here.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 23 | ), 24 | migrations.AlterField( 25 | model_name='historicalsubscriptionplanrenewal', 26 | name='salesforce_opportunity_id', 27 | field=models.CharField(help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters). Note that this is not the same Salesforce Opportunity ID associated with the linked subscription.', max_length=18, validators=[django.core.validators.MinLengthValidator(18)], verbose_name='Salesforce Opportunity Line Item'), 28 | ), 29 | migrations.AlterField( 30 | model_name='subscriptionplan', 31 | name='salesforce_opportunity_id', 32 | field=models.CharField(blank=True, help_text='Deprecated -- 18 character value, derived from Salesforce Opportunity record.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 33 | ), 34 | migrations.AlterField( 35 | model_name='subscriptionplan', 36 | name='salesforce_opportunity_line_item', 37 | field=models.CharField(blank=True, help_text='18 character value -- Locate the appropriate Salesforce Opportunity Line Item record and copy it here.', max_length=18, null=True, validators=[django.core.validators.MinLengthValidator(18)]), 38 | ), 39 | migrations.AlterField( 40 | model_name='subscriptionplanrenewal', 41 | name='salesforce_opportunity_id', 42 | field=models.CharField(help_text='Locate the appropriate Salesforce Opportunity record and copy the Opportunity ID field (18 characters). Note that this is not the same Salesforce Opportunity ID associated with the linked subscription.', max_length=18, validators=[django.core.validators.MinLengthValidator(18)], verbose_name='Salesforce Opportunity Line Item'), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0058_subscriptionlicensesource_subscriptionlicensesourcetype.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.21 on 2023-09-18 04:32 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('subscriptions', '0057_auto_20230915_0722'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='SubscriptionLicenseSourceType', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 22 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 23 | ('name', models.CharField(max_length=64)), 24 | ('slug', models.SlugField(max_length=30, unique=True)), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | ), 30 | migrations.CreateModel( 31 | name='SubscriptionLicenseSource', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 35 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 36 | ('source_id', models.CharField(help_text='18 character value -- Salesforce Opportunity ID', max_length=18, validators=[django.core.validators.MinLengthValidator(18)])), 37 | ('license', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='source', to='subscriptions.license')), 38 | ('source_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='subscriptions.subscriptionlicensesourcetype')), 39 | ], 40 | options={ 41 | 'abstract': False, 42 | }, 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0059_add_subscriptionlicensesourcetypes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.21 on 2023-09-18 04:32 2 | 3 | from django.db import migrations 4 | 5 | 6 | LICENSE_SOURCE_TYPES = { 7 | 'Application Management Technology': 'AMT' 8 | } 9 | 10 | 11 | def add_license_source_types(apps, schema_editor): 12 | license_source_type_model = apps.get_model('subscriptions', 'SubscriptionLicenseSourceType') 13 | for name, slug in LICENSE_SOURCE_TYPES.items(): 14 | license_source_type_model.objects.update_or_create(name=name, slug=slug) 15 | 16 | 17 | def delete_license_source_types(apps, schema_editor): 18 | license_source_type_model = apps.get_model('subscriptions', 'SubscriptionLicenseSourceType') 19 | license_source_type_model.objects.filter(name__in=LICENSE_SOURCE_TYPES).delete() 20 | 21 | 22 | class Migration(migrations.Migration): 23 | 24 | dependencies = [ 25 | ('subscriptions', '0058_subscriptionlicensesource_subscriptionlicensesourcetype'), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython( 30 | code=add_license_source_types, 31 | reverse_code=delete_license_source_types 32 | ) 33 | 34 | ] 35 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0060_historicalsubscriptionlicensesource.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.21 on 2023-09-19 06:38 2 | 3 | from django.conf import settings 4 | import django.core.validators 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | import model_utils.fields 9 | import simple_history.models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ('subscriptions', '0059_add_subscriptionlicensesourcetypes'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='HistoricalSubscriptionLicenseSource', 22 | fields=[ 23 | ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), 24 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 25 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 26 | ('source_id', models.CharField(help_text='18 character value -- Salesforce Opportunity ID', max_length=18, validators=[django.core.validators.MinLengthValidator(18)])), 27 | ('history_id', models.AutoField(primary_key=True, serialize=False)), 28 | ('history_date', models.DateTimeField()), 29 | ('history_change_reason', models.CharField(max_length=100, null=True)), 30 | ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), 31 | ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), 32 | ('license', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.license')), 33 | ('source_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.subscriptionlicensesourcetype')), 34 | ], 35 | options={ 36 | 'verbose_name': 'historical subscription license source', 37 | 'ordering': ('-history_date', '-history_id'), 38 | 'get_latest_by': 'history_date', 39 | }, 40 | bases=(simple_history.models.HistoricalChanges, models.Model), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0061_auto_20230927_1119.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.21 on 2023-09-27 11:19 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0060_historicalsubscriptionlicensesource'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='historicalcustomeragreement', 15 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Customer Agreement', 'verbose_name_plural': 'historical Customer Agreements'}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='historicallicense', 19 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical license', 'verbose_name_plural': 'historical licenses'}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name='historicalnotification', 23 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical notification', 'verbose_name_plural': 'historical notifications'}, 24 | ), 25 | migrations.AlterModelOptions( 26 | name='historicalproduct', 27 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical product', 'verbose_name_plural': 'historical products'}, 28 | ), 29 | migrations.AlterModelOptions( 30 | name='historicalsubscriptionlicensesource', 31 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical subscription license source', 'verbose_name_plural': 'historical subscription license sources'}, 32 | ), 33 | migrations.AlterModelOptions( 34 | name='historicalsubscriptionplan', 35 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Subscription Plan', 'verbose_name_plural': 'historical Subscription Plans'}, 36 | ), 37 | migrations.AlterModelOptions( 38 | name='historicalsubscriptionplanrenewal', 39 | options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Subscription Plan Renewal', 'verbose_name_plural': 'historical Subscription Plan Renewals'}, 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0063_transfer_all_licenses.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.8 on 2024-01-09 15:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0062_add_license_transfer_job'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicallicensetransferjob', 15 | name='transfer_all', 16 | field=models.BooleanField(default=False, help_text='Set to true to transfer ALL licenses from old to new plan, regardless of status.'), 17 | ), 18 | migrations.AddField( 19 | model_name='licensetransferjob', 20 | name='transfer_all', 21 | field=models.BooleanField(default=False, help_text='Set to true to transfer ALL licenses from old to new plan, regardless of status.'), 22 | ), 23 | migrations.AlterField( 24 | model_name='historicallicensetransferjob', 25 | name='license_uuids_raw', 26 | field=models.TextField(blank=True, help_text='Delimitted (with newlines by default) list of license_uuids to transfer', null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='licensetransferjob', 30 | name='license_uuids_raw', 31 | field=models.TextField(blank=True, help_text='Delimitted (with newlines by default) list of license_uuids to transfer', null=True), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0064_subscriptionplan_desired_num_licenses.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-24 03:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0063_transfer_all_licenses'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='historicalsubscriptionplan', 15 | name='desired_num_licenses', 16 | field=models.PositiveIntegerField(blank=True, help_text='Total number of licenses that should exist for this SubscriptionPlan. The total license count (provisioned asynchronously) will reach the desired amount eventually. Empty (NULL) means no attempts will be made to asynchronously provision licenses.', null=True, verbose_name='Desired Number of Licenses'), 17 | ), 18 | migrations.AddField( 19 | model_name='subscriptionplan', 20 | name='desired_num_licenses', 21 | field=models.PositiveIntegerField(blank=True, help_text='Total number of licenses that should exist for this SubscriptionPlan. The total license count (provisioned asynchronously) will reach the desired amount eventually. Empty (NULL) means no attempts will be made to asynchronously provision licenses.', null=True, verbose_name='Desired Number of Licenses'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0065_subscriptionplan_desired_num_licenses_not_editable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-27 00:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0064_subscriptionplan_desired_num_licenses'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicalsubscriptionplan', 15 | name='desired_num_licenses', 16 | field=models.PositiveIntegerField(blank=True, editable=False, help_text='Total number of licenses that should exist for this SubscriptionPlan. The total license count (provisioned asynchronously) will reach the desired amount eventually. Empty (NULL) means no attempts will be made to asynchronously provision licenses.', null=True, verbose_name='Desired Number of Licenses'), 17 | ), 18 | migrations.AlterField( 19 | model_name='subscriptionplan', 20 | name='desired_num_licenses', 21 | field=models.PositiveIntegerField(blank=True, editable=False, help_text='Total number of licenses that should exist for this SubscriptionPlan. The total license count (provisioned asynchronously) will reach the desired amount eventually. Empty (NULL) means no attempts will be made to asynchronously provision licenses.', null=True, verbose_name='Desired Number of Licenses'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0066_license_subscription_plan_status_idx.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-29 20:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0065_subscriptionplan_desired_num_licenses_not_editable'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name='license', 15 | index=models.Index(fields=['subscription_plan', 'status'], name='subscription_plan_status_idx'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0067_editable_desired_num_licenses.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-05-08 14:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0066_license_subscription_plan_status_idx'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='historicalsubscriptionplan', 15 | name='desired_num_licenses', 16 | field=models.PositiveIntegerField(blank=True, help_text='Total number of licenses that should exist for this SubscriptionPlan. The total license count (provisioned asynchronously) will reach the desired amount eventually. Empty (NULL) means no attempts will be made to asynchronously provision licenses.', null=True, verbose_name='Desired Number of Licenses'), 17 | ), 18 | migrations.AlterField( 19 | model_name='subscriptionplan', 20 | name='desired_num_licenses', 21 | field=models.PositiveIntegerField(blank=True, help_text='Total number of licenses that should exist for this SubscriptionPlan. The total license count (provisioned asynchronously) will reach the desired amount eventually. Empty (NULL) means no attempts will be made to asynchronously provision licenses.', null=True, verbose_name='Desired Number of Licenses'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0068_licenseevent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-06-25 07:35 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | import model_utils.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('subscriptions', '0067_editable_desired_num_licenses'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='LicenseEvent', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 21 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 22 | ('event_name', models.CharField(max_length=255)), 23 | ('license', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='events', to='subscriptions.license')), 24 | ], 25 | options={ 26 | 'verbose_name': 'License Triggered Event', 27 | 'verbose_name_plural': 'License Triggered Events', 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0069_alter_customeragreement_disable_expiration_notifications_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-07-09 16:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0068_licenseevent'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='customeragreement', 15 | name='disable_expiration_notifications', 16 | field=models.BooleanField(default=False, help_text='Used to disable subscription expiration notifications, and the expiration date in the subsidy summary box in the enterprise learner portal MFE. If the subscription is expired, the subsidy summary box will not display the subscription status with the expired messaging.'), 17 | ), 18 | migrations.AlterField( 19 | model_name='historicalcustomeragreement', 20 | name='disable_expiration_notifications', 21 | field=models.BooleanField(default=False, help_text='Used to disable subscription expiration notifications, and the expiration date in the subsidy summary box in the enterprise learner portal MFE. If the subscription is expired, the subsidy summary box will not display the subscription status with the expired messaging.'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0071_customeragreement_enable_auto_applied_subscriptions_with_universal_link_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-10-07 15:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0070_customeragreement_expired_subscription_modal_messaging_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='customeragreement', 15 | name='enable_auto_applied_subscriptions_with_universal_link', 16 | field=models.BooleanField(default=False, help_text='By default, auto-applied subscriptions are only granted when learners join their enterprise via SSO, checking this box will enable subscription licenses to be applied when a learner joins the enterprise via Universal link as well'), 17 | ), 18 | migrations.AddField( 19 | model_name='historicalcustomeragreement', 20 | name='enable_auto_applied_subscriptions_with_universal_link', 21 | field=models.BooleanField(default=False, help_text='By default, auto-applied subscriptions are only granted when learners join their enterprise via SSO, checking this box will enable subscription licenses to be applied when a learner joins the enterprise via Universal link as well'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0072_customeragreement_button_label_in_modal_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-10-18 09:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0071_customeragreement_enable_auto_applied_subscriptions_with_universal_link_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='customeragreement', 15 | name='button_label_in_modal', 16 | field=models.CharField(blank=True, help_text='The text that will appear as on the button in the expiration modal', max_length=255, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='customeragreement', 20 | name='modal_header_text', 21 | field=models.CharField(blank=True, help_text='The bold text that will appear as the header in the expiration modal.', max_length=512, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name='customeragreement', 25 | name='url_for_button_in_modal', 26 | field=models.CharField(blank=True, help_text='The URL that should underly the sole button in the expiration modal', max_length=512, null=True), 27 | ), 28 | migrations.AddField( 29 | model_name='historicalcustomeragreement', 30 | name='button_label_in_modal', 31 | field=models.CharField(blank=True, help_text='The text that will appear as on the button in the expiration modal', max_length=255, null=True), 32 | ), 33 | migrations.AddField( 34 | model_name='historicalcustomeragreement', 35 | name='modal_header_text', 36 | field=models.CharField(blank=True, help_text='The bold text that will appear as the header in the expiration modal.', max_length=512, null=True), 37 | ), 38 | migrations.AddField( 39 | model_name='historicalcustomeragreement', 40 | name='url_for_button_in_modal', 41 | field=models.CharField(blank=True, help_text='The URL that should underly the sole button in the expiration modal', max_length=512, null=True), 42 | ), 43 | migrations.AlterField( 44 | model_name='customeragreement', 45 | name='expired_subscription_modal_messaging', 46 | field=models.TextField(blank=True, help_text='The content of a modal that will appear to learners upon subscription expiration. This text can be used for custom guidance per customer.', null=True), 47 | ), 48 | migrations.AlterField( 49 | model_name='historicalcustomeragreement', 50 | name='expired_subscription_modal_messaging', 51 | field=models.TextField(blank=True, help_text='The content of a modal that will appear to learners upon subscription expiration. This text can be used for custom guidance per customer.', null=True), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/0073_remove_customeragreement_hyper_link_text_for_expired_modal_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-10-23 08:16 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0072_customeragreement_button_label_in_modal_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='customeragreement', 15 | name='hyper_link_text_for_expired_modal', 16 | ), 17 | migrations.RemoveField( 18 | model_name='customeragreement', 19 | name='url_for_expired_modal', 20 | ), 21 | migrations.RemoveField( 22 | model_name='historicalcustomeragreement', 23 | name='hyper_link_text_for_expired_modal', 24 | ), 25 | migrations.RemoveField( 26 | model_name='historicalcustomeragreement', 27 | name='url_for_expired_modal', 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/subscriptions/migrations/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/sanitize.py: -------------------------------------------------------------------------------- 1 | import bleach 2 | from bleach.css_sanitizer import CSSSanitizer 3 | 4 | 5 | def sanitize_html(html_content): 6 | """ 7 | Sanitize HTML content to allow only safe tags and attributes, 8 | while disallowing JavaScript and unsafe protocols. 9 | """ 10 | # Define allowed tags and attributes 11 | allowed_tags = set.union(set(bleach.ALLOWED_TAGS), {"span"}) # Allow all standard HTML tags 12 | allowed_attrs = {"*": ["className", "class", "style", "id"]} 13 | css_sanitizer = CSSSanitizer(allowed_css_properties=["color", "font-weight"]) 14 | 15 | # Clean the HTML content 16 | sanitized_content = bleach.clean( 17 | html_content, 18 | tags=allowed_tags, 19 | attributes=allowed_attrs, 20 | strip=True, # Strip disallowed tags completely 21 | protocols=["http", "https"], # Only allow http and https URLs, 22 | css_sanitizer=css_sanitizer, 23 | ) 24 | 25 | # Use bleach.linkify to ensure no javascript: links in tags 26 | sanitized_content = bleach.linkify( 27 | sanitized_content, 28 | callbacks=[ 29 | bleach.callbacks.nofollow 30 | ], # Apply 'nofollow' to external links for safety 31 | ) 32 | 33 | return sanitized_content 34 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/templates/admin/bulk_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | 3 | {% block content %} 4 |
{% csrf_token %} 5 | {{ form }} 6 |

The count of {{ model_name }} records to be deleted: {{ record_count }}

7 |

Really delete all of these records?

8 | 9 |
10 | 11 | 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/apps/subscriptions/tests/__init__.py -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/tests/test_factories.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from license_manager.apps.subscriptions.constants import UNASSIGNED 4 | from license_manager.apps.subscriptions.tests.factories import ( 5 | CustomerAgreementFactory, 6 | LicenseFactory, 7 | SubscriptionPlanFactory, 8 | SubscriptionPlanRenewalFactory, 9 | ) 10 | 11 | 12 | class SubscriptionsModelFactoryTests(TestCase): 13 | """ 14 | Tests on the model factories for subscriptions. 15 | """ 16 | 17 | def test_license_factory(self): 18 | """ 19 | Verify an unassigned license is created and associated with a subscription. 20 | """ 21 | _license = LicenseFactory() 22 | self.assertEqual(_license.status, UNASSIGNED) 23 | subscription_licenses = [_license.uuid for _license in _license.subscription_plan.licenses.all()] 24 | self.assertIn(_license.uuid, subscription_licenses) 25 | 26 | def test_subscription_factory(self): 27 | """ 28 | Verify an unexpired subscription plan is created by default. 29 | """ 30 | subscription = SubscriptionPlanFactory() 31 | self.assertTrue(subscription.start_date < subscription.expiration_date) # pylint: disable=wrong-assert-type 32 | 33 | def test_subscription_factory_licenses(self): 34 | """ 35 | Verify a subscription plan factory can have licenses associated with it. 36 | """ 37 | subscription = SubscriptionPlanFactory() 38 | licenses = LicenseFactory.create_batch(5) 39 | subscription.licenses.set(licenses) 40 | # Verify the subscription plan uuid is correctly set on the licenses 41 | _license = subscription.licenses.first() 42 | self.assertEqual(subscription.uuid, _license.subscription_plan.uuid) 43 | 44 | def test_customer_agreement_factory(self): 45 | """ 46 | Verify the customer agreement factory performs a get_or_create when using the same unique identifiers 47 | """ 48 | customer_agreement = CustomerAgreementFactory() 49 | self.assertTrue(customer_agreement) 50 | customer_agreement_2 = CustomerAgreementFactory( 51 | enterprise_customer_uuid=customer_agreement.enterprise_customer_uuid, 52 | enterprise_customer_slug=customer_agreement.enterprise_customer_slug, 53 | ) 54 | assert customer_agreement == customer_agreement_2 55 | 56 | def test_subscription_plan_renewal_factory(self): 57 | """ 58 | Verify an unexpired subscription plan renewal is created by default. 59 | """ 60 | subscription_renewal = SubscriptionPlanRenewalFactory() 61 | # pylint: disable=wrong-assert-type 62 | self.assertTrue(subscription_renewal.effective_date < subscription_renewal.renewed_expiration_date) 63 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the utils.py module. 3 | """ 4 | import base64 5 | import hashlib 6 | import hmac 7 | import uuid 8 | from unittest import TestCase, mock 9 | 10 | import ddt 11 | 12 | from license_manager.apps.subscriptions import utils 13 | 14 | 15 | def test_get_subsidy_checksum(): 16 | lms_user_id = 123 17 | course_key = 'demoX' 18 | license_uuid = uuid.uuid4() 19 | 20 | with mock.patch( 21 | 'license_manager.apps.subscriptions.utils.settings.ENTERPRISE_SUBSIDY_CHECKSUM_SECRET_KEY', 22 | 'foo', 23 | ): 24 | message = f'{lms_user_id}:{course_key}:{license_uuid}' 25 | expected_checksum = base64.b64encode( 26 | hmac.digest(b'foo', message.encode(), hashlib.sha256), 27 | ).decode() 28 | 29 | assert hmac.compare_digest( 30 | expected_checksum, 31 | utils.get_subsidy_checksum(lms_user_id, course_key, license_uuid), 32 | ) 33 | 34 | 35 | @ddt.ddt 36 | class TestBatchCounts(TestCase): 37 | """ 38 | Tests for batch_counts(). 39 | """ 40 | 41 | @ddt.data( 42 | { 43 | 'total_count': 0, 44 | 'batch_size': 5, 45 | 'expected_batch_counts': [], 46 | }, 47 | { 48 | 'total_count': 4, 49 | 'batch_size': 5, 50 | 'expected_batch_counts': [4], 51 | }, 52 | { 53 | 'total_count': 5, 54 | 'batch_size': 5, 55 | 'expected_batch_counts': [5], 56 | }, 57 | { 58 | 'total_count': 6, 59 | 'batch_size': 5, 60 | 'expected_batch_counts': [5, 1], 61 | }, 62 | { 63 | 'total_count': 23, 64 | 'batch_size': 5, 65 | 'expected_batch_counts': [5, 5, 5, 5, 3], 66 | }, 67 | # Just make sure something weird doesn't happen when the batch size is 1. 68 | { 69 | 'total_count': 5, 70 | 'batch_size': 1, 71 | 'expected_batch_counts': [1, 1, 1, 1, 1], 72 | }, 73 | ) 74 | @ddt.unpack 75 | def test_batch_counts(self, total_count, batch_size, expected_batch_counts): 76 | """ 77 | Test batch_counts(). 78 | """ 79 | actual_batch_counts = list(utils.batch_counts(total_count, batch_size=batch_size)) 80 | assert actual_batch_counts == expected_batch_counts 81 | -------------------------------------------------------------------------------- /license_manager/apps/subscriptions/urls_admin.py: -------------------------------------------------------------------------------- 1 | from dal import autocomplete 2 | from django.urls import re_path as url 3 | 4 | from .models import SubscriptionPlan 5 | 6 | 7 | class FilteredSubscriptionPlanView(autocomplete.Select2QuerySetView): 8 | """ 9 | Supports filtering of LicenseTransferJob SubscriptionPlan 10 | choices to only those plans associated with the selected 11 | customer agreement. 12 | 13 | This is used by the LicenseTransferJobAdminForm.Meta.widgets 14 | property, which forwards the customer_agreement identifier 15 | into this view, so that it can filter the queryset of 16 | available subscription plans to only those plans 17 | associated with the selected customer agreement. 18 | """ 19 | def get_queryset(self): 20 | queryset = super().get_queryset() 21 | customer_agreement = self.forwarded.get('customer_agreement', None) 22 | if customer_agreement: 23 | queryset = queryset.filter(customer_agreement=customer_agreement) 24 | return queryset 25 | 26 | 27 | urlpatterns = [ 28 | url( 29 | 'filtered-subscription-plan-admin/$', 30 | FilteredSubscriptionPlanView.as_view(model=SubscriptionPlan), 31 | name='filtered_subscription_plan_admin', 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /license_manager/celery.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the Celery application for the license_manager project 3 | """ 4 | from celery import Celery 5 | 6 | 7 | app = Celery('license_manager', ) 8 | 9 | # - namespace='CELERY' means all celery-related configuration keys 10 | # should have a `CELERY_` prefix. 11 | app.config_from_object('django.conf:settings', namespace="CELERY") 12 | 13 | # Load task modules from all registered Django app configs. 14 | app.autodiscover_tasks() 15 | 16 | 17 | if __name__ == '__main__': 18 | app.start() 19 | -------------------------------------------------------------------------------- /license_manager/conf/locale/config.yaml: -------------------------------------------------------------------------------- 1 | # Configuration for i18n workflow. 2 | 3 | locales: 4 | - en # English - Source Language 5 | - am # Amharic 6 | - ar # Arabic 7 | - az # Azerbaijani 8 | - bg_BG # Bulgarian (Bulgaria) 9 | - bn_BD # Bengali (Bangladesh) 10 | - bn_IN # Bengali (India) 11 | - bs # Bosnian 12 | - ca # Catalan 13 | - ca@valencia # Catalan (Valencia) 14 | - cs # Czech 15 | - cy # Welsh 16 | - da # Danish 17 | - de_DE # German (Germany) 18 | - el # Greek 19 | - en # English 20 | - en_GB # English (United Kingdom) 21 | # Don't pull these until we figure out why pages randomly display in these locales, 22 | # when the user's browser is in English and the user is not logged in. 23 | # - en@lolcat # LOLCAT English 24 | # - en@pirate # Pirate English 25 | - es_419 # Spanish (Latin America) 26 | - es_AR # Spanish (Argentina) 27 | - es_EC # Spanish (Ecuador) 28 | - es_ES # Spanish (Spain) 29 | - es_MX # Spanish (Mexico) 30 | - es_PE # Spanish (Peru) 31 | - et_EE # Estonian (Estonia) 32 | - eu_ES # Basque (Spain) 33 | - fa # Persian 34 | - fa_IR # Persian (Iran) 35 | - fi_FI # Finnish (Finland) 36 | - fil # Filipino 37 | - fr # French 38 | - gl # Galician 39 | - gu # Gujarati 40 | - he # Hebrew 41 | - hi # Hindi 42 | - hr # Croatian 43 | - hu # Hungarian 44 | - hy_AM # Armenian (Armenia) 45 | - id # Indonesian 46 | - it_IT # Italian (Italy) 47 | - ja_JP # Japanese (Japan) 48 | - kk_KZ # Kazakh (Kazakhstan) 49 | - km_KH # Khmer (Cambodia) 50 | - kn # Kannada 51 | - ko_KR # Korean (Korea) 52 | - lt_LT # Lithuanian (Lithuania) 53 | - ml # Malayalam 54 | - mn # Mongolian 55 | - mr # Marathi 56 | - ms # Malay 57 | - nb # Norwegian Bokmål 58 | - ne # Nepali 59 | - nl_NL # Dutch (Netherlands) 60 | - or # Oriya 61 | - pl # Polish 62 | - pt_BR # Portuguese (Brazil) 63 | - pt_PT # Portuguese (Portugal) 64 | - ro # Romanian 65 | - ru # Russian 66 | - si # Sinhala 67 | - sk # Slovak 68 | - sl # Slovenian 69 | - sq # Albanian 70 | - sr # Serbian 71 | - ta # Tamil 72 | - te # Telugu 73 | - th # Thai 74 | - tr_TR # Turkish (Turkey) 75 | - uk # Ukranian 76 | - ur # Urdu 77 | - uz # Uzbek 78 | - vi # Vietnamese 79 | - zh_CN # Chinese (China) 80 | - zh_HK # Chinese (Hong Kong) 81 | - zh_TW # Chinese (Taiwan) 82 | 83 | # The locales used for fake-accented English, for testing. 84 | dummy_locales: 85 | - eo 86 | -------------------------------------------------------------------------------- /license_manager/docker_gunicorn_configuration.py: -------------------------------------------------------------------------------- 1 | """ 2 | gunicorn configuration file: http://docs.gunicorn.org/en/develop/configure.html 3 | """ 4 | import multiprocessing # pylint: disable=unused-import 5 | 6 | 7 | preload_app = True 8 | timeout = 300 9 | bind = "0.0.0.0:18170" 10 | 11 | workers = 2 12 | 13 | 14 | def pre_request(worker, req): 15 | worker.log.info("%s %s" % (req.method, req.path)) 16 | 17 | 18 | # pylint: disable=import-outside-toplevel 19 | def close_all_caches(): 20 | """ 21 | Close the cache so that newly forked workers cannot accidentally share 22 | the socket with the processes they were forked from. This prevents a race 23 | condition in which one worker could get a cache response intended for 24 | another worker. 25 | """ 26 | # We do this in a way that is safe for 1.4 and 1.8 while we still have some 27 | # 1.4 installations. 28 | from django.conf import settings 29 | from django.core import cache as django_cache 30 | if hasattr(django_cache, 'caches'): 31 | get_cache = django_cache.caches.__getitem__ 32 | else: 33 | get_cache = django_cache.get_cache # pylint: disable=no-member 34 | for cache_name in settings.CACHES: 35 | cache = get_cache(cache_name) 36 | if hasattr(cache, 'close'): 37 | cache.close() 38 | 39 | # The 1.4 global default cache object needs to be closed also: 1.4 40 | # doesn't ensure you get the same object when requesting the same 41 | # cache. The global default is a separate Python object from the cache 42 | # you get with get_cache("default"), so it will have its own connection 43 | # that needs to be closed. 44 | cache = django_cache.cache 45 | if hasattr(cache, 'close'): 46 | cache.close() 47 | 48 | 49 | def post_fork(server, worker): # pylint: disable=unused-argument 50 | close_all_caches() 51 | 52 | 53 | def when_ready(server): # pylint: disable=unused-argument 54 | """When running in debug mode, run Django's `check` to better match what `manage.py runserver` does""" 55 | from django.conf import settings 56 | from django.core.management import call_command 57 | if settings.DEBUG: 58 | call_command("check") 59 | -------------------------------------------------------------------------------- /license_manager/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/settings/__init__.py -------------------------------------------------------------------------------- /license_manager/settings/local.py: -------------------------------------------------------------------------------- 1 | from license_manager.settings.base import * 2 | 3 | DEBUG = True 4 | 5 | # CACHE CONFIGURATION 6 | # See: https://docs.djangoproject.com/en/dev/ref/settings/#caches 7 | CACHES = { 8 | 'default': { 9 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 10 | } 11 | } 12 | # END CACHE CONFIGURATION 13 | 14 | # DATABASE CONFIGURATION 15 | # See: https://docs.djangoproject.com/en/dev/ref/settings/#databases 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | 'NAME': root('default.db'), 20 | 'USER': '', 21 | 'PASSWORD': '', 22 | 'HOST': '', 23 | 'PORT': '', 24 | } 25 | } 26 | # END DATABASE CONFIGURATION 27 | 28 | # TOOLBAR CONFIGURATION 29 | # See: http://django-debug-toolbar.readthedocs.org/en/latest/installation.html 30 | if os.environ.get('ENABLE_DJANGO_TOOLBAR', False): 31 | INSTALLED_APPS += ( 32 | 'debug_toolbar', 33 | ) 34 | 35 | MIDDLEWARE += ( 36 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 37 | ) 38 | 39 | DEBUG_TOOLBAR_PATCH_SETTINGS = False 40 | 41 | DEBUG_TOOLBAR_CONFIG = { 42 | 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG, 43 | } 44 | 45 | INTERNAL_IPS = ('127.0.0.1',) 46 | # END TOOLBAR CONFIGURATION 47 | 48 | # AUTHENTICATION 49 | # Use a non-SSL URL for authorization redirects 50 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = False 51 | 52 | # API GATEWAY Settings 53 | API_GATEWAY_URL = 'api.gateway.url' 54 | 55 | # Generic OAuth2 variables irrespective of SSO/backend service key types. 56 | OAUTH2_PROVIDER_URL = 'http://localhost:18000/oauth2' 57 | 58 | JWT_AUTH.update({ 59 | 'JWT_ALGORITHM': 'HS256', 60 | 'JWT_SECRET_KEY': SOCIAL_AUTH_EDX_OAUTH2_SECRET, 61 | 'JWT_ISSUER': OAUTH2_PROVIDER_URL, 62 | 'JWT_AUDIENCE': SOCIAL_AUTH_EDX_OAUTH2_KEY, 63 | }) 64 | 65 | ENABLE_AUTO_AUTH = True 66 | 67 | LOGGING = get_logger_config(debug=DEBUG, format_string=LOGGING_FORMAT_STRING) 68 | 69 | LOG_SQL = False 70 | 71 | ##################################################################### 72 | # Lastly, see if the developer has any local overrides. 73 | if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')): 74 | from .private import * # pylint: disable=import-error 75 | 76 | # LOG_SQL may be set to True in private.py, which will 77 | # enable logging of SQL statements via the django.db.backends module. 78 | if LOG_SQL: 79 | LOGGING['loggers']['django.db.backends'] = { 80 | 'level': 'DEBUG', 81 | 'handlers': ['console'], 82 | # We have a root 'django' logger enabled 83 | # that we don't want to propagage too, so that 84 | # this doesn't print multiple times. 85 | 'propagate': False, 86 | } 87 | -------------------------------------------------------------------------------- /license_manager/settings/private.py.example: -------------------------------------------------------------------------------- 1 | SOCIAL_AUTH_EDX_OAUTH2_KEY = 'replace-me' 2 | SOCIAL_AUTH_EDX_OAUTH2_SECRET = 'replace-me' 3 | SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = 'http://127.0.0.1:8000/oauth' 4 | BACKEND_SERVICE_EDX_OAUTH2_KEY = 'license_manager-backend-service-key' 5 | BACKEND_SERVICE_EDX_OAUTH2_SECRET = 'license_manager-backend-service-secret' 6 | 7 | SEGMENT_KEY = 'replace-me' 8 | -------------------------------------------------------------------------------- /license_manager/settings/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from license_manager.settings.base import * 4 | import tempfile 5 | 6 | # API GATEWAY Settings 7 | API_GATEWAY_URL = 'api.gateway.url' 8 | 9 | # IN-MEMORY TEST DATABASE 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': ':memory:', 14 | 'USER': '', 15 | 'PASSWORD': '', 16 | 'HOST': '', 17 | 'PORT': '', 18 | }, 19 | } 20 | # END IN-MEMORY TEST DATABASE 21 | 22 | # BEGIN CELERY 23 | CELERY_TASK_ALWAYS_EAGER = True 24 | results_dir = tempfile.TemporaryDirectory() 25 | CELERY_RESULT_BACKEND = f'file://{results_dir.name}' 26 | # END CELERY 27 | 28 | # Increase throttle thresholds for tests 29 | REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = { 30 | 'anon': '2400/minute', 31 | 'user_burst': '120/second', 32 | 'user_sustained': '2400/minute', 33 | } 34 | 35 | # Make some loggers less noisy (useful during test failure) 36 | import logging 37 | 38 | for logger_to_silence in ['faker', 'jwkest', 'edx_rest_framework_extensions']: 39 | logging.getLogger(logger_to_silence).setLevel(logging.WARNING) 40 | # Specifically silence license manager event_utils warnings 41 | logging.getLogger('event_utils').setLevel(logging.ERROR) 42 | 43 | # Django Admin Settings 44 | VALIDATE_FORM_EXTERNAL_FIELDS = False 45 | DEBUG = False 46 | 47 | # Disable toolbar callback 48 | DEBUG_TOOLBAR_CONFIG = { 49 | "SHOW_TOOLBAR_CALLBACK": False, 50 | } 51 | -------------------------------------------------------------------------------- /license_manager/static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/static/.keep -------------------------------------------------------------------------------- /license_manager/static/filtered_subscription_admin.js: -------------------------------------------------------------------------------- 1 | // Execute custom JS for django-autocomplete-light 2 | // after django.jQuery is defined. 3 | // Clears subscription plan selections when the selected 4 | // customer agreement is changed in the LicenseTransferJobAdminForm. 5 | window.addEventListener("load", function() { 6 | (function($) { 7 | $(':input[name$=customer_agreement]').on('change', function() { 8 | $(':input[name=old_subscription_plan]').val(null).trigger('change'); 9 | $(':input[name=new_subscription_plan]').val(null).trigger('change'); 10 | }); 11 | })(django.jQuery); 12 | }); 13 | -------------------------------------------------------------------------------- /license_manager/static/img/edX_Icon_1000courses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/static/img/edX_Icon_1000courses.png -------------------------------------------------------------------------------- /license_manager/static/img/edX_Icon_CGvirtualproctor_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/static/img/edX_Icon_CGvirtualproctor_2.png -------------------------------------------------------------------------------- /license_manager/static/img/edX_Icon_LearningNeuroscience.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/license-manager/36dead8dfd5592b05d844e17141d6dbc7aaac4f9/license_manager/static/img/edX_Icon_LearningNeuroscience.png -------------------------------------------------------------------------------- /license_manager/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing utilities for license-manager 3 | """ 4 | import requests 5 | 6 | 7 | class MockResponse(requests.Response): 8 | def __init__(self, json_data, status_code, content=None): 9 | super().__init__() 10 | 11 | self.json_data = json_data 12 | self.status_code = status_code 13 | self._content = content 14 | 15 | def json(self): # pylint: disable=arguments-differ 16 | return self.json_data 17 | -------------------------------------------------------------------------------- /license_manager/urls.py: -------------------------------------------------------------------------------- 1 | """license_manager URL Configuration 2 | The `urlpatterns` list routes URLs to views. For more information please see: 3 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 4 | Examples: 5 | Function views 6 | 1. Add an import: from my_app import views 7 | 2. Add a URL to urlpatterns: re_path(r'^$', views.home, name='home') 8 | Class-based views 9 | 1. Add an import: from other_app.views import Home 10 | 2. Add a URL to urlpatterns: re_path(r'^$', Home.as_view(), name='home') 11 | Including another URLconf 12 | 1. Add an import: from blog import urls as blog_urls 13 | 2. Add a URL to urlpatterns: re_path(r'^blog/', include(blog_urls)) 14 | """ 15 | 16 | import os 17 | 18 | from auth_backends.urls import oauth2_urlpatterns 19 | from django.conf import settings 20 | from django.contrib import admin 21 | from django.urls import include, path 22 | from drf_spectacular.views import ( 23 | SpectacularAPIView, 24 | SpectacularRedocView, 25 | SpectacularSwaggerView, 26 | ) 27 | 28 | from license_manager.apps.api import urls as api_urls 29 | from license_manager.apps.core import views as core_views 30 | from license_manager.apps.subscriptions import urls_admin as subs_url_admin 31 | 32 | 33 | admin.autodiscover() 34 | 35 | spectacular_view = SpectacularAPIView( 36 | api_version='v1', 37 | title='license manager spectacular view', 38 | ) 39 | spec_swagger_view = SpectacularSwaggerView() 40 | 41 | spec_redoc_view = SpectacularRedocView( 42 | title='Redoc view for the license manager API.', 43 | url_name='schema', 44 | ) 45 | 46 | urlpatterns = [ 47 | path('', include(oauth2_urlpatterns)), 48 | path('', include('csrf.urls')), # Include csrf urls from edx-drf-extensions 49 | path('admin/', admin.site.urls), 50 | path('admin-custom/subscriptions/', include(subs_url_admin)), 51 | path('api/', include(api_urls)), 52 | path('auto_auth/', core_views.AutoAuth.as_view(), name='auto_auth'), 53 | path('health/', core_views.health, name='health'), 54 | # All the API docs 55 | path('api-docs/', spec_swagger_view.as_view(), name='api-docs'), 56 | path('api/schema/', SpectacularAPIView.as_view(), name='schema'), 57 | path('api/schema/redoc/', spec_redoc_view.as_view(url_name='schema'), name='redoc'), 58 | path('api/schema/swagger-ui/', spec_swagger_view.as_view(url_name='schema'), name='swaggger-ui'), 59 | ] 60 | 61 | 62 | if settings.DEBUG and os.environ.get('ENABLE_DJANGO_TOOLBAR', False): # pragma: no cover 63 | # Disable pylint import error because we don't install django-debug-toolbar 64 | # for CI build 65 | import debug_toolbar # pylint: disable=import-error,useless-suppression 66 | urlpatterns.append(path('__debug__/', include(debug_toolbar.urls))) 67 | -------------------------------------------------------------------------------- /license_manager/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for license_manager. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | import os 10 | from os.path import abspath, dirname 11 | from sys import path 12 | 13 | from django.conf import settings 14 | from django.contrib.staticfiles.handlers import StaticFilesHandler 15 | from django.core.wsgi import get_wsgi_application 16 | 17 | 18 | SITE_ROOT = dirname(dirname(abspath(__file__))) 19 | path.append(SITE_ROOT) 20 | 21 | application = get_wsgi_application() # pylint: disable=invalid-name 22 | 23 | # Allows the gunicorn app to serve static files in development environment. 24 | # Without this, css in django admin will not be served locally. 25 | if settings.DEBUG: 26 | application = StaticFilesHandler(get_wsgi_application()) 27 | else: 28 | application = get_wsgi_application() 29 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Django administration utility. 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | if __name__ == "__main__": 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "license_manager.settings.local") 12 | 13 | from django.core.management import execute_from_command_line 14 | 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /provision-license-manager.sh: -------------------------------------------------------------------------------- 1 | name="license_manager" 2 | port="18170" 3 | 4 | docker-compose up -d 5 | 6 | # Install requirements 7 | # Can be skipped right now because we're using the --build flag on docker-compose. This will need to be changed once we move to devstack. 8 | 9 | # Wait for MySQL 10 | echo "Waiting for MySQL" 11 | until docker exec -i license-manager.mysql mysql -u root -se "SELECT EXISTS(SELECT 1 FROM mysql.user WHERE user = 'root')" &> /dev/null 12 | do 13 | printf "." 14 | sleep 1 15 | done 16 | sleep 5 17 | 18 | # Run migrations 19 | echo -e "${GREEN}Running migrations for ${name}...${NC}" 20 | docker exec -t license-manager.app bash -c "cd /edx/app/${name}/ && make migrate" 21 | 22 | # Seed data for development 23 | echo -e "${GREEN}Seeding development data..." 24 | docker exec -t license-manager.app bash -c "python manage.py seed_development_data" 25 | # Some migrations require development data to be seeded, hence migrating again. 26 | docker exec -t license-manager.app bash -c "make migrate" 27 | 28 | # Create superuser 29 | echo -e "${GREEN}Creating super-user for ${name}...${NC}" 30 | docker exec -t license-manager.app bash -c "echo 'from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser(\"edx\", \"edx@example.com\", \"edx\") if not User.objects.filter(username=\"edx\").exists() else None' | python /edx/app/${name}/manage.py shell" 31 | 32 | # Provision IDA User in LMS 33 | echo -e "${GREEN}Provisioning ${name}_worker in LMS...${NC}" 34 | docker exec -t edx.devstack.lms bash -c "source /edx/app/edxapp/edxapp_env && python /edx/app/edxapp/edx-platform/manage.py lms --settings=devstack_docker manage_user ${name}_worker ${name}_worker@example.com --staff --superuser" 35 | 36 | # Create the DOT applications - one for single sign-on and one for backend service IDA-to-IDA authentication. 37 | docker exec -t edx.devstack.lms bash -c "source /edx/app/edxapp/edxapp_env && python /edx/app/edxapp/edx-platform/manage.py lms --settings=devstack_docker create_dot_application --grant-type authorization-code --skip-authorization --redirect-uris 'http://localhost:${port}/complete/edx-oauth2/' --client-id '${name}-sso-key' --client-secret '${name}-sso-secret' --scopes 'user_id' ${name}-sso ${name}_worker" 38 | docker exec -t edx.devstack.lms bash -c "source /edx/app/edxapp/edxapp_env && python /edx/app/edxapp/edx-platform/manage.py lms --settings=devstack_docker create_dot_application --grant-type client-credentials --client-id '${name}-backend-service-key' --client-secret '${name}-backend-service-secret' ${name}-backend-service ${name}_worker" 39 | 40 | # Restart container 41 | docker-compose restart app 42 | -------------------------------------------------------------------------------- /pylintrc_tweaks: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore+= ,migrations, settings, wsgi.py 3 | 4 | [BASIC] 5 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns|logger|User)$ 6 | 7 | [MESSAGES CONTROL] 8 | disable+ = 9 | import-outside-toplevel, 10 | inconsistent-return-statements, 11 | no-else-break, 12 | no-else-continue, 13 | useless-object-inheritance, 14 | useless-suppression, 15 | cyclic-import, 16 | logging-format-interpolation, 17 | invalid-name, 18 | consider-using-f-string, 19 | missing-module-docstring, 20 | missing-class-docstring, 21 | missing-timeout, 22 | unsupported-binary-operation, 23 | no-member, 24 | useless-option-value, 25 | unknown-option-value, 26 | too-many-positional-arguments, 27 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = license_manager.settings.test 3 | addopts = --cov license_manager --cov-report term-missing --cov-report xml 4 | norecursedirs = .* docs requirements 5 | 6 | # Filter depr warnings coming from packages that we can't control. 7 | filterwarnings = 8 | ignore:.*urlresolvers is deprecated in favor of.*:DeprecationWarning:auth_backends.views:5 9 | ignore:.*invalid escape sequence.*:DeprecationWarning:.*(newrelic|uritemplate|psutil).* 10 | ignore:.*the imp module is deprecated in favour of importlib.*:DeprecationWarning:.*distutils.* 11 | -------------------------------------------------------------------------------- /pytest.local.ini: -------------------------------------------------------------------------------- 1 | # This makes it easier to get coverage reports for only specific modules 2 | # when running pytest locally, for example: 3 | # pytest -W ignore license_manager/apps/subscriptions/management/commands/tests/test_validate_num_catalog_queries.py -c pytest.local.ini --reuse-db 4 | [pytest] 5 | DJANGO_SETTINGS_MODULE = license_manager.settings.test 6 | addopts = --cov-report term-missing --cov-report xml -W ignore 7 | norecursedirs = .* docs requirements 8 | 9 | # Filter depr warnings coming from packages that we can't control. 10 | filterwarnings = 11 | ignore:.*urlresolvers is deprecated in favor of.*:DeprecationWarning:auth_backends.views:5 12 | ignore:.*invalid escape sequence.*:DeprecationWarning:.*(newrelic|uritemplate|psutil).* 13 | ignore:.*the imp module is deprecated in favour of importlib.*:DeprecationWarning:.*distutils.* 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file is here because many Platforms as a Service look for 2 | # requirements.txt in the root directory of a project. 3 | -r requirements/production.txt 4 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | 2 | # Core requirements for using this application 3 | 4 | -c constraints.txt 5 | 6 | analytics-python 7 | backoff 8 | celery 9 | Django 10 | # https://django-autocomplete-light.readthedocs.io/en/master/ 11 | # Supports admin field choices that depend on other fields 12 | django-autocomplete-light 13 | django-celery-results 14 | django-cors-headers 15 | django-durationwidget 16 | django-extensions 17 | django-filter 18 | django-model-utils 19 | djangoql 20 | drf-spectacular 21 | django-ses 22 | rules 23 | django-simple-history 24 | django-waffle 25 | djangorestframework 26 | djangorestframework-csv 27 | drf-nested-routers 28 | edx-auth-backends 29 | edx-braze-client 30 | edx-celeryutils 31 | edx-django-utils 32 | edx-drf-extensions 33 | edx-toggles 34 | edx-rbac 35 | edx-rest-api-client 36 | mysqlclient 37 | pytz 38 | redis 39 | rules 40 | simplejson 41 | zipp 42 | django-log-request-id 43 | bleach 44 | bleach[css] 45 | -------------------------------------------------------------------------------- /requirements/common_constraints.txt: -------------------------------------------------------------------------------- 1 | # This is a temporary solution to override the real common_constraints.txt 2 | # In edx-lint, until the pyjwt constraint in edx-lint has been removed. 3 | # See BOM-2721 for more details. 4 | # Below is the copied and edited version of common_constraints 5 | 6 | # A central location for most common version constraints 7 | # (across edx repos) for pip-installation. 8 | # 9 | # Similar to other constraint files this file doesn't install any packages. 10 | # It specifies version constraints that will be applied if a package is needed. 11 | # When pinning something here, please provide an explanation of why it is a good 12 | # idea to pin this package across all edx repos, Ideally, link to other information 13 | # that will help people in the future to remove the pin when possible. 14 | # Writing an issue against the offending project and linking to it here is good. 15 | # 16 | # Note: Changes to this file will automatically be used by other repos, referencing 17 | # this file from Github directly. It does not require packaging in edx-lint. 18 | 19 | # using LTS django version 20 | Django<5.0 21 | 22 | # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. 23 | # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html 24 | # See https://github.com/openedx/edx-platform/issues/35126 for more info 25 | elasticsearch<7.14.0 26 | 27 | # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected 28 | 29 | 30 | # Cause: https://github.com/openedx/edx-lint/issues/458 31 | # This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved. 32 | pip<24.3 33 | 34 | # Cause: https://github.com/openedx/edx-lint/issues/475 35 | # This can be unpinned once https://github.com/openedx/edx-lint/issues/476 has been resolved. 36 | urllib3<2.3.0 37 | -------------------------------------------------------------------------------- /requirements/constraints.txt: -------------------------------------------------------------------------------- 1 | # Version constraints for pip-installation. 2 | # 3 | # This file doesn't install any packages. It specifies version constraints 4 | # that will be applied if a package is needed. 5 | # 6 | # When pinning something here, please provide an explanation of why. Ideally, 7 | # link to other information that will help people in the future to remove the 8 | # pin when possible. Writing an issue against the offending project and 9 | # linking to it here is good. 10 | 11 | -c common_constraints.txt 12 | 13 | # diff-cover latest requires (pluggy>=0.13.1,<0.14.0) 14 | # which conflicts with pytest(pluggy>=0.12,<2.0.0) and tox(pluggy>0.12) both of these fetch pluggy==1.0.0 15 | # but diff-cover latest has a pin (pluggy<1.0.0a1) 16 | # Using the same version of diff-cover which is being used currently in edx-platform to avoid this conflict. 17 | diff-cover==4.0.0 18 | 19 | # For python greater than or equal to 3.9 backports.zoneinfo is causing failures 20 | backports.zoneinfo;python_version<"3.9" 21 | 22 | # path>16.14.0 has removed the deprecated abspath function, which is breaking the docs build 23 | path<16.15.0 24 | 25 | # The newer version if this package has drop support for background tasks so that why 26 | # when i include that new version (7.0.0) in the requirements upgrade, most of the jobs start failing 27 | # so i have to pin this version to 6.1.0 28 | edx-django-utils==6.1.0 29 | 30 | # pinning braze-client below version 1, which will likely introduce a breaking-change 31 | # as the package is converted to an openedx plugin. 32 | # https://github.com/edx/braze-client/pull/30 33 | edx-braze-client<1 34 | -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | # Additional requirements for development of this application 2 | 3 | -c constraints.txt 4 | 5 | -r pip-tools.txt # pip-tools and its deps, for managing requirements files 6 | -r validation.txt # Core, testing, and quality check dependencies 7 | 8 | ddt # Data-driven tests 9 | diff-cover # Changeset diff test coverage 10 | django-debug-toolbar # For debugging Django 11 | django-extensions # Custom dev extensions 12 | gunicorn 13 | inflect 14 | pywatchman # More effecient checking for runserver reload trigger events -------------------------------------------------------------------------------- /requirements/doc.in: -------------------------------------------------------------------------------- 1 | # Requirements for documentation validation 2 | 3 | -c constraints.txt 4 | 5 | -r test.txt # Core and testing dependencies for this package 6 | 7 | doc8 # reStructuredText style checker 8 | sphinx-book-theme # Common theme for all Open edX projects 9 | readme_renderer # Validates README.rst for usage on PyPI 10 | Sphinx # Documentation builder 11 | -------------------------------------------------------------------------------- /requirements/monitoring/requirements.txt: -------------------------------------------------------------------------------- 1 | # This requirements.txt file is meant to pull in other requirements.txt files so that 2 | # dependency monitoring tools like gemnasium.com can easily process them. 3 | 4 | # It can not be used to do the full installation because it is not in the correct 5 | # order. 6 | 7 | -r ../dev.txt # Includes validation requirements 8 | -r ../optional.txt 9 | -r ../production.txt 10 | -------------------------------------------------------------------------------- /requirements/optional.txt: -------------------------------------------------------------------------------- 1 | newrelic 2 | -------------------------------------------------------------------------------- /requirements/pip-tools.in: -------------------------------------------------------------------------------- 1 | # Just the dependencies to run pip-tools, mainly for the "upgrade" make target 2 | 3 | -c constraints.txt 4 | 5 | pip-tools # Contains pip-compile, used to generate pip requirements files 6 | -------------------------------------------------------------------------------- /requirements/pip-tools.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | build==1.2.2.post1 8 | # via pip-tools 9 | click==8.2.1 10 | # via pip-tools 11 | packaging==25.0 12 | # via build 13 | pip-tools==7.4.1 14 | # via -r requirements/pip-tools.in 15 | pyproject-hooks==1.2.0 16 | # via 17 | # build 18 | # pip-tools 19 | wheel==0.45.1 20 | # via pip-tools 21 | 22 | # The following packages are considered to be unsafe in a requirements file: 23 | # pip 24 | # setuptools 25 | -------------------------------------------------------------------------------- /requirements/pip.in: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | # Core dependencies for installing other packages 3 | 4 | pip 5 | setuptools 6 | wheel 7 | 8 | -------------------------------------------------------------------------------- /requirements/pip.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | wheel==0.45.1 8 | # via -r requirements/pip.in 9 | 10 | # The following packages are considered to be unsafe in a requirements file: 11 | pip==24.2 12 | # via 13 | # -c /home/runner/work/license-manager/license-manager/requirements/common_constraints.txt 14 | # -r requirements/pip.in 15 | setuptools==80.9.0 16 | # via -r requirements/pip.in 17 | -------------------------------------------------------------------------------- /requirements/private.readme: -------------------------------------------------------------------------------- 1 | # If there are any Python packages you want to keep in your virtualenv beyond 2 | # those listed in the official requirements files, create a "private.in" file 3 | # and list them there. Generate the corresponding "private.txt" file pinning 4 | # all of their indirect dependencies to specific versions as follows: 5 | 6 | # pip-compile private.in 7 | 8 | # This allows you to use "pip-sync" without removing these packages: 9 | 10 | # pip-sync requirements/*.txt 11 | 12 | # "private.in" and "private.txt" aren't checked into git to avoid merge 13 | # conflicts, and the presence of this file allows "private.*" to be 14 | # included in scripted pip-sync usage without requiring that those files be 15 | # created first. 16 | -------------------------------------------------------------------------------- /requirements/production.in: -------------------------------------------------------------------------------- 1 | # Packages required in a production environment 2 | 3 | -c constraints.txt 4 | 5 | -r base.txt 6 | 7 | gevent 8 | gunicorn 9 | PyYAML 10 | python-memcached 11 | pymemcache 12 | -------------------------------------------------------------------------------- /requirements/quality.in: -------------------------------------------------------------------------------- 1 | # Requirements for code quality checks 2 | 3 | -c constraints.txt 4 | 5 | -r base.txt # Core dependencies for this package 6 | 7 | edx-lint # edX pylint rules and plugins 8 | isort # to standardize order of imports 9 | pycodestyle # PEP 8 compliance validation 10 | pydocstyle # PEP 257 compliance validation 11 | -------------------------------------------------------------------------------- /requirements/test.in: -------------------------------------------------------------------------------- 1 | # Requirements for test runs. 2 | 3 | -c constraints.txt 4 | 5 | -r base.txt # Core dependencies for this package 6 | 7 | code-annotations 8 | coverage 9 | ddt 10 | django-dynamic-fixture # library to create dynamic model instances for testing purposes 11 | edx-lint 12 | factory-boy 13 | freezegun 14 | pytest-cov 15 | pytest-django 16 | -------------------------------------------------------------------------------- /requirements/validation.in: -------------------------------------------------------------------------------- 1 | # Requirements for validation (testing, code quality). 2 | 3 | -c constraints.txt 4 | 5 | -r quality.txt 6 | -r test.txt 7 | 8 | edx-i18n-tools # For i18n_tool dummy 9 | pathlib2 10 | -------------------------------------------------------------------------------- /scripts/assignment_validation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script to help validate input file before 3 | consumption by ``local_assignment_multi.py`` 4 | 5 | To use: 6 | ``` 7 | pip install -r scripts/local_assignment_requirements.txt 8 | 9 | python assignment_validation.py print_duplicates --input-file=your-input-file.csv 10 | 11 | # or 12 | 13 | python assignment_validation.py print_plan_counts --input-file=your-input-file.csv 14 | """ 15 | import csv 16 | from collections import defaultdict, Counter 17 | from email.utils import parseaddr 18 | 19 | import click 20 | 21 | INPUT_FIELDNAMES = ['university_name', 'email'] 22 | 23 | 24 | def _iterate_csv(input_file): 25 | with open(input_file, 'r', encoding='latin-1') as f_in: 26 | reader = csv.DictReader(f_in, fieldnames=INPUT_FIELDNAMES, delimiter=',') 27 | # read and skip the header 28 | next(reader, None) 29 | for row in reader: 30 | yield row 31 | 32 | 33 | @click.command() 34 | @click.option( 35 | '--input-file', 36 | help='Path of local file containing email addresses to assign.', 37 | ) 38 | def print_duplicates(input_file): 39 | unis_by_email = defaultdict(list) 40 | for row in _iterate_csv(input_file): 41 | unis_by_email[row['email']].append(row['university_name']) 42 | 43 | for email, uni_list in unis_by_email.items(): 44 | if len(uni_list) > 1: 45 | print(email or 'THE EMPTY STRING', 'is contained in', len(uni_list), 'different rows') 46 | 47 | 48 | @click.command() 49 | @click.option( 50 | '--input-file', 51 | help='Path of local file containing email addresses to assign.', 52 | ) 53 | def print_plan_counts(input_file): 54 | counts_by_plan = Counter() 55 | for row in _iterate_csv(input_file): 56 | counts_by_plan[row['university_name']] += 1 57 | 58 | for plan, count in counts_by_plan.items(): 59 | print(plan, count) 60 | 61 | 62 | def is_valid_email(email): 63 | _, address = parseaddr(email) 64 | if not address: 65 | return False 66 | return True 67 | 68 | 69 | @click.command() 70 | @click.option( 71 | '--input-file', 72 | help='Path of local file containing email addresses to assign.', 73 | ) 74 | def validate_emails(input_file): 75 | invalid_emails = Counter() 76 | for row in _iterate_csv(input_file): 77 | if not is_valid_email(row['email']): 78 | invalid_emails[row['email']] += 1 79 | 80 | print(f'There were {sum(invalid_emails.values())} invalid emails') 81 | print(invalid_emails) 82 | 83 | 84 | @click.group() 85 | def run(): 86 | pass 87 | 88 | 89 | run.add_command(print_duplicates) 90 | run.add_command(print_plan_counts) 91 | run.add_command(validate_emails) 92 | 93 | 94 | if __name__ == '__main__': 95 | run() 96 | -------------------------------------------------------------------------------- /scripts/generate_csvs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper to generate assignment input 3 | CSVs of fake email data. 4 | """ 5 | import csv 6 | import math 7 | 8 | import click 9 | 10 | DEFAULT_EMAIL_TEMPLATE = 'testuser+{}@example.com' 11 | 12 | 13 | def generate_multi_plan_input( 14 | subscription_plan_identifiers, number_in_plan, 15 | email_template, filename, subscription_plan_fieldname='university_name', 16 | ): 17 | total = len(subscription_plan_identifiers) * sum(number_in_plan) 18 | order_mag = math.ceil(math.log(total, 10)) 19 | 20 | with open(filename, 'w') as file_out: 21 | fieldnames = ['email', subscription_plan_fieldname] 22 | writer = csv.DictWriter(file_out, fieldnames) 23 | writer.writeheader() 24 | 25 | # This offset helps us generate emails 26 | # that are unique across all sub plan identifiers that we iterate. 27 | offset = 0 28 | for plan_id, num_emails_for_plan in zip(subscription_plan_identifiers, number_in_plan): 29 | for index in range(offset, offset + num_emails_for_plan): 30 | email = email_template.format( 31 | str(index).zfill(order_mag) 32 | ) 33 | writer.writerow({'email': email, subscription_plan_fieldname: plan_id}) 34 | offset = index + 1 35 | 36 | 37 | @click.command 38 | @click.option( 39 | '--subscription-plan-identifier', '-s', 40 | multiple=True, 41 | help='One or more subscription plan identifier, comma-separated. Could be a uuid or an external name.', 42 | ) 43 | @click.option( 44 | '--subscription-plan-fieldname', '-n', 45 | help='Name of output field corresponding to subscription plans', 46 | default='university_name', 47 | show_default=True, 48 | ) 49 | @click.option( 50 | '--number-in-plan', '-n', 51 | multiple=True, 52 | help='One or more: Number of emails to generate in each plan.', 53 | show_default=True, 54 | ) 55 | @click.option( 56 | '--email-template', 57 | default=DEFAULT_EMAIL_TEMPLATE, 58 | help='Optional python string template to use for email address generation, must take exactly one argument', 59 | ) 60 | @click.option( 61 | '--filename', 62 | help='Where to write the generated file.', 63 | ) 64 | def run( 65 | subscription_plan_identifier, subscription_plan_fieldname, number_in_plan, 66 | email_template, filename, 67 | ): 68 | number_in_plan = [int(s) for s in number_in_plan] 69 | generate_multi_plan_input( 70 | subscription_plan_identifier, number_in_plan, email_template, 71 | filename, subscription_plan_fieldname, 72 | ) 73 | 74 | 75 | if __name__ == '__main__': 76 | run() 77 | -------------------------------------------------------------------------------- /scripts/local_assignment_requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | requests 3 | -------------------------------------------------------------------------------- /scripts/local_license_enrollment_requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | requests 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | # E501: Line too long (80 chars) 3 | # W503: Line break occurred before a binary operator 4 | # E261: At least two spaces before inline comment 5 | ignore=E501,W503,E261 6 | max-line-length = 120 7 | exclude=.git,settings,migrations,license_manager/static,bower_components,license_manager/wsgi.py 8 | 9 | [tool:isort] 10 | indent=' ' 11 | line_length=80 12 | multi_line_output=3 13 | lines_after_imports=2 14 | include_trailing_comma=True 15 | skip= 16 | settings 17 | migrations 18 | 19 | [flake8] 20 | max-line-length=120 --------------------------------------------------------------------------------