diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index e91fe39cd613..4c93795458ff 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -105,58 +105,10 @@ enterprise.HistoricalEnterpriseCustomerCatalog: enterprise.HistoricalEnterpriseCustomerEntitlement: ".. no_pii:": "No PII" -# Via edx-ora2, these can be removed once the models are annotated for real -assessment.Assessment: - ".. no_pii:": "No PII" -assessment.AssessmentFeedback: - ".. no_pii:": "No PII" -assessment.AssessmentFeedbackOption: - ".. no_pii:": "No PII" -assessment.AssessmentPart: - ".. no_pii:": "No PII" -assessment.Criterion: - ".. no_pii:": "No PII" -assessment.CriterionOption: - ".. no_pii:": "No PII" -assessment.PeerWorkflow: - ".. no_pii:": "No PII" -assessment.PeerWorkflowItem: - ".. no_pii:": "No PII" -assessment.Rubric: - ".. no_pii:": "No PII" -assessment.StaffWorkflow: - ".. no_pii:": "No PII" -assessment.StudentTrainingWorkflow: - ".. no_pii:": "No PII" -assessment.StudentTrainingWorkflowItem: - ".. no_pii:": "No PII" -assessment.TrainingExample: - ".. no_pii:": "No PII" -workflow.AssessmentWorkflow: - ".. no_pii:": "No PII" -workflow.AssessmentWorkflowCancellation: - ".. no_pii:": "No PII" -workflow.AssessmentWorkflowStep: - ".. no_pii:": "No PII" - # Via edx-celeryutils celery_utils.ChordData: ".. no_pii:": "No PII" -# Via completion XBlock -completion.BlockCompletion: - ".. no_pii:": "No PII" - -# Via django_notify (required / installed by wiki) -django_notify.Notification: - ".. no_pii:": "No PII" -django_notify.NotificationType: - ".. no_pii:": "No PII" -django_notify.Settings: - ".. no_pii:": "No PII" -django_notify.Subscription: - ".. no_pii:": "No PII" - # Via django-openid-auth https://github.com/edx/django-openid-auth django_openid_auth.Association: ".. no_pii:": "No PII" @@ -203,42 +155,12 @@ edx_name_affirmation.HistoricalVerifiedName: ".. pii_types:": "name" ".. pii_retirement:": "local_api" -# Via VAL -edxval.CourseVideo: - ".. no_pii:": "No PII" -edxval.EncodedVideo: - ".. no_pii:": "No PII" -edxval.Profile: - ".. no_pii:": "No PII" -edxval.ThirdPartyTranscriptCredentialsState: - ".. no_pii:": "No PII" -edxval.TranscriptPreference: - ".. no_pii:": "No PII" -edxval.Video: - ".. no_pii:": "No PII" -edxval.VideoImage: - ".. no_pii:": "No PII" -edxval.VideoTranscript: - ".. no_pii:": "No PII" - # Via PyLTI1p3 lti1p3_tool_config.LtiTool: ".. no_pii:": "No PII" lti1p3_tool_config.LtiToolKey: ".. no_pii:": "No PII" -# Via Milestones -milestones.CourseContentMilestone: - ".. no_pii:": "No PII" -milestones.CourseMilestone: - ".. no_pii:": "No PII" -milestones.Milestone: - ".. no_pii:": "No PII" -milestones.MilestoneRelationshipType: - ".. no_pii:": "No PII" -milestones.UserMilestone: - ".. no_pii:": "No PII" - # Via Django OAuth2 Provider https://github.com/edx/django-oauth2-provider oauth2.Client: ".. no_pii:": "No PII" @@ -289,12 +211,6 @@ oauth_provider.Token: ".. pii_types:": external_service, password ".. pii_retirement:": retained -# Via edx-organizations -organizations.Organization: - ".. no_pii:": "No PII" -organizations.OrganizationCourse: - ".. no_pii:": "No PII" - # Via Problem Builder XBlock problem_builder.Answer: ".. no_pii:": "No PII" @@ -321,30 +237,10 @@ social_django.UserSocialAuth: splash.SplashConfig: ".. no_pii:": "No PII" -# Via edx-submissions -submissions.Score: - ".. no_pii:": "No PII" -submissions.ScoreAnnotation: - ".. no_pii:": "No PII" -submissions.ScoreSummary: - ".. no_pii:": "No PII" -submissions.StudentItem: - ".. no_pii:": "No PII" -submissions.Submission: - ".. no_pii:": "No PII" -submissions.TeamSubmission: - ".. no_pii:": "No PII" - # Via sorl-thumbnail https://github.com/jazzband/sorl-thumbnail thumbnail.KVStore: ".. no_pii:": "No PII" -# Via django-user-tasks -user_tasks.UserTaskArtifact: - ".. no_pii:": "No PII" -user_tasks.UserTaskStatus: - ".. no_pii:": "No PII" - # Via waffle waffle.Flag: ".. no_pii:": "No PII" @@ -353,22 +249,198 @@ waffle.Sample: waffle.Switch: ".. no_pii:": "No PII" -# Via django-wiki https://github.com/openedx/django-wiki -wiki.Article: +# Additional non-local or generated models requiring safelist coverage +agreements.HistoricalUserAgreement: + ".. no_pii:": "No PII" +assessment.HistoricalSharedFileUpload: + ".. no_pii:": "No PII" +casbin_adapter.CasbinRule: + ".. no_pii:": "No PII" +channel_integration.ApiResponseRecord: + ".. no_pii:": "No PII" +channel_integration.GenericEnterpriseCustomerPluginConfiguration: + ".. no_pii:": "No PII" +channel_integration.IntegratedChannelAPIRequestLogs: + ".. no_pii:": "No PII" +channel_integration.OrphanedContentTransmissions: + ".. no_pii:": "No PII" +edx_proctoring.HistoricalProctoredExam: + ".. no_pii:": "No PII" +edx_proctoring.HistoricalProctoredExamStudentAttempt: + ".. no_pii:": "No PII" +enterprise.HistoricalDefaultEnterpriseEnrollmentIntention: + ".. no_pii:": "No PII" +enterprise.HistoricalDefaultEnterpriseEnrollmentRealization: + ".. no_pii:": "No PII" +enterprise.HistoricalEnterpriseCourseEntitlement: + ".. no_pii:": "No PII" +enterprise.HistoricalEnterpriseCustomerInviteKey: ".. no_pii:": "No PII" -wiki.ArticleForObject: +enterprise.HistoricalEnterpriseCustomerSsoConfiguration: ".. no_pii:": "No PII" -wiki.ArticlePlugin: +enterprise.HistoricalEnterpriseCustomerUser: ".. no_pii:": "No PII" -wiki.ArticleRevision: +enterprise.HistoricalEnterpriseGroup: ".. no_pii:": "No PII" -wiki.ReusablePlugin: +enterprise.HistoricalEnterpriseGroupMembership: ".. no_pii:": "No PII" -wiki.RevisionPlugin: +enterprise.HistoricalLearnerCreditEnterpriseCourseEnrollment: ".. no_pii:": "No PII" -wiki.RevisionPluginRevision: +enterprise.HistoricalLicensedEnterpriseCourseEnrollment: ".. no_pii:": "No PII" -wiki.SimplePlugin: +enterprise.HistoricalPendingEnrollment: ".. no_pii:": "No PII" -wiki.URLPath: +enterprise.HistoricalPendingEnterpriseCustomerAdminUser: + ".. pii:": "Contains pending enterprise admin email address." + ".. pii_types:": "email_address" + ".. pii_retirement:": "consumer_api" +enterprise.HistoricalPendingEnterpriseCustomerUser: + ".. pii:": "Contains pending enterprise learner email address." + ".. pii_types:": "email_address" + ".. pii_retirement:": "consumer_api" +enterprise.HistoricalSystemWideEnterpriseUserRoleAssignment: + ".. no_pii:": "No PII" +forum.AbuseFlagger: + ".. no_pii:": "No PII" +forum.Comment: + ".. pii:": "Forum comments may contain user-generated content and usernames." + ".. pii_types:": "username, biography" + ".. pii_retirement:": "local_api" +forum.CommentThread: + ".. pii:": "Forum thread content may include user-generated text and usernames." + ".. pii_types:": "username, biography" + ".. pii_retirement:": "local_api" +forum.CourseStat: + ".. no_pii:": "No PII" +forum.EditHistory: + ".. pii:": "Forum edit history stores user-generated text and author identifiers." + ".. pii_types:": "username, biography" + ".. pii_retirement:": "local_api" +forum.ForumUser: + ".. pii:": "Forum profile links and username data." + ".. pii_types:": "username" + ".. pii_retirement:": "local_api" +forum.HistoricalAbuseFlagger: + ".. no_pii:": "No PII" +forum.LastReadTime: + ".. no_pii:": "No PII" +forum.MongoContent: + ".. no_pii:": "No PII" +forum.ReadState: + ".. no_pii:": "No PII" +forum.Subscription: + ".. no_pii:": "No PII" +forum.UserVote: + ".. no_pii:": "No PII" +lti_consumer.Lti1p3Passport: + ".. pii:": "Stores third-party LTI service credentials and identifiers." + ".. pii_types:": "password, external_service" + ".. pii_retirement:": "retained" +oel_collections.Collection: + ".. no_pii:": "No PII" +oel_components.Component: + ".. no_pii:": "No PII" +oel_publishing.Container: + ".. no_pii:": "No PII" +oel_publishing.DraftChangeLog: + ".. no_pii:": "No PII" +oel_publishing.DraftChangeLogRecord: + ".. no_pii:": "No PII" +oel_publishing.LearningPackage: + ".. no_pii:": "No PII" +oel_publishing.PublishableEntity: + ".. no_pii:": "No PII" +oel_tagging.ObjectTag: + ".. no_pii:": "No PII" +oel_tagging.Tag: + ".. no_pii:": "No PII" +oel_tagging.TagImportTask: + ".. no_pii:": "No PII" +oel_tagging.Taxonomy: + ".. no_pii:": "No PII" +openedx_catalog.CatalogCourse: + ".. no_pii:": "No PII" +openedx_catalog.CourseRun: + ".. no_pii:": "No PII" +openedx_content.Collection: + ".. no_pii:": "No PII" +openedx_content.CollectionPublishableEntity: + ".. no_pii:": "No PII" +openedx_content.Component: + ".. no_pii:": "No PII" +openedx_content.ComponentType: ".. no_pii:": "No PII" +openedx_content.ComponentVersion: + ".. no_pii:": "No PII" +openedx_content.ComponentVersionMedia: + ".. no_pii:": "No PII" +openedx_content.Container: + ".. no_pii:": "No PII" +openedx_content.ContainerType: + ".. no_pii:": "No PII" +openedx_content.ContainerVersion: + ".. no_pii:": "No PII" +openedx_content.Draft: + ".. no_pii:": "No PII" +openedx_content.EntityList: + ".. no_pii:": "No PII" +openedx_content.EntityListRow: + ".. no_pii:": "No PII" +openedx_content.LearningPackage: + ".. no_pii:": "No PII" +openedx_content.Media: + ".. no_pii:": "No PII" +openedx_content.MediaType: + ".. no_pii:": "No PII" +openedx_content.PublishLog: + ".. no_pii:": "No PII" +openedx_content.PublishLogRecord: + ".. no_pii:": "No PII" +openedx_content.PublishableEntity: + ".. no_pii:": "No PII" +openedx_content.PublishableEntityVersion: + ".. no_pii:": "No PII" +openedx_content.PublishableEntityVersionDependency: + ".. no_pii:": "No PII" +openedx_content.Published: + ".. no_pii:": "No PII" +openedx_content.Section: + ".. no_pii:": "No PII" +openedx_content.SectionVersion: + ".. no_pii:": "No PII" +openedx_content.Subsection: + ".. no_pii:": "No PII" +openedx_content.SubsectionVersion: + ".. no_pii:": "No PII" +openedx_content.Unit: + ".. no_pii:": "No PII" +openedx_content.UnitVersion: + ".. no_pii:": "No PII" +organizations.HistoricalOrganization: + ".. no_pii:": "No PII" +organizations.HistoricalOrganizationCourse: + ".. no_pii:": "No PII" +push_notifications.APNSDevice: + ".. pii:": "Contains mobile push tokens tied to users/devices." + ".. pii_types:": "external_service" + ".. pii_retirement:": "local_api" +push_notifications.GCMDevice: + ".. pii:": "Contains mobile push tokens tied to users/devices." + ".. pii_types:": "external_service" + ".. pii_retirement:": "local_api" +push_notifications.WNSDevice: + ".. pii:": "Contains mobile push tokens tied to users/devices." + ".. pii_types:": "external_service" + ".. pii_retirement:": "local_api" +push_notifications.WebPushDevice: + ".. pii:": "Contains web push endpoint/subscription data tied to users/devices." + ".. pii_types:": "external_service" + ".. pii_retirement:": "local_api" +submissions.ExternalGraderDetail: + ".. no_pii:": "No PII" +submissions.SubmissionFile: + ".. no_pii:": "No PII" +support.HistoricalUserSocialAuth: + ".. pii:": "Historical social auth linkage to third-party identity providers." + ".. pii_types:": "external_service" + ".. pii_retirement:": "local_api" diff --git a/.github/renovate.json5 b/.github/renovate.json5 index edd326558c61..beada3ff3ec2 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,45 +1,50 @@ { extends: [ - 'config:recommended', - 'schedule:weekly', - ':automergeLinters', - ':automergeMinor', - ':automergeTesters', - ':enableVulnerabilityAlerts', - ':semanticCommits', - ':updateNotScheduled', + 'config:recommended', // Renovate base defaults: dependency dashboard, monorepo grouping, sane PR limits + 'schedule:weekly', // Only open new PRs once a week + ':automergeLinters', // Automerge linter updates (eslint, prettier, etc.) + ':automergeTesters', // Automerge test runner updates (jest, mocha, etc.) + ':enableVulnerabilityAlerts', // Open security PRs immediately, ignoring the weekly schedule + ':semanticCommits', // Use conventional commit format (fix(deps):, chore(deps):) + ':updateNotScheduled', // Allow vulnerability fixes to bypass the weekly schedule ], + + // Never auto-rebase PRs; let humans decide when to rebase + rebaseWhen: 'never', + + // Wait 3 days after a release before opening a PR, giving time for early bugs and + // potentially malicious releases (e.g. supply chain attacks) to be detected + minimumReleaseAge: '3 days', + + // Only manage npm dependencies + enabledManagers: [ + 'npm', + ], + + // Only create PRs during Eastern time (aligns with the weekly schedule) + timezone: 'America/New_York', + + // Cap the number of open Renovate PRs at any one time + prConcurrentLimit: 3, + + // Packages with known breaking changes or no active maintainer + ignoreDeps: [ + 'karma-spec-reporter', + ], + packageRules: [ { - matchDepTypes: [ - 'devDependencies', - ], - matchUpdateTypes: [ - 'lockFileMaintenance', - 'minor', - 'patch', - 'pin', + // Automerge minor and patch updates for @edx and @openedx scoped packages; + // these are maintained by the same org so breakage is caught upstream + matchPackageNames: [ + '/@edx/', + '/@openedx/', ], - automerge: true, - }, - { matchUpdateTypes: [ 'minor', 'patch', ], automerge: true, - matchPackageNames: [ - '/@edx/', - '/@openedx/', - ], }, ], - ignoreDeps: [ - 'karma-spec-reporter', - ], - timezone: 'America/New_York', - prConcurrentLimit: 3, - enabledManagers: [ - 'npm', - ], } diff --git a/.github/workflows/add-quarterly-ticket-to-check-constraints.yml b/.github/workflows/add-quarterly-ticket-to-check-constraints.yml new file mode 100644 index 000000000000..00ec08c65cea --- /dev/null +++ b/.github/workflows/add-quarterly-ticket-to-check-constraints.yml @@ -0,0 +1,26 @@ +name: Create quarterly issues for GitHub audit +on: + schedule: + - cron: 0 0 1 1,4,7,10 * + workflow_dispatch: {} + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + create_issue: + name: Create quarterly constraint check issue + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: | + # Platform constraints audit + new_issue_url=$(gh issue create --repo "openedx/openedx-platform" \ + --title "Quarterly audit of openedx-platform constraints" \ + --label "code health" \ + --body "It is time to perform the quartely audit of constrained dependencies in \`openedx-platform\`. The goal is to remove any constraints that are no longer necessary to proactively prevent version conflicts and keep us up to date with security patches. The playbook for performing the audit can be found [here](https://openedx.atlassian.net/wiki/spaces/AC/pages/6340968449/Quarterly+Platform+Constraints+Audit).") + echo "NEW_ISSUE_URL=$new_issue_url" >> $GITHUB_ENV + + - name: Comment on issue + run: gh issue comment $NEW_ISSUE_URL --body "@openedx/wg-maintenance-openedx-platform-oncall heads up on this request" diff --git a/.github/workflows/check-consistent-dependencies.yml b/.github/workflows/check-consistent-dependencies.yml index cef731a7d175..a84ef40bbcab 100644 --- a/.github/workflows/check-consistent-dependencies.yml +++ b/.github/workflows/check-consistent-dependencies.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-python@v6 if: ${{ env.RELEVANT == 'true' }} with: - python-version: '3.11' + python-version: '3.12' - name: "Recompile requirements" if: ${{ env.RELEVANT == 'true' }} diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml index a5ab117acdfc..94fb9e863d87 100644 --- a/.github/workflows/ci-static-analysis.yml +++ b/.github/workflows/ci-static-analysis.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: python-version: - - "3.11" + - "3.12" os: ["ubuntu-24.04"] steps: diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml index 1da50b9b6bdd..fe8fdc6ff542 100644 --- a/.github/workflows/compile-python-requirements.yml +++ b/.github/workflows/compile-python-requirements.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Python environment uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.12" - name: Run make compile-requirements env: diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 42a6a6cfb057..dd21d12e3d25 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -16,7 +16,7 @@ jobs: os: [ubuntu-latest] node-version: [20] python-version: - - "3.11" + - "3.12" steps: - uses: actions/checkout@v6 @@ -73,7 +73,7 @@ jobs: npm run test - name: Save Job Artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Build-Artifacts path: | diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index 2debc4cb1028..8b119d31e6b5 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.12" - name: Install system requirements run: sudo apt update && sudo apt install -y libxmlsec1-dev diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index c63b5ef02fba..20540fb43906 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -16,7 +16,7 @@ jobs: matrix: os: [ubuntu-24.04] python-version: - - "3.11" + - "3.12" # 'pinned' is used to install the latest patch version of Django # within the global constraint i.e. Django==4.2.8 in current case # because we have global constraint of Django<4.2 diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 03160b77a7e4..91141c0c29ed 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -21,7 +21,7 @@ jobs: - module-name: openedx-1 path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/djangoapps/course_live/" - module-name: openedx-2 - path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/envs/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/" + path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/envs/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/ openedx/core/djangoapps/authz/" - module-name: common path: "common" - module-name: cms @@ -40,7 +40,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.11 + python-version: 3.12 - name: Get pip cache dir id: pip-cache-dir diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index b70587eda5f7..b57b713eb93a 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -16,7 +16,7 @@ jobs: matrix: os: [ubuntu-24.04] python-version: - - "3.11" + - "3.12" node-version: [20] steps: @@ -78,14 +78,14 @@ jobs: PIP_SRC: ${{ runner.temp }} TARGET_BRANCH: ${{ github.base_ref }} run: | - make pycodestyle + ruff check --output-format=github . make xsslint make pii_check make check_keywords - name: Save Job Artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Build-Artifacts path: | diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 557ba0227c5d..c201f30aa799 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -20,7 +20,7 @@ jobs: matrix: os: ["ubuntu-latest"] python-version: - - "3.11" + - "3.12" steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index ae9b57879c9f..41159e3526c5 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -15,7 +15,7 @@ jobs: matrix: os: [ubuntu-24.04] python-version: - - "3.11" + - "3.12" node-version: [20] npm-version: [10.7.x] mongo-version: @@ -71,6 +71,13 @@ jobs: - name: Install Limited Python Deps for Build run: | + # Install pip-tools.txt first to pin setuptools<82 before installing + # assets.txt. setuptools 82+ removed pkg_resources, which pyfilesystem2 + # (fs) still uses for namespace package declarations. The constraints.txt + # pin covers full installs, but this step only installs assets.txt so we + # pre-install pip-tools.txt to ensure setuptools 81.x is in place. + # See: https://github.com/PyFilesystem/pyfilesystem2/issues/577 + pip install -r requirements/pip-tools.txt pip install -r requirements/edx/assets.txt - name: Add node_modules bin to $Path @@ -98,7 +105,6 @@ jobs: env: LMS_CFG: lms/envs/minimal.yml CMS_CFG: lms/envs/minimal.yml - DJANGO_SETTINGS_MODULE: lms.envs.production run: | - ./manage.py lms collectstatic --noinput - ./manage.py cms collectstatic --noinput + DJANGO_SETTINGS_MODULE=lms.envs.production ./manage.py lms collectstatic --noinput + DJANGO_SETTINGS_MODULE=cms.envs.production ./manage.py cms collectstatic --noinput diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index cb9beeb3c6bf..0f26c1be11b0 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -2,167 +2,169 @@ "lms-1": { "settings": "lms.envs.test", "paths": [ - "lms/djangoapps/branding/", - "lms/djangoapps/bulk_email/", - "lms/djangoapps/bulk_enroll/", - "lms/djangoapps/bulk_user_retirement/", - "lms/djangoapps/ccx/", - "lms/djangoapps/certificates/", - "lms/djangoapps/commerce/" + "lms/djangoapps/discussion/rest_api/" ] }, "lms-2": { "settings": "lms.envs.test", "paths": [ + "lms/djangoapps/ccx/", + "lms/djangoapps/commerce/", "lms/djangoapps/course_api/", - "lms/djangoapps/course_blocks/", - "lms/djangoapps/course_goals/", - "lms/djangoapps/course_home_api/", "lms/djangoapps/course_wiki/", - "lms/djangoapps/coursewarehistoryextended/", - "lms/djangoapps/debug/" + "lms/djangoapps/discussion/notification_prefs/", + "lms/djangoapps/instructor_task/", + "lms/djangoapps/ora_staff_grader/", + "lms/djangoapps/survey/", + "lms/djangoapps/teams/", + "lms/djangoapps/verify_student/" ] }, "lms-3": { "settings": "lms.envs.test", "paths": [ - "lms/djangoapps/courseware/" + "lms/djangoapps/branding/", + "lms/djangoapps/bulk_enroll/", + "lms/djangoapps/courseware/", + "lms/djangoapps/instructor_analytics/", + "lms/djangoapps/learner_dashboard/", + "lms/djangoapps/lti_provider/", + "lms/djangoapps/program_enrollments/" ] }, "lms-4": { "settings": "lms.envs.test", "paths": [ - "lms/djangoapps/discussion/", - "lms/djangoapps/edxnotes/", - "lms/djangoapps/experiments/" - ] - }, - "lms-5": { - "settings": "lms.envs.test", - "paths": [ - "lms/djangoapps/gating/", + "lms/djangoapps/bulk_user_retirement/", + "lms/djangoapps/course_blocks/", + "lms/djangoapps/course_goals/", + "lms/djangoapps/course_home_api/", + "lms/djangoapps/coursewarehistoryextended/", + "lms/djangoapps/discussion/tests/", + "lms/djangoapps/experiments/", "lms/djangoapps/grades/", "lms/djangoapps/instructor/", - "lms/djangoapps/instructor_analytics/" + "lms/djangoapps/mfe_config_api/", + "lms/djangoapps/staticbook/", + "lms/lib/" ] }, - "lms-6": { + "lms-5": { "settings": "lms.envs.test", "paths": [ - "lms/djangoapps/instructor_task/", - "lms/djangoapps/learner_dashboard/", + "lms/djangoapps/bulk_email/", + "lms/djangoapps/certificates/", + "lms/djangoapps/debug/", + "lms/djangoapps/discussion/django_comment_client/", + "lms/djangoapps/edxnotes/", + "lms/djangoapps/gating/", "lms/djangoapps/learner_home/", "lms/djangoapps/lms_initialization/", "lms/djangoapps/lms_xblock/", - "lms/djangoapps/lti_provider/", "lms/djangoapps/mailing/", "lms/djangoapps/mobile_api/", "lms/djangoapps/monitoring/", - "lms/djangoapps/ora_staff_grader/", - "lms/djangoapps/program_enrollments/", "lms/djangoapps/rss_proxy/", "lms/djangoapps/static_template_view/", - "lms/djangoapps/staticbook/", "lms/djangoapps/support/", - "lms/djangoapps/survey/", - "lms/djangoapps/teams/", "lms/djangoapps/tests/", "lms/djangoapps/user_tours/", - "lms/djangoapps/verify_student/", - "lms/djangoapps/mfe_config_api/", "lms/envs/", - "lms/lib/", "lms/tests.py" ] }, - "openedx-1-with-lms": { + "shared-with-lms-1": { "settings": "lms.envs.test", "paths": [ - "openedx/core/djangoapps/ace_common/", - "openedx/core/djangoapps/cors_csrf/", + "common/djangoapps/", "openedx/core/djangoapps/agreements/", "openedx/core/djangoapps/api_admin/", + "openedx/core/djangoapps/authz/", + "openedx/core/djangoapps/cache_toolbox/", + "openedx/core/djangoapps/ccxcon/", + "openedx/core/djangoapps/content/", + "openedx/core/djangoapps/content_libraries/", + "openedx/core/djangoapps/course_apps/", + "openedx/core/djangoapps/credentials/", + "openedx/core/djangoapps/credit/", + "openedx/core/djangoapps/dark_lang/", + "openedx/core/djangoapps/django_comment_common/", + "openedx/core/djangoapps/embargo/", + "openedx/core/djangoapps/header_control/", + "openedx/core/djangoapps/heartbeat/", + "openedx/core/djangoapps/models/", + "openedx/core/djangoapps/notifications/", + "openedx/core/djangoapps/oauth_dispatch/", + "openedx/core/djangoapps/safe_sessions/", + "openedx/core/djangoapps/schedules/", + "openedx/core/djangoapps/user_api/", + "openedx/core/djangoapps/util/", + "openedx/core/djangoapps/video_pipeline/", + "openedx/core/djangoapps/waffle_utils/", + "openedx/core/djangoapps/xblock/", + "openedx/core/tests/", + "openedx/features/", + "openedx/tests/" + ] + }, + "shared-with-lms-2": { + "settings": "lms.envs.test", + "paths": [ + "openedx/core/djangoapps/ace_common/", "openedx/core/djangoapps/auth_exchange/", "openedx/core/djangoapps/bookmarks/", - "openedx/core/djangoapps/cache_toolbox/", "openedx/core/djangoapps/catalog/", - "openedx/core/djangoapps/ccxcon/", "openedx/core/djangoapps/commerce/", "openedx/core/djangoapps/common_initialization/", "openedx/core/djangoapps/common_views/", "openedx/core/djangoapps/config_model_utils/", - "openedx/core/djangoapps/content/", - "openedx/core/djangoapps/content_libraries/", "openedx/core/djangoapps/contentserver/", "openedx/core/djangoapps/cookie_metadata/", - "openedx/core/djangoapps/course_apps/", + "openedx/core/djangoapps/cors_csrf/", "openedx/core/djangoapps/course_date_signals/", "openedx/core/djangoapps/course_groups/", + "openedx/core/djangoapps/course_live/", "openedx/core/djangoapps/courseware_api/", "openedx/core/djangoapps/crawlers/", - "openedx/core/djangoapps/credentials/", - "openedx/core/djangoapps/credit/", - "openedx/core/djangoapps/course_live/", - "openedx/core/djangoapps/dark_lang/", "openedx/core/djangoapps/debug/", "openedx/core/djangoapps/discussions/", - "openedx/core/djangoapps/django_comment_common/", - "openedx/core/djangoapps/embargo/", "openedx/core/djangoapps/enrollments/", - "openedx/core/djangoapps/external_user_ids/" - ] - }, - "openedx-2-with-lms": { - "settings": "lms.envs.test", - "paths": [ + "openedx/core/djangoapps/external_user_ids/", "openedx/core/djangoapps/geoinfo/", - "openedx/core/djangoapps/header_control/", - "openedx/core/djangoapps/heartbeat/", "openedx/core/djangoapps/lang_pref/", - "openedx/core/djangoapps/models/", "openedx/core/djangoapps/monkey_patch/", - "openedx/core/djangoapps/notifications/", - "openedx/core/djangoapps/oauth_dispatch/", "openedx/core/djangoapps/olx_rest_api/", "openedx/core/djangoapps/password_policy/", "openedx/core/djangoapps/plugin_api/", "openedx/core/djangoapps/plugins/", "openedx/core/djangoapps/profile_images/", "openedx/core/djangoapps/programs/", - "openedx/core/djangoapps/safe_sessions/", - "openedx/core/djangoapps/schedules/", "openedx/core/djangoapps/service_status/", "openedx/core/djangoapps/session_inactivity_timeout/", "openedx/core/djangoapps/signals/", "openedx/core/djangoapps/site_configuration/", "openedx/core/djangoapps/system_wide_roles/", "openedx/core/djangoapps/theming/", - "openedx/core/djangoapps/user_api/", "openedx/core/djangoapps/user_authn/", - "openedx/core/djangoapps/util/", "openedx/core/djangoapps/verified_track_content/", "openedx/core/djangoapps/video_config/", - "openedx/core/djangoapps/video_pipeline/", - "openedx/core/djangoapps/waffle_utils/", - "openedx/core/djangoapps/xblock/", "openedx/core/djangoapps/xmodule_django/", "openedx/core/djangoapps/zendesk_proxy/", "openedx/core/djangolib/", "openedx/core/lib/", - "openedx/core/tests/", - "openedx/features/", "openedx/testing/", - "openedx/tests/" + "xmodule/" ] }, - "openedx-1-with-cms": { + "shared-with-cms-1": { "settings": "cms.envs.test", "paths": [ + "common/djangoapps/", "openedx/core/djangoapps/ace_common/", - "openedx/core/djangoapps/cors_csrf/", "openedx/core/djangoapps/agreements/", "openedx/core/djangoapps/api_admin/", "openedx/core/djangoapps/auth_exchange/", + "openedx/core/djangoapps/authz/", "openedx/core/djangoapps/bookmarks/", "openedx/core/djangoapps/cache_toolbox/", "openedx/core/djangoapps/catalog/", @@ -174,8 +176,10 @@ "openedx/core/djangoapps/content/", "openedx/core/djangoapps/content_libraries/", "openedx/core/djangoapps/content_staging/", + "openedx/core/djangoapps/content_tagging/", "openedx/core/djangoapps/contentserver/", "openedx/core/djangoapps/cookie_metadata/", + "openedx/core/djangoapps/cors_csrf/", "openedx/core/djangoapps/course_apps/", "openedx/core/djangoapps/course_date_signals/", "openedx/core/djangoapps/course_groups/", @@ -189,13 +193,7 @@ "openedx/core/djangoapps/django_comment_common/", "openedx/core/djangoapps/embargo/", "openedx/core/djangoapps/enrollments/", - "openedx/core/djangoapps/external_user_ids/" - ] - }, - "openedx-2-with-cms": { - "settings": "cms.envs.test", - "paths": [ - "openedx/core/djangoapps/content_tagging/", + "openedx/core/djangoapps/external_user_ids/", "openedx/core/djangoapps/geoinfo/", "openedx/core/djangoapps/header_control/", "openedx/core/djangoapps/heartbeat/", @@ -228,7 +226,8 @@ "openedx/core/djangoapps/xmodule_django/", "openedx/core/djangoapps/zendesk_proxy/", "openedx/core/lib/", - "openedx/tests/" + "openedx/tests/", + "xmodule/" ] }, "cms-1": { @@ -238,8 +237,8 @@ "cms/djangoapps/cms_user_tasks/", "cms/djangoapps/course_creators/", "cms/djangoapps/export_course_metadata/", - "cms/djangoapps/modulestore_migrator/", "cms/djangoapps/models/", + "cms/djangoapps/modulestore_migrator/", "cms/djangoapps/pipeline_js/", "cms/djangoapps/xblock_config/", "cms/envs/", @@ -251,29 +250,5 @@ "paths": [ "cms/djangoapps/contentstore/" ] - }, - "common-with-lms": { - "settings": "lms.envs.test", - "paths": [ - "common/djangoapps/" - ] - }, - "common-with-cms": { - "settings": "cms.envs.test", - "paths": [ - "common/djangoapps/" - ] - }, - "xmodule-with-lms": { - "settings": "lms.envs.test", - "paths": [ - "xmodule/" - ] - }, - "xmodule-with-cms": { - "settings": "cms.envs.test", - "paths": [ - "xmodule/" - ] } } diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4d857c7b97a1..777af3491eb6 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,6 +8,7 @@ on: push: branches: - master + - release/* concurrency: # We only need to be running tests for the latest commit on each PR @@ -22,29 +23,24 @@ jobs: strategy: matrix: python-version: - - "3.11" - "3.12" django-version: - "pinned" - # When updating the shards, remember to make the same changes in - # .github/workflows/unit-tests-gh-hosted.yml shard_name: - "lms-1" - "lms-2" - "lms-3" - "lms-4" - "lms-5" - - "lms-6" - - "openedx-1-with-lms" - - "openedx-2-with-lms" - - "openedx-1-with-cms" - - "openedx-2-with-cms" + - "shared-with-lms-1" + - "shared-with-lms-2" + # Note: The shared-with-cms-1 shard is currently a subset of both + # shared-with-lms-1 and shared-with-lms-2. Some shared tests are + # not run -with-cms at all. + # https://github.com/openedx/openedx-platform/issues/38355 + - "shared-with-cms-1" - "cms-1" - "cms-2" - - "common-with-lms" - - "common-with-cms" - - "xmodule-with-lms" - - "xmodule-with-cms" mongo-version: - "7.0" os-version: @@ -57,18 +53,18 @@ jobs: # # We're testing the older version of Ubuntu and running the xmodule tests since those rely on many # dependent complex libraries and will hopefully catch most issues quickly. - include: - - shard_name: "xmodule-with-cms" - python-version: "3.11" - django-version: "pinned" - mongo-version: "7.0" - os-version: "ubuntu-22.04" - - shard_name: "xmodule-with-lms" - python-version: "3.11" - django-version: "pinned" - mongo-version: "7.0" - os-version: "ubuntu-22.04" - + # + # include: + # - shard_name: "xmodule-with-cms" + # python-version: "3.12" + # django-version: "pinned" + # mongo-version: "7.0" + # os-version: "ubuntu-24.04" + # - shard_name: "xmodule-with-lms" + # python-version: "3.12" + # django-version: "pinned" + # mongo-version: "7.0" + # os-version: "ubuntu-24.04" steps: - name: checkout repo uses: actions/checkout@v6 @@ -116,11 +112,25 @@ jobs: shell: bash run: | echo "unit_test_paths=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} )" >> $GITHUB_ENV + if [[ "${{ github.ref }}" == "refs/heads/master" ]]; then + echo "report_log_arg=--report-log=reports/pytest-report-${{ matrix.shard_name }}.jsonl" >> $GITHUB_ENV + else + echo "report_log_arg=" >> $GITHUB_ENV + fi - name: run tests shell: bash run: | - python -Wd -m pytest -p no:randomly --ds=${{ env.settings_path }} ${{ env.unit_test_paths }} --cov=. + python -Wd -m pytest -p no:randomly --ds=${{ env.settings_path }} ${{ env.unit_test_paths }} --cov=. \ + ${{ env.report_log_arg }} + + - name: Upload pytest timing report + if: github.ref == 'refs/heads/master' + uses: actions/upload-artifact@v7 + with: + name: pytest-report-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }} + path: reports/pytest-report-${{ matrix.shard_name }}.jsonl + overwrite: true - name: rename warnings json file if: success() @@ -131,7 +141,7 @@ jobs: - name: save pytest warnings json file if: success() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: pytest-warnings-json-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }} path: | @@ -143,7 +153,7 @@ jobs: mv reports/.coverage reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage - name: Upload coverage - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }} path: reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage @@ -156,7 +166,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: 3.11 + python-version: 3.12 - name: install system requirements run: | @@ -230,7 +240,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: collect pytest warnings files - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: pytest-warnings-json-* merge-multiple: true @@ -245,7 +255,7 @@ jobs: - name: save warning report if: success() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: pytest-warning-report-html path: | @@ -257,14 +267,14 @@ jobs: needs: [compile-warnings-report] steps: - name: Merge Pytest Warnings JSON Artifacts - uses: actions/upload-artifact/merge@v6 + uses: actions/upload-artifact/merge@v7 with: name: pytest-warnings-json pattern: pytest-warnings-json-* delete-merged: true - name: Merge Coverage Artifacts - uses: actions/upload-artifact/merge@v6 + uses: actions/upload-artifact/merge@v7 with: name: coverage pattern: coverage-* @@ -278,7 +288,7 @@ jobs: strategy: matrix: python-version: - - 3.11 + - 3.12 steps: - name: Checkout repo uses: actions/checkout@v6 @@ -289,7 +299,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Download all artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: coverage-* merge-multiple: true diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml index 36a6b361887f..fc4610baf49d 100644 --- a/.github/workflows/upgrade-one-python-dependency.yml +++ b/.github/workflows/upgrade-one-python-dependency.yml @@ -39,7 +39,7 @@ jobs: - name: Set up Python environment uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.12" - name: Update any pinned dependencies env: diff --git a/.gitignore b/.gitignore index 57e4ed34104d..069d8ebe3635 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ lms/envs/private.py cms/envs/private.py .venv/ CLAUDE.md +.claude/ AGENTS.md # end-noclean diff --git a/.pii_annotations.yml b/.pii_annotations.yml index 9000115a253e..5f0f822b584d 100644 --- a/.pii_annotations.yml +++ b/.pii_annotations.yml @@ -1,7 +1,7 @@ source_path: ./ report_path: pii_report safelist_path: .annotation_safe_list.yml -coverage_target: 85.3 +coverage_target: 100.0 # See OEP-30 for more information on these values and what they mean: # https://open-edx-proposals.readthedocs.io/en/latest/oep-0030-arch-pii-markup-and-auditing.html#docstring-annotations annotations: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index fdef59eb56b2..fef9e3c2a9d2 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,13 +3,17 @@ version: 2 build: os: "ubuntu-lts-latest" tools: - python: "3.11" + python: "3.12" sphinx: configuration: docs/conf.py python: install: + # Need to install this to set the correct version of steuptools for now + # because it is needed by fs + # See https://github.com/openedx/openedx-platform/issues/38068 for details. + - requirements: "requirements/pip-tools.txt" - requirements: "requirements/edx/doc.txt" - method: pip path: . diff --git a/Makefile b/Makefile index 2ca7fbc5848e..98931cb608d2 100644 --- a/Makefile +++ b/Makefile @@ -171,22 +171,20 @@ xsslint: ## check xss for quality issuest --config=scripts.xsslint_config \ --thresholds=scripts/xsslint_thresholds.json -pycodestyle: ## check python files for quality issues - pycodestyle . +ruff: ## check python files with ruff + ruff check . ## Re-enable --lint flag when this issue https://github.com/openedx/edx-platform/issues/35775 is resolved pii_check: ## check django models for pii annotations DJANGO_SETTINGS_MODULE=cms.envs.test \ code_annotations django_find_annotations \ --config_file .pii_annotations.yml \ - --app_name cms \ --coverage \ --lint DJANGO_SETTINGS_MODULE=lms.envs.test \ code_annotations django_find_annotations \ --config_file .pii_annotations.yml \ - --app_name lms \ --coverage \ --lint diff --git a/README.rst b/README.rst index b315cdcbf2b6..1e404c53dd49 100644 --- a/README.rst +++ b/README.rst @@ -78,7 +78,7 @@ OS: Interpreters/Tools: -* Python 3.11 or 3.12 +* Python 3.12 * Node: See the ``.nvmrc`` file in this repository. @@ -230,6 +230,23 @@ running at the given ports. - localhost:1997 - ACCOUNT_MICROFRONTEND_URL +Security Deployment Requirements +******************************** + +Some platform features require a **shared** Django cache backend (Redis or +Memcached) to function correctly across multiple LMS nodes: + +* **LTI Provider** — OAuth nonce replay protection stores seen nonces in the + Django ``default`` cache. A per-process backend (e.g. ``LocMemCache``) will + not detect replays that arrive on a different node. See + `lms/djangoapps/lti_provider/README.rst`_ for details. + +Tutor-based deployments satisfy this requirement automatically. For bare-metal +or custom deployments, verify that ``CACHES['default']`` points at a shared +Redis or Memcached instance before enabling these features. + +.. _lms/djangoapps/lti_provider/README.rst: lms/djangoapps/lti_provider/README.rst + License ******* @@ -293,6 +310,11 @@ Code of Conduct Please read the `Community Code of Conduct`_ for interacting with this repository. +AI Contribution Policy +********************** + +Note that contributions are expected to follow the Open edX `AI Contribution Policy`_. + Reporting Security Issues ************************* @@ -302,6 +324,7 @@ security@openedx.org. .. _individual contributor agreement: https://openedx.org/cla .. _CONTRIBUTING: https://github.com/openedx/.github/blob/master/CONTRIBUTING.md .. _Community Code of Conduct: https://openedx.org/code-of-conduct/ +.. _AI Contribution Policy: https://github.com/openedx/.github/blob/master/AI_POLICY.md People ****** diff --git a/cms/__init__.py b/cms/__init__.py index f9ed0bb3cea1..ff320dad9248 100644 --- a/cms/__init__.py +++ b/cms/__init__.py @@ -1,9 +1,9 @@ +# ruff: noqa: I001 """ Celery needs to be loaded when the cms modules are so that task registration and discovery can work correctly. Import sorting is intentionally disabled in this module. -isort:skip_file """ @@ -21,4 +21,4 @@ # This will make sure the app is always imported when Django starts so # that shared_task will use this app, and also ensures that the celery # singleton is always configured for the CMS. -from .celery import APP as CELERY_APP # lint-amnesty, pylint: disable=wrong-import-position +from .celery import APP as CELERY_APP # pylint: disable=wrong-import-position # noqa: F401 diff --git a/cms/celery.py b/cms/celery.py index a7166feb1c27..fbcfe36e4e8c 100644 --- a/cms/celery.py +++ b/cms/celery.py @@ -16,4 +16,4 @@ # Set the default Django settings module for the 'celery' program # and then instantiate the Celery singleton. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cms.envs.production') -from openedx.core.lib.celery import APP # pylint: disable=wrong-import-position,unused-import +from openedx.core.lib.celery import APP # pylint: disable=wrong-import-position,unused-import # noqa: F401 diff --git a/cms/conftest.py b/cms/conftest.py index 131ea8e04831..270b45d57cad 100644 --- a/cms/conftest.py +++ b/cms/conftest.py @@ -1,3 +1,4 @@ +# ruff: noqa: I001 """ Studio unit test configuration and fixtures. @@ -13,7 +14,7 @@ from openedx.core.pytest_hooks import DeferPlugin # Patch the xml libs before anything else. -from openedx.core.lib.safe_lxml import defuse_xml_libs # isort:skip +from openedx.core.lib.safe_lxml import defuse_xml_libs # must patch xml libs before other imports execute defuse_xml_libs() @@ -27,7 +28,7 @@ def pytest_configure(config): logging.info("pytest did not register json_report correctly") -@pytest.fixture(autouse=True, scope='function') +@pytest.fixture(autouse=True, scope='function') # noqa: PT003 def _django_clear_site_cache(): """ pytest-django uses this fixture to automatically clear the Site object @@ -39,4 +40,4 @@ def _django_clear_site_cache(): clearing mechanism actually works. So override this fixture to not mess with what has been working for us so far. """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass diff --git a/cms/djangoapps/api/__init__.py b/cms/djangoapps/api/__init__.py index 41903687689c..7cac77010d3c 100644 --- a/cms/djangoapps/api/__init__.py +++ b/cms/djangoapps/api/__init__.py @@ -1 +1 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring diff --git a/cms/djangoapps/api/urls.py b/cms/djangoapps/api/urls.py index 8fd8495647b6..7c41fb966233 100644 --- a/cms/djangoapps/api/urls.py +++ b/cms/djangoapps/api/urls.py @@ -3,8 +3,7 @@ """ -from django.urls import include -from django.urls import path +from django.urls import include, path app_name = 'cms.djangoapps.api' diff --git a/cms/djangoapps/api/v1/serializers/course_runs.py b/cms/djangoapps/api/v1/serializers/course_runs.py index 36598b025073..0f73de5f3c76 100644 --- a/cms/djangoapps/api/v1/serializers/course_runs.py +++ b/cms/djangoapps/api/v1/serializers/course_runs.py @@ -13,7 +13,7 @@ from cms.djangoapps.contentstore.views.course import create_new_course, get_course_and_check_access, rerun_course from common.djangoapps.student.models import CourseAccessRole from openedx.core.lib.courses import course_image_url -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order IMAGE_TYPES = { 'image/jpeg': 'jpg', @@ -23,7 +23,7 @@ log = logging.getLogger(__name__) -class CourseAccessRoleSerializer(serializers.ModelSerializer): # lint-amnesty, pylint: disable=missing-class-docstring +class CourseAccessRoleSerializer(serializers.ModelSerializer): # pylint: disable=missing-class-docstring user = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all()) class Meta: @@ -31,21 +31,21 @@ class Meta: fields = ('user', 'role',) -class CourseRunScheduleSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method +class CourseRunScheduleSerializer(serializers.Serializer): # pylint: disable=abstract-method start = serializers.DateTimeField() end = serializers.DateTimeField() enrollment_start = serializers.DateTimeField(allow_null=True, required=False) enrollment_end = serializers.DateTimeField(allow_null=True, required=False) -class CourseRunTeamSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring +class CourseRunTeamSerializer(serializers.Serializer): # pylint: disable=abstract-method, missing-class-docstring def to_internal_value(self, data): """Overriding this to support deserialization, for write operations.""" for member in data: try: User.objects.get(username=member['user']) except User.DoesNotExist: - raise serializers.ValidationError( # lint-amnesty, pylint: disable=raise-missing-from + raise serializers.ValidationError( # pylint: disable=raise-missing-from # noqa: B904 _('Course team user does not exist') ) @@ -61,10 +61,10 @@ def get_attribute(self, instance): return instance -class CourseRunTeamSerializerMixin(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring +class CourseRunTeamSerializerMixin(serializers.Serializer): # pylint: disable=abstract-method, missing-class-docstring team = CourseRunTeamSerializer(required=False) - def update_team(self, instance, team): # lint-amnesty, pylint: disable=missing-function-docstring + def update_team(self, instance, team): # pylint: disable=missing-function-docstring # Existing data should remain intact when performing a partial update. if not self.partial: CourseAccessRole.objects.filter(course_id=instance.id).delete() @@ -81,7 +81,7 @@ def update_team(self, instance, team): # lint-amnesty, pylint: disable=missing- ) -class CourseRunImageField(serializers.ImageField): # lint-amnesty, pylint: disable=missing-class-docstring +class CourseRunImageField(serializers.ImageField): # pylint: disable=missing-class-docstring def get_attribute(self, instance): return course_image_url(instance) @@ -92,7 +92,7 @@ def to_representation(self, value): return request.build_absolute_uri(value) -class CourseRunPacingTypeField(serializers.ChoiceField): # lint-amnesty, pylint: disable=missing-class-docstring +class CourseRunPacingTypeField(serializers.ChoiceField): # pylint: disable=missing-class-docstring def to_representation(self, value): return 'self_paced' if value else 'instructor_paced' @@ -100,7 +100,7 @@ def to_internal_value(self, data): return data == 'self_paced' -class CourseRunImageSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring +class CourseRunImageSerializer(serializers.Serializer): # pylint: disable=abstract-method, missing-class-docstring # We set an empty default to prevent the parent serializer from attempting # to save this value to the Course object. card_image = CourseRunImageField(source='course_image', default=empty) @@ -115,13 +115,13 @@ def update(self, instance, validated_data): return instance -class CourseRunSerializerCommonFieldsMixin(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method +class CourseRunSerializerCommonFieldsMixin(serializers.Serializer): # pylint: disable=abstract-method schedule = CourseRunScheduleSerializer(source='*', required=False) pacing_type = CourseRunPacingTypeField(source='self_paced', required=False, choices=((False, 'instructor_paced'), (True, 'self_paced'),)) -class CourseRunSerializer(CourseRunSerializerCommonFieldsMixin, CourseRunTeamSerializerMixin, serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring +class CourseRunSerializer(CourseRunSerializerCommonFieldsMixin, CourseRunTeamSerializerMixin, serializers.Serializer): # pylint: disable=abstract-method, missing-class-docstring id = serializers.CharField(read_only=True) title = serializers.CharField(source='display_name') images = CourseRunImageSerializer(source='*', required=False) @@ -139,7 +139,7 @@ def update(self, instance, validated_data): return instance -class CourseRunCreateSerializer(CourseRunSerializer): # lint-amnesty, pylint: disable=missing-class-docstring +class CourseRunCreateSerializer(CourseRunSerializer): # pylint: disable=missing-class-docstring org = serializers.CharField(source='id.org') number = serializers.CharField(source='id.course') run = serializers.CharField(source='id.run') @@ -155,7 +155,7 @@ def create(self, validated_data): return instance -class CourseRunRerunSerializer(CourseRunSerializerCommonFieldsMixin, CourseRunTeamSerializerMixin, # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring +class CourseRunRerunSerializer(CourseRunSerializerCommonFieldsMixin, CourseRunTeamSerializerMixin, # pylint: disable=abstract-method, missing-class-docstring serializers.Serializer): title = serializers.CharField(source='display_name', required=False) number = serializers.CharField(source='id.course', required=False) @@ -171,7 +171,7 @@ def validate(self, attrs): with store.default_store('split'): new_course_run_key = store.make_course_key(course_run_key.org, number, run) except InvalidKeyError: - raise serializers.ValidationError( # lint-amnesty, pylint: disable=raise-missing-from + raise serializers.ValidationError( # pylint: disable=raise-missing-from # noqa: B904 'Invalid key supplied. Ensure there are no special characters in the Course Number.' ) if store.has_course(new_course_run_key, ignore_case=True): @@ -200,7 +200,7 @@ def update(self, instance, validated_data): return course_run -class CourseCloneSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring +class CourseCloneSerializer(serializers.Serializer): # pylint: disable=abstract-method, missing-class-docstring source_course_id = serializers.CharField() destination_course_id = serializers.CharField() diff --git a/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py index c648b2e5b875..4ff5ea4f0016 100644 --- a/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py @@ -2,6 +2,7 @@ import datetime + import ddt import pytz from django.test import RequestFactory @@ -9,15 +10,17 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from openedx.core.lib.courses import course_image_url -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order from ...serializers.course_runs import CourseRunSerializer from ..utils import serialize_datetime @ddt.ddt -class CourseRunSerializerTests(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class CourseRunSerializerTests(ModuleStoreTestCase): # pylint: disable=missing-class-docstring def setUp(self): super().setUp() diff --git a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py index 78261a421424..57bda92f2355 100644 --- a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py @@ -2,7 +2,7 @@ import datetime -from unittest.mock import patch # lint-amnesty, pylint: disable=unused-import +from unittest.mock import patch # pylint: disable=unused-import # noqa: F401 import ddt import pytz @@ -16,12 +16,17 @@ from common.djangoapps.student.models import CourseAccessRole from common.djangoapps.student.tests.factories import TEST_PASSWORD, AdminFactory, UserFactory from openedx.core.lib.courses import course_image_url -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, ToyCourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.exceptions import NotFoundError # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + CourseFactory, + ToyCourseFactory, +) from ...serializers.course_runs import CourseRunSerializer from ..utils import serialize_datetime @@ -261,15 +266,15 @@ def test_create(self, pacing_type, expected_self_paced_value): data = self.get_course_run_data(user, start, end, pacing_type, role) response = self.client.post(self.list_url, data, format='json') - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 201) # noqa: PT009 course_run_key = CourseKey.from_string(response.data['id']) course_run = modulestore().get_course(course_run_key) - self.assertEqual(course_run.display_name, data['title']) - self.assertEqual(course_run.id.org, data['org']) - self.assertEqual(course_run.id.course, data['number']) - self.assertEqual(course_run.id.run, data['run']) - self.assertEqual(course_run.self_paced, expected_self_paced_value) + self.assertEqual(course_run.display_name, data['title']) # noqa: PT009 + self.assertEqual(course_run.id.org, data['org']) # noqa: PT009 + self.assertEqual(course_run.id.course, data['number']) # noqa: PT009 + self.assertEqual(course_run.id.run, data['run']) # noqa: PT009 + self.assertEqual(course_run.self_paced, expected_self_paced_value) # noqa: PT009 self.assert_course_run_schedule(course_run, start, end) self.assert_access_role(course_run, user, role) self.assert_course_access_role_count(course_run, 1) @@ -285,8 +290,8 @@ def test_create_with_invalid_course_team(self): data = self.get_course_run_data(user, start, end, 'self-paced') data['team'] = [{'user': 'invalid-username'}] response = self.client.post(self.list_url, data, format='json') - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data.get('team'), ['Course team user does not exist']) + self.assertEqual(response.status_code, 400) # noqa: PT009 + self.assertEqual(response.data.get('team'), ['Course team user does not exist']) # noqa: PT009 def test_images_upload(self): # http://www.django-rest-framework.org/api-guide/parsers/#fileuploadparser @@ -331,7 +336,7 @@ def test_rerun(self, pacing_type, expected_self_paced_value, number): original_course_run = ToyCourseFactory() add_organization({ 'name': 'Test Organization', - 'short_name': original_course_run.id.org, # lint-amnesty, pylint: disable=no-member + 'short_name': original_course_run.id.org, # pylint: disable=no-member 'description': 'Testing Organization Description', }) start = datetime.datetime.now(pytz.UTC).replace(microsecond=0) @@ -339,7 +344,7 @@ def test_rerun(self, pacing_type, expected_self_paced_value, number): user = UserFactory() role = 'instructor' run = '3T2017' - url = reverse('api:v1:course_run-rerun', kwargs={'pk': str(original_course_run.id)}) # lint-amnesty, pylint: disable=no-member + url = reverse('api:v1:course_run-rerun', kwargs={'pk': str(original_course_run.id)}) # pylint: disable=no-member data = { 'run': run, 'schedule': { @@ -369,16 +374,16 @@ def test_rerun(self, pacing_type, expected_self_paced_value, number): if number: assert course_run.id.course == number - assert course_run.id.course != original_course_run.id.course # lint-amnesty, pylint: disable=no-member + assert course_run.id.course != original_course_run.id.course # pylint: disable=no-member else: - assert course_run.id.course == original_course_run.id.course # lint-amnesty, pylint: disable=no-member + assert course_run.id.course == original_course_run.id.course # pylint: disable=no-member self.assert_course_run_schedule(course_run, start, end) self.assert_access_role(course_run, user, role) self.assert_course_access_role_count(course_run, 1) course_orgs = get_course_organizations(course_run_key) - self.assertEqual(len(course_orgs), 1) - self.assertEqual(course_orgs[0]['short_name'], original_course_run.id.org) # lint-amnesty, pylint: disable=no-member + self.assertEqual(len(course_orgs), 1) # noqa: PT009 + self.assertEqual(course_orgs[0]['short_name'], original_course_run.id.org) # pylint: disable=no-member # noqa: PT009 def test_rerun_duplicate_run(self): course_run = ToyCourseFactory() @@ -412,7 +417,7 @@ def test_clone_course(self): } response = self.client.post(url, data, format='json') assert response.status_code == 201 - self.assertEqual(response.data, {"message": "Course cloned successfully."}) + self.assertEqual(response.data, {"message": "Course cloned successfully."}) # noqa: PT009 def test_clone_course_with_missing_source_id(self): url = reverse('api:v1:course_run-clone') @@ -421,7 +426,7 @@ def test_clone_course_with_missing_source_id(self): } response = self.client.post(url, data, format='json') assert response.status_code == 400 - self.assertEqual(response.data, {'source_course_id': ['This field is required.']}) + self.assertEqual(response.data, {'source_course_id': ['This field is required.']}) # noqa: PT009 def test_clone_course_with_missing_dest_id(self): url = reverse('api:v1:course_run-clone') @@ -430,7 +435,7 @@ def test_clone_course_with_missing_dest_id(self): } response = self.client.post(url, data, format='json') assert response.status_code == 400 - self.assertEqual(response.data, {'destination_course_id': ['This field is required.']}) + self.assertEqual(response.data, {'destination_course_id': ['This field is required.']}) # noqa: PT009 def test_clone_course_with_nonexistent_source_course(self): url = reverse('api:v1:course_run-clone') diff --git a/cms/djangoapps/api/v1/tests/utils.py b/cms/djangoapps/api/v1/tests/utils.py index 1684e018dd1c..b0933e84749c 100644 --- a/cms/djangoapps/api/v1/tests/utils.py +++ b/cms/djangoapps/api/v1/tests/utils.py @@ -1,3 +1,3 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring def serialize_datetime(d): return d.strftime('%Y-%m-%dT%H:%M:%S.%fZ') diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index 31c30d2d139a..28b9edc58ace 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -15,11 +15,11 @@ CourseRunCreateSerializer, CourseRunImageSerializer, CourseRunRerunSerializer, - CourseRunSerializer + CourseRunSerializer, ) -class CourseRunViewSet(viewsets.GenericViewSet): # lint-amnesty, pylint: disable=missing-class-docstring +class CourseRunViewSet(viewsets.GenericViewSet): # pylint: disable=missing-class-docstring lookup_value_regex = settings.COURSE_KEY_REGEX permission_classes = (permissions.IsAdminUser,) serializer_class = CourseRunSerializer @@ -29,7 +29,7 @@ def get_object(self): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field assert lookup_url_kwarg in self.kwargs, ( - 'Expected view %s to be called with a URL keyword argument ' + 'Expected view %s to be called with a URL keyword argument ' # noqa: UP031 'named "%s". Fix your URL conf, or set the `.lookup_field` ' 'attribute on the view correctly.' % (self.__class__.__name__, lookup_url_kwarg) @@ -42,18 +42,18 @@ def get_object(self): raise Http404 - def list(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + def list(self, request, *args, **kwargs): # pylint: disable=unused-argument course_runs, __ = _accessible_courses_iter(request) page = self.paginate_queryset(list(course_runs)) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) - def retrieve(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + def retrieve(self, request, *args, **kwargs): # pylint: disable=unused-argument course_run = self.get_object() serializer = self.get_serializer(course_run) return Response(serializer.data) - def update(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument + def update(self, request, *args, **kwargs): # pylint: disable=missing-function-docstring, unused-argument course_run = self.get_object() partial = kwargs.pop('partial', False) @@ -66,7 +66,7 @@ def partial_update(self, request, *args, **kwargs): kwargs['partial'] = True return self.update(request, *args, **kwargs) - def create(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + def create(self, request, *args, **kwargs): # pylint: disable=unused-argument serializer = CourseRunCreateSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) serializer.save() @@ -77,7 +77,7 @@ def create(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unu methods=['post', 'put'], parser_classes=(parsers.FormParser, parsers.MultiPartParser,), serializer_class=CourseRunImageSerializer) - def images(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument + def images(self, request, *args, **kwargs): # pylint: disable=missing-function-docstring, unused-argument course_run = self.get_object() serializer = CourseRunImageSerializer(course_run, data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) @@ -85,7 +85,7 @@ def images(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=mis return Response(serializer.data) @action(detail=True, methods=['post']) - def rerun(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument + def rerun(self, request, *args, **kwargs): # pylint: disable=missing-function-docstring, unused-argument course_run = self.get_object() serializer = CourseRunRerunSerializer(course_run, data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) @@ -94,7 +94,7 @@ def rerun(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=miss return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post']) - def clone(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + def clone(self, request, *args, **kwargs): # pylint: disable=unused-argument """ **Use Case** diff --git a/cms/djangoapps/cms_user_tasks/apps.py b/cms/djangoapps/cms_user_tasks/apps.py index c4ac938c6e5d..390fa453db58 100644 --- a/cms/djangoapps/cms_user_tasks/apps.py +++ b/cms/djangoapps/cms_user_tasks/apps.py @@ -17,4 +17,4 @@ def ready(self): """ Connect signal handlers. """ - from . import signals # pylint: disable=unused-import + from . import signals # pylint: disable=unused-import # noqa: F401 diff --git a/cms/djangoapps/cms_user_tasks/tests.py b/cms/djangoapps/cms_user_tasks/tests.py index 8a9bc5532101..9e4f91cb4979 100644 --- a/cms/djangoapps/cms_user_tasks/tests.py +++ b/cms/djangoapps/cms_user_tasks/tests.py @@ -77,7 +77,7 @@ class TestUserTasks(APITestCase): """ @classmethod - def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called + def setUpTestData(cls): # pylint: disable=super-method-not-called cls.user = UserFactory.create(username='test_user', email='test@example.com', password='password') cls.status = UserTaskStatus.objects.create( user=cls.user, task_id=str(uuid4()), task_class='test_rest_api.sample_task', name='SampleTask 2', @@ -151,7 +151,7 @@ class TestUserTaskStopped(APITestCase): """ @classmethod - def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called + def setUpTestData(cls): # pylint: disable=super-method-not-called cls.user = UserFactory.create(username='test_user', email='test@example.com', password='password') cls.status = UserTaskStatus.objects.create( user=cls.user, task_id=str(uuid4()), task_class='test_rest_api.sample_task', name='SampleTask 2', @@ -176,15 +176,15 @@ def create_olx_validation_artifact(self): def assert_msg_subject(self, msg): """Verify that msg subject is in expected format.""" - subject = "{platform_name} {studio_name}: Task Status Update".format( + subject = "{platform_name} {studio_name}: Task Status Update".format( # noqa: UP032 platform_name=settings.PLATFORM_NAME, studio_name=settings.STUDIO_NAME ) - self.assertEqual(msg.subject, subject) + self.assertEqual(msg.subject, subject) # noqa: PT009 def assert_msg_body_fragments(self, msg, body_fragments): """Verify that email body contains expected fragments""" for fragment in body_fragments: - self.assertIn(fragment, msg.body) + self.assertIn(fragment, msg.body) # noqa: PT009 def test_email_sent_with_site(self): """ @@ -201,7 +201,7 @@ def test_email_sent_with_site(self): reverse('usertaskstatus-detail', args=[self.status.uuid]) ] - self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox), 1) # noqa: PT009 msg = mail.outbox[0] @@ -216,7 +216,7 @@ def test_email_not_sent_with_libary_import_task(self): end_of_task_status.name = "bulk_migrate_from_modulestore" user_task_stopped.send(sender=UserTaskStatus, status=end_of_task_status) - self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(mail.outbox), 0) # noqa: PT009 def test_email_not_sent_with_libary_content_update(self): """ @@ -229,7 +229,7 @@ def test_email_not_sent_with_libary_content_update(self): end_of_task_status.name = "updating block-v1:course+type@library_content+block@uuid from library" user_task_stopped.send(sender=UserTaskStatus, status=end_of_task_status) - self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(mail.outbox), 0) # noqa: PT009 def test_email_not_sent_with_legacy_libary_content_ref_update(self): """ @@ -239,7 +239,7 @@ def test_email_not_sent_with_legacy_libary_content_ref_update(self): end_of_task_status.name = "Updating legacy library content blocks references of course-v1:UNIX+UN1+2025_T4" user_task_stopped.send(sender=UserTaskStatus, status=end_of_task_status) - self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(mail.outbox), 0) # noqa: PT009 def test_email_sent_with_olx_validations_with_config_enabled(self): """ @@ -258,7 +258,7 @@ def test_email_sent_with_olx_validations_with_config_enabled(self): user_task_stopped.send(sender=UserTaskStatus, status=self.status) msg = mail.outbox[0] - self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox), 1) # noqa: PT009 self.assert_msg_subject(msg) self.assert_msg_body_fragments(msg, body_fragments_with_validation) @@ -277,8 +277,8 @@ def test_email_sent_with_olx_validations_with_default_config(self): msg = mail.outbox[0] # Verify olx validation is not enabled out of the box. - self.assertFalse(settings.FEATURES.get('ENABLE_COURSE_OLX_VALIDATION')) - self.assertEqual(len(mail.outbox), 1) + self.assertFalse(settings.FEATURES.get('ENABLE_COURSE_OLX_VALIDATION')) # noqa: PT009 + self.assertEqual(len(mail.outbox), 1) # noqa: PT009 self.assert_msg_subject(msg) self.assert_msg_body_fragments(msg, body_fragments) @@ -298,11 +298,11 @@ def test_email_sent_with_olx_validations_with_bypass_flag(self): user_task_stopped.send(sender=UserTaskStatus, status=self.status) - self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox), 1) # noqa: PT009 msg = mail.outbox[0] self.assert_msg_subject(msg) self.assert_msg_body_fragments(msg, body_fragments) - self.assertNotIn("Here are some validations we found with your course content.", msg.body) + self.assertNotIn("Here are some validations we found with your course content.", msg.body) # noqa: PT009 def test_email_not_sent_for_child(self): """ @@ -312,7 +312,7 @@ def test_email_not_sent_for_child(self): user=self.user, task_id=str(uuid4()), task_class='test_rest_api.sample_task', name='SampleTask 2', total_steps=5, parent=self.status) user_task_stopped.send(sender=UserTaskStatus, status=child_status) - self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(mail.outbox), 0) # noqa: PT009 def test_email_sent_without_site(self): """ @@ -325,7 +325,7 @@ def test_email_sent_without_site(self): "Sign in to view the details of your task or download any files created." ] - self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox), 1) # noqa: PT009 msg = mail.outbox[0] self.assert_msg_subject(msg) @@ -342,7 +342,7 @@ def test_email_retries(self): with mock.patch('cms.djangoapps.cms_user_tasks.tasks.send_task_complete_email.retry') as mock_retry: user_task_stopped.send(sender=UserTaskStatus, status=self.status) - self.assertTrue(mock_retry.called) + self.assertTrue(mock_retry.called) # noqa: PT009 def test_queue_email_failure(self): logger = logging.getLogger("cms.djangoapps.cms_user_tasks.signals") @@ -354,5 +354,5 @@ def test_queue_email_failure(self): {'error_response': 'error occurred'}, {'operation_name': 'test'} ) user_task_stopped.send(sender=UserTaskStatus, status=self.status) - self.assertTrue(mock_delay.called) - self.assertEqual(hdlr.messages['error'][0], 'Unable to queue send_task_complete_email') + self.assertTrue(mock_delay.called) # noqa: PT009 + self.assertEqual(hdlr.messages['error'][0], 'Unable to queue send_task_complete_email') # noqa: PT009 diff --git a/cms/djangoapps/contentstore/api/__init__.py b/cms/djangoapps/contentstore/api/__init__.py index da36a1afe4d8..bbd94880dad0 100644 --- a/cms/djangoapps/contentstore/api/__init__.py +++ b/cms/djangoapps/contentstore/api/__init__.py @@ -1,2 +1,2 @@ """Contentstore API""" -from .views.utils import course_author_access_required, get_ready_to_migrate_legacy_library_content_blocks +from .views.utils import course_author_access_required, get_ready_to_migrate_legacy_library_content_blocks # noqa: F401 diff --git a/cms/djangoapps/contentstore/api/tests/base.py b/cms/djangoapps/contentstore/api/tests/base.py index a169c431e419..086ac06ac5fb 100644 --- a/cms/djangoapps/contentstore/api/tests/base.py +++ b/cms/djangoapps/contentstore/api/tests/base.py @@ -5,11 +5,10 @@ from django.urls import reverse from rest_framework.test import APITestCase -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory -from common.djangoapps.student.tests.factories import StaffFactory -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import StaffFactory, UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory # pylint: disable=unused-variable @@ -18,6 +17,8 @@ class BaseCourseViewTest(SharedModuleStoreTestCase, APITestCase): Base test class for course data views. """ view_name = None # The name of the view to use in reverse() call in self.get_url() + course_key_arg_name = 'course_id' + extra_request_args = {} @classmethod def setUpClass(cls): @@ -65,7 +66,7 @@ def initialize_course(cls, course): parent_location=cls.section.location, category="sequential", ) - unit2 = BlockFactory.create( + unit2 = BlockFactory.create( # noqa: F841 parent_location=cls.subsection2.location, category="vertical", ) @@ -86,9 +87,10 @@ def get_url(self, course_id): """ Helper function to create the url """ + args = { + self.course_key_arg_name: course_id, + } return reverse( self.view_name, - kwargs={ - 'course_id': course_id - } + kwargs= args | self.extra_request_args ) diff --git a/cms/djangoapps/contentstore/api/tests/test_import.py b/cms/djangoapps/contentstore/api/tests/test_import.py index 0f989a8151c0..74236d8f5c54 100644 --- a/cms/djangoapps/contentstore/api/tests/test_import.py +++ b/cms/djangoapps/contentstore/api/tests/test_import.py @@ -12,12 +12,11 @@ from rest_framework import status from rest_framework.test import APITestCase from user_tasks.models import UserTaskStatus + +from common.djangoapps.student.tests.factories import StaffFactory, UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from common.djangoapps.student.tests.factories import StaffFactory -from common.djangoapps.student.tests.factories import UserFactory - class CourseImportViewTest(SharedModuleStoreTestCase, APITestCase): """ @@ -74,7 +73,7 @@ def test_anonymous_import_fails(self): """ with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009 def test_student_import_fails(self): """ @@ -83,7 +82,7 @@ def test_student_import_fails(self): self.client.login(username=self.student.username, password=self.password) with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 def test_staff_with_access_import_succeeds(self): """ @@ -92,7 +91,7 @@ def test_staff_with_access_import_succeeds(self): self.client.login(username=self.staff.username, password=self.password) with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 def test_staff_has_no_access_import_fails(self): """ @@ -101,7 +100,7 @@ def test_staff_has_no_access_import_fails(self): self.client.login(username=self.staff.username, password=self.password) with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.restricted_course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 def test_student_get_status_fails(self): """ @@ -109,14 +108,14 @@ def test_student_get_status_fails(self): """ self.client.login(username=self.student.username, password=self.password) resp = self.client.get(self.get_url(self.course_key), {'task_id': '1234', 'filename': self.good_tar_filename}) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 def test_anonymous_get_status_fails(self): """ Test that an anonymous user cannot access the API and an error is received. """ resp = self.client.get(self.get_url(self.course_key), {'task_id': '1234', 'filename': self.good_tar_filename}) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009 def test_staff_get_status_succeeds(self): """ @@ -127,13 +126,13 @@ def test_staff_get_status_succeeds(self): self.client.login(username=self.staff.username, password=self.password) with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 resp = self.client.get( self.get_url(self.course_key), {'task_id': resp.data['task_id'], 'filename': self.good_tar_filename}, format='multipart' ) - self.assertEqual(resp.data['state'], UserTaskStatus.SUCCEEDED) + self.assertEqual(resp.data['state'], UserTaskStatus.SUCCEEDED) # noqa: PT009 def test_staff_no_access_get_status_fails(self): """ @@ -144,7 +143,7 @@ def test_staff_no_access_get_status_fails(self): self.client.login(username=self.staff.username, password=self.password) with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 task_id = resp.data['task_id'] resp = self.client.get( @@ -152,7 +151,7 @@ def test_staff_no_access_get_status_fails(self): {'task_id': task_id, 'filename': self.good_tar_filename}, format='multipart' ) - self.assertEqual(resp.data['state'], UserTaskStatus.SUCCEEDED) + self.assertEqual(resp.data['state'], UserTaskStatus.SUCCEEDED) # noqa: PT009 self.client.logout() @@ -162,7 +161,7 @@ def test_staff_no_access_get_status_fails(self): {'task_id': task_id, 'filename': self.good_tar_filename}, format='multipart' ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 def test_course_task_mismatch_get_status_fails(self): """ @@ -173,7 +172,7 @@ def test_course_task_mismatch_get_status_fails(self): self.client.login(username=self.staff.username, password=self.password) with open(self.good_tar_fullpath, 'rb') as fp: resp = self.client.post(self.get_url(self.course_key), {'course_data': fp}, format='multipart') - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 task_id = resp.data['task_id'] resp = self.client.get( @@ -181,4 +180,4 @@ def test_course_task_mismatch_get_status_fails(self): {'task_id': task_id, 'filename': self.good_tar_filename}, format='multipart' ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/api/tests/test_quality.py b/cms/djangoapps/contentstore/api/tests/test_quality.py index 8625cbeb55e5..0efab43972f6 100644 --- a/cms/djangoapps/contentstore/api/tests/test_quality.py +++ b/cms/djangoapps/contentstore/api/tests/test_quality.py @@ -2,8 +2,12 @@ Tests for the course import API views """ - +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_STAFF from rest_framework import status +from rest_framework.test import APIClient + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin from .base import BaseCourseViewTest @@ -17,7 +21,7 @@ class CourseQualityViewTest(BaseCourseViewTest): def test_staff_succeeds(self): self.client.login(username=self.staff.username, password=self.password) resp = self.client.get(self.get_url(self.course_key), {'all': 'true'}) - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 expected_data = { 'units': { 'num_blocks': { @@ -61,9 +65,69 @@ def test_staff_succeeds(self): }, 'is_self_paced': True, } - self.assertDictEqual(resp.data, expected_data) + self.assertDictEqual(resp.data, expected_data) # noqa: PT009 def test_student_fails(self): self.client.login(username=self.student.username, password=self.password) resp = self.client.get(self.get_url(self.course_key)) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + +class CourseQualityAuthzTest(CourseAuthzTestMixin, BaseCourseViewTest): + """ + Tests Course Quality API authorization using openedx-authz. + The endpoint uses COURSES_VIEW_COURSE permission. + """ + + view_name = "courses_api:course_quality" + authz_roles_to_assign = [COURSE_STAFF.external_key] + + def test_authorized_user_can_access(self): + """User with COURSE_STAFF role can access.""" + resp = self.authorized_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_unauthorized_user_cannot_access(self): + """User without role cannot access.""" + resp = self.unauthorized_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_role_scoped_to_course(self): + """Authorization should only apply to the assigned course.""" + other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id) + + resp = self.authorized_client.get(self.get_url(other_course.id)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_staff_user_allowed_via_legacy(self): + """ + Staff users should still pass through legacy fallback. + """ + self.client.login(username=self.staff.username, password=self.password) + + resp = self.client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_allowed(self): + """Superusers should always be allowed.""" + superuser = UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + resp = client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access(self): + """ + User without permissions should be denied. + This case validates that a non-staff user cannot access even + if they have course author access to the course. + """ + non_staff_user = UserFactory() + non_staff_client = APIClient() + self.add_user_to_role(non_staff_user, COURSE_DATA_RESEARCHER.external_key) + non_staff_client.force_authenticate(user=non_staff_user) + + resp = non_staff_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py index 8bf8e19b626c..a34faf516219 100644 --- a/cms/djangoapps/contentstore/api/tests/test_validation.py +++ b/cms/djangoapps/contentstore/api/tests/test_validation.py @@ -11,12 +11,15 @@ from django.contrib.auth import get_user_model from django.test.utils import override_settings from django.urls import reverse +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_EDITOR, COURSE_STAFF from rest_framework import status -from rest_framework.test import APITestCase +from rest_framework.test import APIClient, APITestCase +from cms.djangoapps.contentstore.api.tests.base import BaseCourseViewTest from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.tests.factories import StaffFactory, UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin, CourseAuthzTestMixin from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @@ -93,7 +96,7 @@ def get_url(self, course_id): def test_student_fails(self): self.client.login(username=self.student.username, password=self.password) resp = self.client.get(self.get_url(self.course_key)) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 @ddt.data( (False, False), @@ -113,7 +116,7 @@ def test_staff_succeeds(self, certs_html_view, with_modes): ) self.client.login(username=self.staff.username, password=self.password) resp = self.client.get(self.get_url(self.course_key), {'all': 'true'}) - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 expected_data = { 'assignments': { 'total_number': 1, @@ -145,7 +148,7 @@ def test_staff_succeeds(self, certs_html_view, with_modes): }, 'is_self_paced': True, } - self.assertDictEqual(resp.data, expected_data) + self.assertDictEqual(resp.data, expected_data) # noqa: PT009 class TestMigrationViewSetCreate(SharedModuleStoreTestCase, APITestCase): @@ -244,7 +247,7 @@ def test_create_update_reference_success(self, mock_block, mock_user_task_status mock_auth.assert_called_once() - @patch('cms.djangoapps.contentstore.api.views.utils.has_course_author_access') + @patch('openedx.core.djangoapps.authz.decorators.user_has_course_permission') @patch('xmodule.library_content_block.LegacyLibraryContentBlock.is_ready_to_migrate_to_v2') def test_list_ready_to_update_reference_success(self, mock_block, mock_auth): """ @@ -267,8 +270,166 @@ def test_list_ready_to_update_reference_success(self, mock_block, mock_auth): assert response.status_code == status.HTTP_200_OK data = response.json() - self.assertListEqual(data, [ + self.assertListEqual(data, [ # noqa: PT009 {'usage_key': str(self.block1.location)}, {'usage_key': str(self.block2.location)}, ]) mock_auth.assert_called_once() + + +class CourseValidationAuthzTest(CourseAuthzTestMixin, BaseCourseViewTest): + """ + Tests Course Validation API authorization using openedx-authz. + The endpoint uses COURSES_VIEW_COURSE permission. + """ + + view_name = "courses_api:course_validation" + authz_roles_to_assign = [COURSE_STAFF.external_key] + + def test_authorized_user_can_access(self): + """ + User with COURSE_STAFF role should be allowed via AuthZ. + """ + resp = self.authorized_client.get(self.get_url(self.course_key)) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_unauthorized_user_cannot_access(self): + """ + User without permissions should be denied. + """ + resp = self.unauthorized_client.get(self.get_url(self.course_key)) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_role_scoped_to_course(self): + """ + Authorization should only apply to the assigned course scope. + """ + other_course = self.store.create_course( + "OtherOrg", + "OtherCourse", + "Run", + self.staff.id, + ) + + resp = self.authorized_client.get(self.get_url(other_course.id)) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_staff_user_allowed_via_legacy(self): + """ + Course staff should pass through legacy fallback when AuthZ denies. + """ + self.client.login(username=self.staff.username, password=self.password) + + resp = self.client.get(self.get_url(self.course_key)) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_allowed(self): + """ + Superusers should always be allowed through legacy fallback. + """ + superuser = UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + resp = client.get(self.get_url(self.course_key)) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access(self): + """ + User without permissions should be denied. + This case validates that a non-staff user cannot access even + if they have course author access to the course. + """ + non_staff_user = UserFactory() + non_staff_client = APIClient() + self.add_user_to_role(non_staff_user, COURSE_DATA_RESEARCHER.external_key) + non_staff_client.force_authenticate(user=non_staff_user) + + resp = non_staff_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + +class TestMigrationViewSetCreateAuthz( + CourseAuthoringAuthzTestMixin, + SharedModuleStoreTestCase, + APITestCase, +): + """ + AuthZ tests for: + /api/courses/v1/migrate_legacy_content_blocks// + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.course = CourseFactory.create( + display_name='test course', + run="Testing_course", + ) + cls.course_key = cls.course.id + + cls.initialize_course(cls.course) + + @classmethod + def initialize_course(cls, course): + """Sets up test course structure.""" + section = BlockFactory.create( + parent_location=course.location, + category="chapter", + ) + subsection = BlockFactory.create( + parent_location=section.location, + category="sequential", + ) + unit = BlockFactory.create( + parent_location=subsection.location, + category="vertical", + ) + BlockFactory.create( + parent_location=unit.location, + category="library_content", + ) + + def url(self): + return f"/api/courses/v1/migrate_legacy_content_blocks/{self.course_key}/" + + # ---- GET (list) ---- + + def test_authorized_user_can_list_blocks(self): + """Authorized user can list migratable blocks.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id, + ) + + response = self.authorized_client.get(self.url()) + + assert response.status_code == status.HTTP_200_OK + + def test_unauthorized_user_cannot_list_blocks(self): + """Unauthorized user should receive 403.""" + response = self.unauthorized_client.get(self.url()) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # ---- elevated users ---- + + def test_staff_user_can_access_without_authz_role(self): + """Staff user bypasses AuthZ.""" + response = self.staff_client.get(self.url()) + + assert response.status_code == status.HTTP_200_OK + + def test_superuser_can_access_without_authz_role(self): + """Superuser bypasses AuthZ.""" + response = self.super_client.get(self.url()) + + assert response.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED] diff --git a/cms/djangoapps/contentstore/api/views/course_import.py b/cms/djangoapps/contentstore/api/views/course_import.py index 3027b1926d0f..f2f8f168df50 100644 --- a/cms/djangoapps/contentstore/api/views/course_import.py +++ b/cms/djangoapps/contentstore/api/views/course_import.py @@ -155,7 +155,7 @@ def post(self, request, course_key): }) except Exception as e: log.exception(f'Course import {course_key}: Unknown error in import') - raise self.api_error( + raise self.api_error( # noqa: B904 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, developer_message=str(e), error_code='internal_error' @@ -177,7 +177,7 @@ def get(self, request, course_key): }) except Exception as e: log.exception(str(e)) - raise self.api_error( + raise self.api_error( # noqa: B904 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, developer_message=str(e), error_code='internal_error' diff --git a/cms/djangoapps/contentstore/api/views/course_quality.py b/cms/djangoapps/contentstore/api/views/course_quality.py index b301f5ac1420..123fc1ea0d0f 100644 --- a/cms/djangoapps/contentstore/api/views/course_quality.py +++ b/cms/djangoapps/contentstore/api/views/course_quality.py @@ -1,19 +1,22 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring import logging import time import numpy as np from edxval.api import get_course_videos_qset +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.generics import GenericAPIView from rest_framework.response import Response from scipy import stats +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import authz_permission_required from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from openedx.core.lib.cache_utils import request_cached from openedx.core.lib.graph_traversals import traverse_pre_order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order -from .utils import course_author_access_required, get_bool_param +from .utils import get_bool_param log = logging.getLogger(__name__) @@ -82,7 +85,7 @@ class CourseQualityView(DeveloperErrorViewMixin, GenericAPIView): # does not specify a serializer class. swagger_schema = None - @course_author_access_required + @authz_permission_required(COURSES_VIEW_COURSE.identifier, LegacyAuthoringPermission.READ) def get(self, request, course_key): """ Returns validation information for the given course. @@ -132,7 +135,7 @@ def _execute_method_and_log_time(log_time, func, *args): return Response(response) - def _required_course_depth(self, request, all_requested): # lint-amnesty, pylint: disable=missing-function-docstring + def _required_course_depth(self, request, all_requested): # pylint: disable=missing-function-docstring if get_bool_param(request, 'units', all_requested): # The num_blocks metric for "units" requires retrieving all blocks in the graph. return None @@ -155,7 +158,7 @@ def _sections_quality(self, course): highlights_enabled=True, # used to be controlled by a waffle switch, now just always enabled ) - def _subsections_quality(self, course, request): # lint-amnesty, pylint: disable=missing-function-docstring + def _subsections_quality(self, course, request): # pylint: disable=missing-function-docstring subsection_unit_dict = self._get_subsections_and_units(course, request) num_block_types_per_subsection_dict = {} for subsection_key, unit_dict in subsection_unit_dict.items(): @@ -171,7 +174,7 @@ def _subsections_quality(self, course, request): # lint-amnesty, pylint: disabl num_block_types=self._stats_dict(list(num_block_types_per_subsection_dict.values())), ) - def _units_quality(self, course, request): # lint-amnesty, pylint: disable=missing-function-docstring + def _units_quality(self, course, request): # pylint: disable=missing-function-docstring subsection_unit_dict = self._get_subsections_and_units(course, request) num_leaf_blocks_per_unit = [ unit_info['num_leaf_blocks'] @@ -183,7 +186,7 @@ def _units_quality(self, course, request): # lint-amnesty, pylint: disable=miss num_blocks=self._stats_dict(num_leaf_blocks_per_unit), ) - def _videos_quality(self, course): # lint-amnesty, pylint: disable=missing-function-docstring + def _videos_quality(self, course): # pylint: disable=missing-function-docstring video_blocks_in_course = modulestore().get_items(course.id, qualifiers={'category': 'video'}) video_durations = [cv.video.duration for cv in get_course_videos_qset(course.id)] @@ -229,7 +232,7 @@ def _get_sections(cls, course): return cls._get_all_children(course) @classmethod - def _get_all_children(cls, parent): # lint-amnesty, pylint: disable=missing-function-docstring + def _get_all_children(cls, parent): # pylint: disable=missing-function-docstring store = modulestore() children = [store.get_item(child_usage_key) for child_usage_key in cls._get_children(parent)] visible_children = [ @@ -244,14 +247,14 @@ def _get_visible_children(cls, parent): return visible_chidren @classmethod - def _get_children(cls, parent): # lint-amnesty, pylint: disable=missing-function-docstring + def _get_children(cls, parent): # pylint: disable=missing-function-docstring if not hasattr(parent, 'children'): return [] else: return parent.children @classmethod - def _get_leaf_blocks(cls, unit): # lint-amnesty, pylint: disable=missing-function-docstring + def _get_leaf_blocks(cls, unit): # pylint: disable=missing-function-docstring def leaf_filter(block): return ( block.location.block_type not in ('chapter', 'sequential', 'vertical') and @@ -260,7 +263,7 @@ def leaf_filter(block): return list(traverse_pre_order(unit, cls._get_visible_children, leaf_filter)) - def _stats_dict(self, data): # lint-amnesty, pylint: disable=missing-function-docstring + def _stats_dict(self, data): # pylint: disable=missing-function-docstring if not data: return dict( min=None, diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py index d3565fc1688d..668431fa0e50 100644 --- a/cms/djangoapps/contentstore/api/views/course_validation.py +++ b/cms/djangoapps/contentstore/api/views/course_validation.py @@ -1,10 +1,11 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring import logging import dateutil import edx_api_doc_tools as apidocs from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from pytz import UTC from rest_framework import serializers, status from rest_framework.generics import GenericAPIView @@ -16,11 +17,13 @@ from cms.djangoapps.contentstore.tasks import migrate_course_legacy_library_blocks_to_item_bank from cms.djangoapps.contentstore.views.certificates import CertificateManager from common.djangoapps.util.proctoring import requires_escalation_email +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import authz_permission_required from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.serializers import StatusSerializerWithUuid from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from xmodule.course_metadata_utils import DEFAULT_GRADING_POLICY # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.course_metadata_utils import DEFAULT_GRADING_POLICY # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from .utils import course_author_access_required, get_bool_param, get_ready_to_migrate_legacy_library_content_blocks @@ -80,7 +83,7 @@ class CourseValidationView(DeveloperErrorViewMixin, GenericAPIView): # does not specify a serializer class. swagger_schema = None - @course_author_access_required + @authz_permission_required(COURSES_VIEW_COURSE.identifier, LegacyAuthoringPermission.READ) def get(self, request, course_key): """ Returns validation information for the given course. @@ -133,7 +136,7 @@ def _dates_validation(self, course): has_end_date=course.end is not None, ) - def _assignments_validation(self, course, request): # lint-amnesty, pylint: disable=missing-function-docstring + def _assignments_validation(self, course, request): # pylint: disable=missing-function-docstring assignments, visible_assignments = self._get_assignments(course) assignments_with_dates = [ a for a in visible_assignments if a.due @@ -241,7 +244,7 @@ def _updates_validation(self, course, request): has_update=len(updates) > 0, ) - def _get_assignments(self, course): # lint-amnesty, pylint: disable=missing-function-docstring + def _get_assignments(self, course): # pylint: disable=missing-function-docstring store = modulestore() sections = [store.get_item(section_usage_key) for section_usage_key in course.children] assignments = [ @@ -268,7 +271,7 @@ def _get_open_responses(self, course, graded_only): oras = modulestore().get_items(course.id, qualifiers={'category': 'openassessment'}) return oras if not graded_only else [ora for ora in oras if ora.graded] - def _has_date_before_start(self, ora, start): # lint-amnesty, pylint: disable=missing-function-docstring + def _has_date_before_start(self, ora, start): # pylint: disable=missing-function-docstring if ora.submission_start: if dateutil.parser.parse(ora.submission_start).replace(tzinfo=UTC) < start: return True @@ -285,7 +288,7 @@ def _has_date_before_start(self, ora, start): # lint-amnesty, pylint: disable=m return False - def _has_date_after_end(self, ora, end): # lint-amnesty, pylint: disable=missing-function-docstring + def _has_date_after_end(self, ora, end): # pylint: disable=missing-function-docstring if ora.submission_start: if dateutil.parser.parse(ora.submission_start).replace(tzinfo=UTC) > end: return True @@ -304,7 +307,7 @@ def _has_date_after_end(self, ora, end): # lint-amnesty, pylint: disable=missin def _has_start_date(self, course): return not course.start_date_is_still_default - def _has_grading_policy(self, course): # lint-amnesty, pylint: disable=missing-function-docstring + def _has_grading_policy(self, course): # pylint: disable=missing-function-docstring grading_policy_formatted = {} default_grading_policy_formatted = {} @@ -361,7 +364,7 @@ class CourseLegacyLibraryContentSerializer(serializers.Serializer): usage_key = serializers.CharField() -class CourseLegacyLibraryContentMigratorView(StatusViewSet): +class CourseLegacyLibraryContentMigratorView(DeveloperErrorViewMixin, StatusViewSet): """ This endpoint is used for migrating legacy library content to the new item bank block library v2. """ @@ -381,7 +384,7 @@ class CourseLegacyLibraryContentMigratorView(StatusViewSet): 401: "The requester is not authenticated.", }, ) - @course_author_access_required + @authz_permission_required(COURSES_VIEW_COURSE.identifier, LegacyAuthoringPermission.WRITE) def list(self, _, course_key): # pylint: disable=arguments-differ """ Returns all legacy library content blocks ready to be migrated to new item bank block. diff --git a/cms/djangoapps/contentstore/api/views/utils.py b/cms/djangoapps/contentstore/api/views/utils.py index 5d7dd8ff06d1..57d3ae30497b 100644 --- a/cms/djangoapps/contentstore/api/views/utils.py +++ b/cms/djangoapps/contentstore/api/views/utils.py @@ -14,7 +14,7 @@ from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from openedx.core.lib.cache_utils import request_cached from xmodule.library_content_block import LegacyLibraryContentBlock -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order @view_auth_classes() @@ -120,7 +120,7 @@ def course_author_access_required(view): Usage:: @course_author_access_required def my_view(request, course_key): - # Some functionality ... + # Some functionality... """ def _wrapper_view(self, request, course_id, *args, **kwargs): """ diff --git a/cms/djangoapps/contentstore/apps.py b/cms/djangoapps/contentstore/apps.py index a1ff02e4cabc..93818066a3ee 100644 --- a/cms/djangoapps/contentstore/apps.py +++ b/cms/djangoapps/contentstore/apps.py @@ -20,4 +20,4 @@ def ready(self): """ # Can't import models at module level in AppConfigs, and models get # included from the signal handlers - from .signals import handlers # pylint: disable=unused-import + from .signals import handlers # pylint: disable=unused-import # noqa: F401 diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py index 2489be61bae3..6c84311b4574 100644 --- a/cms/djangoapps/contentstore/asset_storage_handlers.py +++ b/cms/djangoapps/contentstore/asset_storage_handlers.py @@ -17,25 +17,32 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods, require_POST from opaque_keys.edx.keys import AssetKey, CourseKey +from openedx_authz.constants.permissions import ( + COURSES_CREATE_FILES, + COURSES_DELETE_FILES, + COURSES_EDIT_FILES, + COURSES_VIEW_FILES, +) +from openedx_filters.content_authoring.filters import LMSPageURLRequested from pymongo import ASCENDING, DESCENDING -from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.json_request import JsonResponse +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.djangoapps.contentserver.caching import del_cached_content from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.models import UserPreference -from openedx_filters.content_authoring.filters import LMSPageURLRequested -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.toggles import enable_authz_course_authoring +from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.exceptions import NotFoundError # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order from .exceptions import AssetNotFoundException, AssetSizeTooLargeException from .utils import get_files_uploads_url, get_response_format, request_response_format_is_json - REQUEST_DEFAULTS = { 'page': 0, 'page_size': 50, @@ -73,8 +80,8 @@ def handle_assets(request, course_key_string=None, asset_key_string=None): json: delete an asset ''' course_key = CourseKey.from_string(course_key_string) - if not has_course_author_access(request.user, course_key): - raise PermissionDenied() + # Enforce file permissions. + _authz_enforce_file_permissions(request, course_key) response_format = get_response_format(request) if request_response_format_is_json(request, response_format): @@ -91,12 +98,60 @@ def handle_assets(request, course_key_string=None, asset_key_string=None): return HttpResponseNotFound() +def _authz_enforce_file_permissions(request, course_key): + """ + Enforce permissions for file operations in asset handler. + When the authz.enable_course_authoring flag is enabled for the specified course, + This function enforces the appropriate file permission depending on request content. + When the flag is disabled, it enforces the legacy has_studio_write_access permission. + """ + # Enforce permission to view files. + # This is the minimum permission needed for handling assets. + if not user_has_course_permission( + request.user, + COURSES_VIEW_FILES.identifier, + course_key, + LegacyAuthoringPermission.WRITE + ): + raise PermissionDenied() + + if enable_authz_course_authoring(course_key): + # Check create, edit and delete permissions for AuthZ-enabled courses. + if request.method in ('PUT', 'POST'): + permission = ( + COURSES_CREATE_FILES.identifier + if 'file' in request.FILES + else COURSES_EDIT_FILES.identifier + ) + + if not user_has_course_permission( + request.user, + permission, + course_key, + LegacyAuthoringPermission.WRITE + ): + raise PermissionDenied() + + if request.method == 'DELETE' and not user_has_course_permission( + request.user, + COURSES_DELETE_FILES.identifier, + course_key, + LegacyAuthoringPermission.WRITE + ): + raise PermissionDenied() + + def get_asset_usage_path_json(request, course_key, asset_key_string): """ Get a list of units with ancestors that use given asset. """ course_key = CourseKey.from_string(course_key) - if not has_course_author_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_FILES.identifier, + course_key, + LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() asset_location = AssetKey.from_string(asset_key_string) if asset_key_string else None usage_locations = _get_asset_usage_path(course_key, [{'asset_key': asset_location}]) @@ -214,7 +269,7 @@ def _assets_json(request, course_key): assets_usage_locations_map = _get_asset_usage_path(course_key, assets) - if request_options['requested_page'] > 0 and first_asset_to_display_index >= total_count and total_count > 0: # lint-amnesty, pylint: disable=chained-comparison + if request_options['requested_page'] > 0 and first_asset_to_display_index >= total_count and total_count > 0: # pylint: disable=chained-comparison _update_options_to_requery_final_page(query_options, total_count) current_page = query_options['current_page'] first_asset_to_display_index = _get_first_asset_index(current_page, requested_page_size) @@ -275,7 +330,7 @@ def _get_error_if_invalid_parameters(requested_filter): if invalid_filters: error_message = { 'error_code': 'invalid_asset_type_filter', - 'developer_message': 'The asset_type parameter to the request is invalid. ' + 'developer_message': 'The asset_type parameter to the request is invalid. ' # noqa: UP032 'The {} filters are not described in the settings.FILES_AND_UPLOAD_TYPE_FILTERS ' 'dictionary.'.format(invalid_filters) } @@ -536,7 +591,7 @@ def _upload_asset(request, course_key): }) -def _get_error_if_course_does_not_exist(course_key): # lint-amnesty, pylint: disable=missing-function-docstring +def _get_error_if_course_does_not_exist(course_key): # pylint: disable=missing-function-docstring try: modulestore().get_course(course_key) except ItemNotFoundError: @@ -544,7 +599,7 @@ def _get_error_if_course_does_not_exist(course_key): # lint-amnesty, pylint: di return HttpResponseBadRequest() -def _get_file_metadata_as_dictionary(upload_file): # lint-amnesty, pylint: disable=missing-function-docstring +def _get_file_metadata_as_dictionary(upload_file): # pylint: disable=missing-function-docstring # compute a 'filename' which is similar to the location formatting; we're # using the 'filename' nomenclature since we're using a FileSystem paradigm # here; we're just imposing the Location string formatting expectations to @@ -672,15 +727,15 @@ def delete_asset(course_key, asset_key): del_cached_content(content.location) -def _check_existence_and_get_asset_content(asset_key): # lint-amnesty, pylint: disable=missing-function-docstring +def _check_existence_and_get_asset_content(asset_key): # pylint: disable=missing-function-docstring try: content = contentstore().find(asset_key) return content except NotFoundError: - raise AssetNotFoundException # lint-amnesty, pylint: disable=raise-missing-from + raise AssetNotFoundException # pylint: disable=raise-missing-from # noqa: B904 -def _delete_thumbnail(thumbnail_location, course_key, asset_key): # lint-amnesty, pylint: disable=missing-function-docstring +def _delete_thumbnail(thumbnail_location, course_key, asset_key): # pylint: disable=missing-function-docstring if thumbnail_location is not None: # We are ignoring the value of the thumbnail_location-- we only care whether diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index 40d11817667e..d0f6033022e5 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -13,12 +13,12 @@ LOG_PREFIX = 'Studio: ' # Switches -ENABLE_ACCESSIBILITY_POLICY_PAGE = WaffleSwitch( # lint-amnesty, pylint: disable=toggle-missing-annotation +ENABLE_ACCESSIBILITY_POLICY_PAGE = WaffleSwitch( # pylint: disable=toggle-missing-annotation f'{WAFFLE_NAMESPACE}.enable_policy_page', __name__ ) # TODO: After removing this flag, add a migration to remove waffle flag in a follow-up deployment. -ENABLE_CHECKLISTS_QUALITY = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation +ENABLE_CHECKLISTS_QUALITY = CourseWaffleFlag( # pylint: disable=toggle-missing-annotation f'{WAFFLE_NAMESPACE}.enable_checklists_quality', __name__, LOG_PREFIX ) diff --git a/cms/djangoapps/contentstore/core/course_optimizer_provider.py b/cms/djangoapps/contentstore/core/course_optimizer_provider.py index 134329992cfe..434d8729e77e 100644 --- a/cms/djangoapps/contentstore/core/course_optimizer_provider.py +++ b/cms/djangoapps/contentstore/core/course_optimizer_provider.py @@ -11,7 +11,7 @@ CourseLinkCheckTask, CourseLinkUpdateTask, LinkState, - extract_content_URLs_from_course + extract_content_URLs_from_course, ) from cms.djangoapps.contentstore.utils import create_course_info_usage_key, get_previous_run_course_key from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock diff --git a/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py index 0780848dfda9..d4d1a8d6ee63 100644 --- a/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py +++ b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py @@ -11,7 +11,7 @@ _create_dto_recursive, _update_node_tree_and_dictionary, generate_broken_links_descriptor, - sort_course_sections + sort_course_sections, ) from cms.djangoapps.contentstore.tasks import LinkState, extract_content_URLs_from_course from cms.djangoapps.contentstore.tests.utils import CourseTestCase @@ -73,7 +73,7 @@ def test_update_node_tree_and_dictionary_returns_node_tree(self): self.mock_block, 'example_link', LinkState.LOCKED, {}, {} ) - self.assertEqual(expected_tree, result_tree) + self.assertEqual(expected_tree, result_tree) # noqa: PT009 def test_update_node_tree_and_dictionary_returns_dictionary(self): """ @@ -104,7 +104,7 @@ def test_update_node_tree_and_dictionary_returns_dictionary(self): self.mock_block, 'example_link', LinkState.LOCKED, {}, {} ) - self.assertEqual(expected_dictionary, result_dictionary) + self.assertEqual(expected_dictionary, result_dictionary) # noqa: PT009 def test_create_dto_recursive_returns_for_empty_node(self): """ @@ -112,7 +112,7 @@ def test_create_dto_recursive_returns_for_empty_node(self): Function should return None when given empty node tree and empty dictionary. """ expected = _create_dto_recursive({}, {}) - self.assertEqual(None, expected) + self.assertEqual(None, expected) # noqa: PT009 def test_create_dto_recursive_returns_for_leaf_node(self): """ @@ -159,7 +159,7 @@ def test_create_dto_recursive_returns_for_leaf_node(self): } } expected = _create_dto_recursive(mock_node_tree, mock_dictionary) - self.assertEqual(expected_result, expected) + self.assertEqual(expected_result, expected) # noqa: PT009 def test_create_dto_recursive_returns_for_full_tree(self): """ @@ -231,7 +231,7 @@ def test_create_dto_recursive_returns_for_full_tree(self): } expected = _create_dto_recursive(mock_node_tree, mock_dictionary) - self.assertEqual(expected_result, expected) + self.assertEqual(expected_result, expected) # noqa: PT009 @mock.patch('cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore', autospec=True) def test_returns_unchanged_data_if_no_course_blocks(self, mock_modulestore): @@ -306,7 +306,7 @@ def test_sorts_sections_correctly(self, mock_modulestore): {"id": "section3", "name": "Bonus"}, {"id": "section1", "name": "Intro"}, ] - self.assertEqual(result["LinkCheckOutput"]["sections"], expected_sections) + self.assertEqual(result["LinkCheckOutput"]["sections"], expected_sections) # noqa: PT009 def test_prev_run_link_detection(self): """Test the core logic of separating previous run links from regular links.""" @@ -330,7 +330,7 @@ def test_prev_run_link_detection(self): for url, expected_match in test_cases: with self.subTest(url=url, expected=expected_match): result = contains_course_reference(url, previous_course_key) - self.assertEqual( + self.assertEqual( # noqa: PT009 result, expected_match, f"URL '{url}' should {'match' if expected_match else 'not match'} previous course", @@ -362,7 +362,7 @@ def test_enhanced_url_detection_edge_cases(self): with self.subTest(content=content): urls = extract_content_URLs_from_course(content) for expected_url in expected_urls: - self.assertIn( + self.assertIn( # noqa: PT009 expected_url, urls, f"Should find '{expected_url}' in content: {content}", @@ -459,19 +459,19 @@ def test_course_updates_and_custom_pages_structure(self): ) # Verify top-level structure - self.assertIn("sections", result) - self.assertIn("course_updates", result) - self.assertIn("custom_pages", result) - self.assertNotIn("handouts", result) + self.assertIn("sections", result) # noqa: PT009 + self.assertIn("course_updates", result) # noqa: PT009 + self.assertIn("custom_pages", result) # noqa: PT009 + self.assertNotIn("handouts", result) # noqa: PT009 # Course updates should include both updates and handouts - self.assertGreaterEqual( + self.assertGreaterEqual( # noqa: PT009 len(result["course_updates"]), 1, "Should have course updates/handouts", ) # Custom pages should have custom pages data - self.assertGreaterEqual( + self.assertGreaterEqual( # noqa: PT009 len(result["custom_pages"]), 1, "Should have custom pages" ) diff --git a/cms/djangoapps/contentstore/course_group_config.py b/cms/djangoapps/contentstore/course_group_config.py index a6babd0a0c2f..0c9b40c4a14e 100644 --- a/cms/djangoapps/contentstore/course_group_config.py +++ b/cms/djangoapps/contentstore/course_group_config.py @@ -12,18 +12,25 @@ from cms.djangoapps.contentstore.utils import reverse_usage_url from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id from lms.lib.utils import get_parent_unit + # Re-exported for backward compatibility - other modules import these from here from openedx.core.djangoapps.course_groups.constants import ( # pylint: disable=unused-import COHORT_SCHEME, CONTENT_GROUP_CONFIGURATION_DESCRIPTION, CONTENT_GROUP_CONFIGURATION_NAME, - ENROLLMENT_SCHEME, + ENROLLMENT_SCHEME, # noqa: F401 RANDOM_SCHEME, ) from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition -from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, ReadOnlyUserPartitionError, UserPartition # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.split_test_block import get_split_user_partitions # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.partitions.partitions import ( # pylint: disable=wrong-import-order + MINIMUM_UNUSED_PARTITION_ID, + ReadOnlyUserPartitionError, + UserPartition, +) +from xmodule.partitions.partitions_service import ( + get_all_partitions_for_course, # pylint: disable=wrong-import-order +) +from xmodule.split_test_block import get_split_user_partitions # pylint: disable=wrong-import-order MINIMUM_GROUP_ID = MINIMUM_UNUSED_PARTITION_ID @@ -34,7 +41,7 @@ class GroupConfigurationsValidationError(Exception): """ An error thrown when a group configurations input is invalid. """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass class GroupConfiguration: @@ -60,7 +67,7 @@ def parse(json_string): try: configuration = json.loads(json_string.decode("utf-8")) except ValueError: - raise GroupConfigurationsValidationError(_("invalid JSON")) # lint-amnesty, pylint: disable=raise-missing-from + raise GroupConfigurationsValidationError(_("invalid JSON")) # pylint: disable=raise-missing-from # noqa: B904 configuration["version"] = UserPartition.VERSION return configuration @@ -109,7 +116,7 @@ def get_user_partition(self): try: return UserPartition.from_json(self.configuration) except ReadOnlyUserPartitionError: - raise GroupConfigurationsValidationError(_("unable to load this type of group configuration")) # lint-amnesty, pylint: disable=raise-missing-from + raise GroupConfigurationsValidationError(_("unable to load this type of group configuration")) # pylint: disable=raise-missing-from # noqa: B904 @staticmethod def _get_usage_dict(course, unit, block, scheme_name=None): diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index e8a359d80564..59a0d6a8fe72 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -19,11 +19,11 @@ from django.http import HttpResponseBadRequest from django.utils.translation import gettext as _ -from cms.djangoapps.contentstore.utils import track_course_update_event, send_course_update_notification +from cms.djangoapps.contentstore.utils import send_course_update_notification, track_course_update_event from openedx.core.lib.xblock_utils import get_course_update_items -from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.html_block import CourseInfoBlock # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order # # This should be in a class which inherits from XModuleDescriptor log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/courseware_index.py b/cms/djangoapps/contentstore/courseware_index.py index b7b74992035a..e5b8c5ce0df9 100644 --- a/cms/djangoapps/contentstore/courseware_index.py +++ b/cms/djangoapps/contentstore/courseware_index.py @@ -15,9 +15,9 @@ from cms.djangoapps.contentstore.course_group_config import GroupConfiguration from common.djangoapps.course_modes.models import CourseMode from openedx.core.lib.courses import course_image_url, course_organization_image_url -from xmodule.annotator_mixin import html_to_text # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.library_tools import normalize_key_for_search # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.annotator_mixin import html_to_text # pylint: disable=wrong-import-order +from xmodule.library_tools import normalize_key_for_search # pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order # REINDEX_AGE is the default amount of time that we look back for changes # that might have happened. If we are provided with a time at which the @@ -114,7 +114,7 @@ def remove_deleted_items(cls, searcher, structure_key, exclude_items): searcher.remove(result_ids) @classmethod - def index(cls, modulestore, structure_key, triggered_at=None, reindex_age=REINDEX_AGE, timeout=INDEXING_REQUEST_TIMEOUT): # lint-amnesty, pylint: disable=line-too-long, too-many-statements + def index(cls, modulestore, structure_key, triggered_at=None, reindex_age=REINDEX_AGE, timeout=INDEXING_REQUEST_TIMEOUT): # pylint: disable=line-too-long, too-many-statements """ Process course for indexing @@ -185,7 +185,7 @@ def prepare_item_index(item, skip_index=False, groups_usage_info=None): item_content_groups = None - if item.category == "split_test": # lint-amnesty, pylint: disable=too-many-nested-blocks + if item.category == "split_test": # pylint: disable=too-many-nested-blocks split_partition = item.get_selected_partition() for split_test_child in item.get_children(): if split_partition: @@ -313,7 +313,7 @@ def fetch_group_usage(cls, modulestore, structure): # pylint: disable=unused-ar """ return None - @classmethod + @classmethod # noqa: B027 def supplemental_index_information(cls, modulestore, structure): """ Perform any supplemental indexing given that the structure object has @@ -326,7 +326,7 @@ def supplemental_index_information(cls, modulestore, structure): Returns: None """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass @classmethod def supplemental_fields(cls, item): # pylint: disable=unused-argument @@ -669,7 +669,7 @@ def _get_location_info(cls, normalized_structure_key): return {"course": str(normalized_structure_key), "org": normalized_structure_key.org} @classmethod - def remove_deleted_items(cls, structure_key): # lint-amnesty, pylint: disable=arguments-differ + def remove_deleted_items(cls, structure_key): # pylint: disable=arguments-differ """ Remove item from Course About Search_index """ searcher = SearchEngine.get_search_engine(cls.INDEX_NAME) if not searcher: diff --git a/cms/djangoapps/contentstore/debug_file_uploader.py b/cms/djangoapps/contentstore/debug_file_uploader.py index e9546abd24cb..f8a540e2c831 100644 --- a/cms/djangoapps/contentstore/debug_file_uploader.py +++ b/cms/djangoapps/contentstore/debug_file_uploader.py @@ -6,7 +6,7 @@ from django.core.files.uploadhandler import FileUploadHandler -class DebugFileUploader(FileUploadHandler): # lint-amnesty, pylint: disable=missing-class-docstring +class DebugFileUploader(FileUploadHandler): # pylint: disable=missing-class-docstring def __init__(self, request=None): super().__init__(request) self.count = 0 diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py index 8a4ddc09425e..384dd1041e7a 100644 --- a/cms/djangoapps/contentstore/exams.py +++ b/cms/djangoapps/contentstore/exams.py @@ -33,7 +33,7 @@ def register_exams(course_key): course = modulestore().get_course(course_key) if course is None: - raise ItemNotFoundError("Course {} does not exist", str(course_key)) # lint-amnesty, pylint: disable=raising-format-tuple + raise ItemNotFoundError("Course {} does not exist", str(course_key)) # pylint: disable=raising-format-tuple # get all sequences, since they can be marked as timed/proctored exams _timed_exams = modulestore().get_items( @@ -58,7 +58,7 @@ def register_exams(course_key): for timed_exam in timed_exams: location = str(timed_exam.location) msg = ( - 'Found {location} as an exam in course structure.'.format( + 'Found {location} as an exam in course structure.'.format( # noqa: UP032 location=location ) ) diff --git a/cms/djangoapps/contentstore/git_export_utils.py b/cms/djangoapps/contentstore/git_export_utils.py index e0ac80a4627f..1d94fea81198 100644 --- a/cms/djangoapps/contentstore/git_export_utils.py +++ b/cms/djangoapps/contentstore/git_export_utils.py @@ -10,13 +10,15 @@ from urllib.parse import urlparse from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 +from openedx.core.djangoapps.content_libraries.api import extract_library_v2_zip_to_dir from xmodule.contentstore.django import contentstore from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_exporter import export_course_to_xml +from xmodule.modulestore.xml_exporter import CourseLocator, export_course_to_xml, export_library_to_xml log = logging.getLogger(__name__) @@ -66,10 +68,28 @@ def cmd_log(cmd, cwd): return output -def export_to_git(course_id, repo, user='', rdir=None): - """Export a course to git.""" +def export_to_git(context_key, repo, user='', rdir=None): + """ + Export a course or library to git. + + Args: + context_key: LearningContextKey for the content to export + repo (str): Git repository URL + user (str): Optional username for git commit identity + rdir (str): Optional custom directory name for the repository + + Raises: + GitExportError: For various git operation failures + """ # pylint: disable=too-many-statements + # Validate context_key type and determine export function and content type label + if not isinstance(context_key, (LibraryLocatorV2, LibraryLocator, CourseLocator)): + raise TypeError( + f"{context_key!r} for git export must be LibraryLocatorV2, LibraryLocator, " + f"or CourseLocator, not {type(context_key)}" + ) + if not GIT_REPO_EXPORT_DIR: raise GitExportError(GitExportError.NO_EXPORT_DIR) @@ -128,15 +148,31 @@ def export_to_git(course_id, repo, user='', rdir=None): log.exception('Failed to pull git repository: %r', ex.output) raise GitExportError(GitExportError.CANNOT_PULL) from ex - # export course as xml before commiting and pushing + # export content as xml (or zip for v2 libraries) before commiting and pushing root_dir = os.path.dirname(rdirp) - course_dir = os.path.basename(rdirp).rsplit('.git', 1)[0] + content_dir = os.path.basename(rdirp).rsplit('.git', 1)[0] + + content_type_label = "course" if context_key.is_course else "library" + + is_library_v2 = isinstance(context_key, LibraryLocatorV2) + if is_library_v2: + # V2 libraries use backup API with zip extraction + content_export_func = extract_library_v2_zip_to_dir + elif isinstance(context_key, LibraryLocator): + content_export_func = export_library_to_xml + else: + content_export_func = export_course_to_xml + try: - export_course_to_xml(modulestore(), contentstore(), course_id, - root_dir, course_dir) - except (OSError, AttributeError): - log.exception('Failed export to xml') - raise GitExportError(GitExportError.XML_EXPORT_FAIL) # lint-amnesty, pylint: disable=raise-missing-from + if is_library_v2: + content_export_func(context_key, root_dir, content_dir, user) + else: + # V1 libraries and courses: use XML export (no user parameter) + content_export_func(modulestore(), contentstore(), context_key, + root_dir, content_dir) + except (OSError, AttributeError) as ex: + log.exception('Failed to export %s', content_type_label) + raise GitExportError(GitExportError.XML_EXPORT_FAIL) from ex # Get current branch if not already set if not branch: @@ -160,9 +196,7 @@ def export_to_git(course_id, repo, user='', rdir=None): ident = GIT_EXPORT_DEFAULT_IDENT time_stamp = timezone.now() cwd = os.path.abspath(rdirp) - commit_msg = "Export from Studio at {time_stamp}".format( - time_stamp=time_stamp, - ) + commit_msg = f"Export {content_type_label} from Studio at {time_stamp}" try: cmd_log(['git', 'config', 'user.email', ident['email']], cwd) cmd_log(['git', 'config', 'user.name', ident['name']], cwd) @@ -180,3 +214,10 @@ def export_to_git(course_id, repo, user='', rdir=None): except subprocess.CalledProcessError as ex: log.exception('Error running git push command: %r', ex.output) raise GitExportError(GitExportError.CANNOT_PUSH) from ex + + log.info( + '%s %s exported to git repository %s successfully', + content_type_label.capitalize(), + context_key, + repo, + ) diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 9bfab1f1f385..93d71ce811bb 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -6,44 +6,45 @@ Only Studio-specfic helper functions should be added here. Platform-wide Python APIs should be added to an appropriate api.py file instead. """ + from __future__ import annotations + import json import logging import pathlib +import re import urllib -from lxml import etree from mimetypes import guess_type -import re -from attrs import frozen, Factory -from django.core.files.base import ContentFile +from attrs import Factory, frozen from django.conf import settings from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile from django.utils.translation import gettext as _ +from edxval.api import create_external_video, create_or_update_video_transcript +from lxml import etree from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import DefinitionLocator, LocalId -from openedx.core.djangoapps.content_tagging.types import TagValuesByObjectIdDict from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.runtime import IdGenerator -from xmodule.contentstore.content import StaticContent -from xmodule.contentstore.django import contentstore -from xmodule.exceptions import NotFoundError -from xmodule.modulestore.django import modulestore -from xmodule.xml_block import XmlMixin -from openedx.core.djangoapps.video_config.transcripts_utils import Transcript, build_components_import_path -from edxval.api import ( - create_external_video, - create_or_update_video_transcript, -) +import openedx.core.djangoapps.content_staging.api as content_staging_api +import openedx.core.djangoapps.content_tagging.api as content_tagging_api from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -import openedx.core.djangoapps.content_staging.api as content_staging_api -import openedx.core.djangoapps.content_tagging.api as content_tagging_api +from openedx.core.djangoapps.content_staging.api import StagedContentID from openedx.core.djangoapps.content_staging.data import LIBRARY_SYNC_PURPOSE +from openedx.core.djangoapps.content_tagging.types import TagValuesByObjectIdDict +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.video_config.transcripts_utils import Transcript, build_components_import_path +from openedx.core.types import AuthUser as UserType +from xmodule.contentstore.content import StaticContent +from xmodule.contentstore.django import contentstore +from xmodule.exceptions import NotFoundError +from xmodule.modulestore.django import modulestore +from xmodule.xml_block import XmlMixin from .utils import reverse_course_url, reverse_library_url, reverse_usage_url @@ -229,7 +230,7 @@ def xblock_type_display_name(xblock, default_display_name=None): return _('Problem Bank') component_class = XBlock.load_class(category) if hasattr(component_class, 'display_name') and component_class.display_name.default: - return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string + return _(component_class.display_name.default) # pylint: disable=translation-of-non-string else: return default_display_name @@ -282,7 +283,10 @@ def create_usage(self, def_id) -> UsageKey: def create_definition(self, block_type, slug=None) -> DefinitionLocator: """ Generate a new definition_id for an XBlock """ # Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved. - return DefinitionLocator(block_type, LocalId(block_type)) + # FIXME: The DefinitionLocator technically only accepts an ObjectId (or a str representing an ObjectId), but + # this code relies on passing a LocalId and having it save the LocalId object as its `definition_id`. We should + # either change this in the future or update DefinitionLocator to support LocalId-typed definition IDs. + return DefinitionLocator(block_type, LocalId(block_type)) # type: ignore[arg-type] @frozen @@ -314,7 +318,7 @@ def _rewrite_static_asset_references(downstream_xblock: XBlock, substitutions: d def _insert_static_files_into_downstream_xblock( - downstream_xblock: XBlock, staged_content_id: int, request + downstream_xblock: XBlock, staged_content_id: StagedContentID, request ) -> StaticFileNotices: """ Gets static files from staged content, and inserts them into the downstream XBlock. @@ -454,7 +458,7 @@ def _fetch_and_set_upstream_link( copied_from_block: str, copied_from_version_num: int, temp_xblock: XBlock, - user: User + user: UserType, ): """ Fetch and set upstream link for the given xblock which is being pasted. This function handles following cases: @@ -505,8 +509,18 @@ def _fetch_and_set_upstream_link( # temp_xblock.display_name == temp_xblock.upstream_display_name # temp_xblock.data == temp_xblock.upstream_data # for html blocks # Even then we want to set `downstream_customized` value to avoid overriding user customisations on sync - downstream_customized = temp_xblock.xml_attributes.get("downstream_customized", '[]') - temp_xblock.downstream_customized = json.loads(downstream_customized) + downstream_customized = getattr(temp_xblock, "downstream_customized", []) + # XmlMixin blocks expose raw XML attrs on `xml_attributes`; other blocks (e.g. DnD) + # may not have this attribute, but still have parsed downstream_customized field. + xml_attributes = getattr(temp_xblock, "xml_attributes", None) + if isinstance(xml_attributes, dict): + raw_downstream_customized = xml_attributes.get("downstream_customized") + if isinstance(raw_downstream_customized, str): + downstream_customized = json.loads(raw_downstream_customized) + elif isinstance(raw_downstream_customized, list): + downstream_customized = raw_downstream_customized + if hasattr(temp_xblock, "downstream_customized"): + temp_xblock.downstream_customized = downstream_customized def _import_xml_node_to_parent( @@ -515,7 +529,7 @@ def _import_xml_node_to_parent( # The modulestore we're using store, # The user who is performing this operation - user: User, + user: UserType, # Hint to use as usage ID (block_id) for the new XBlock slug_hint: str | None = None, # Content tags applied to the source XBlock(s) @@ -637,7 +651,7 @@ def _import_xml_node_to_parent( def _import_files_into_course( course_key: CourseKey, - staged_content_id: int, + staged_content_id: StagedContentID, static_files: list[content_staging_api.StagedContentFileData], usage_key: UsageKey, ) -> tuple[StaticFileNotices, dict[str, str]]: @@ -687,7 +701,7 @@ def _import_files_into_course( pass # This file already exists; no action needed. else: conflicting_files.append(file_data_obj.filename) - except Exception: # lint-amnesty, pylint: disable=broad-except + except Exception: # pylint: disable=broad-except error_files.append(file_data_obj.filename) log.exception(f"Failed to import Files & Uploads file {file_data_obj.filename}") @@ -702,7 +716,7 @@ def _import_files_into_course( def _import_file_into_course( course_key: CourseKey, - staged_content_id: int, + staged_content_id: StagedContentID, file_data_obj: content_staging_api.StagedContentFileData, usage_key: UsageKey, ) -> tuple[bool | None, dict]: @@ -762,7 +776,7 @@ def _import_file_into_course( def _import_transcripts( block: XBlock, - staged_content_id: int, + staged_content_id: StagedContentID, static_files: list[content_staging_api.StagedContentFileData], ): """ diff --git a/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py b/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py index 99718ce0dd67..04c10f477fff 100644 --- a/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py +++ b/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py @@ -12,10 +12,7 @@ from django.core.management.base import BaseCommand from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.content.learning_sequences.api import ( - get_course_keys_with_outlines, - key_supports_outlines, -) +from openedx.core.djangoapps.content.learning_sequences.api import get_course_keys_with_outlines, key_supports_outlines from ...tasks import update_outline_from_modulestore_task diff --git a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py index 768c3a53f7ac..c2d8a187eb2b 100644 --- a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py +++ b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py @@ -12,11 +12,11 @@ import logging from django.core.management.base import BaseCommand -from xmodule.tabs import CourseTabList -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore from cms.djangoapps.contentstore.models import BackfillCourseTabsConfig +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.tabs import CourseTabList logger = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py b/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py index cba7ab452b0a..f8a122488c26 100644 --- a/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py +++ b/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py @@ -5,13 +5,13 @@ For full context, see: https://github.com/openedx/edx-organizations/blob/master/docs/decisions/0001-phase-in-db-backed-organizations-to-all.rst """ -from typing import Dict, List, Set, Tuple +from typing import Dict, List, Set, Tuple # noqa: UP035 from django.core.management import BaseCommand, CommandError from organizations import api as organizations_api from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order class Command(BaseCommand): @@ -151,9 +151,9 @@ def handle(self, *args, **options): def confirm_changes( - options: Dict[str, str], - orgs: List[dict], - org_courseid_pairs: List[Tuple[dict, str]], + options: Dict[str, str], # noqa: UP006 + orgs: List[dict], # noqa: UP006 + org_courseid_pairs: List[Tuple[dict, str]], # noqa: UP006 ) -> bool: """ Should we apply the changes to the database? @@ -192,8 +192,8 @@ def confirm_changes( def bulk_add_data( - orgs: List[dict], - org_courseid_pairs: List[Tuple[dict, str]], + orgs: List[dict], # noqa: UP006 + org_courseid_pairs: List[Tuple[dict, str]], # noqa: UP006 dry_run: bool, activate: bool, ): @@ -246,7 +246,7 @@ def bulk_add_data( print("------------------------------------------------------") -def find_orgslug_courseid_pairs() -> Set[Tuple[str, str]]: +def find_orgslug_courseid_pairs() -> Set[Tuple[str, str]]: # noqa: UP006 """ Returns the unique pairs of (organization short name, course run key string) from the CourseOverviews table, which should contain all course runs in the @@ -266,7 +266,7 @@ def find_orgslug_courseid_pairs() -> Set[Tuple[str, str]]: } -def find_orgslug_libraryid_pairs() -> Set[Tuple[str, str]]: +def find_orgslug_libraryid_pairs() -> Set[Tuple[str, str]]: # noqa: UP006 """ Returns the unique pairs of (organization short name, content library key string) from the modulestore. diff --git a/cms/djangoapps/contentstore/management/commands/clean_cert_name.py b/cms/djangoapps/contentstore/management/commands/clean_cert_name.py index 087a3a4a7245..9c4e761c5b46 100644 --- a/cms/djangoapps/contentstore/management/commands/clean_cert_name.py +++ b/cms/djangoapps/contentstore/management/commands/clean_cert_name.py @@ -158,14 +158,14 @@ def _display(self, results): col_format = "| {{:>{}}} |" self.stdout.write(id_format.format(""), ending='') - for header, width in zip(headers, col_widths): + for header, width in zip(headers, col_widths): # noqa: B905 self.stdout.write(col_format.format(width).format(header), ending='') self.stdout.write('') for idx, result in enumerate(results): self.stdout.write(id_format.format(idx), ending='') - for col, width in zip(result, col_widths): + for col, width in zip(result, col_widths): # noqa: B905 self.stdout.write(col_format.format(width).format(str(col)), ending='') self.stdout.write("") diff --git a/cms/djangoapps/contentstore/management/commands/cleanup_assets.py b/cms/djangoapps/contentstore/management/commands/cleanup_assets.py index ed6b0568b761..3d93c3cac001 100644 --- a/cms/djangoapps/contentstore/management/commands/cleanup_assets.py +++ b/cms/djangoapps/contentstore/management/commands/cleanup_assets.py @@ -32,8 +32,8 @@ def handle(self, *args, **options): # Remove all redundant Mac OS metadata files assets_deleted = content_store.remove_redundant_content_for_courses() success = True - except Exception as err: # lint-amnesty, pylint: disable=broad-except - log.info("=" * 30 + "> failed to cleanup") # lint-amnesty, pylint: disable=logging-not-lazy + except Exception as err: # pylint: disable=broad-except + log.info("=" * 30 + "> failed to cleanup") # pylint: disable=logging-not-lazy log.info("Error:") log.info(err) diff --git a/cms/djangoapps/contentstore/management/commands/compare_course_index_entries.py b/cms/djangoapps/contentstore/management/commands/compare_course_index_entries.py index bbb5bd79fe65..fb7e2d714175 100644 --- a/cms/djangoapps/contentstore/management/commands/compare_course_index_entries.py +++ b/cms/djangoapps/contentstore/management/commands/compare_course_index_entries.py @@ -1,10 +1,12 @@ """A Command to determine if the Mongo active_versions and Django course_index tables are out of sync""" import logging -from django.core.management.base import BaseCommand + from django.conf import settings +from django.core.management.base import BaseCommand from opaque_keys.edx.locator import CourseLocator + from common.djangoapps.split_modulestore_django.models import SplitModulestoreCourseIndex -from xmodule.modulestore.split_mongo.mongo_connection import MongoPersistenceBackend, DjangoFlexPersistenceBackend +from xmodule.modulestore.split_mongo.mongo_connection import DjangoFlexPersistenceBackend, MongoPersistenceBackend logger = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/management/commands/create_course.py b/cms/djangoapps/contentstore/management/commands/create_course.py index 9ff8b0abca5c..d30de0339315 100644 --- a/cms/djangoapps/contentstore/management/commands/create_course.py +++ b/cms/djangoapps/contentstore/management/commands/create_course.py @@ -5,13 +5,13 @@ from datetime import datetime, timedelta -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from cms.djangoapps.contentstore.management.commands.utils import user_from_str from cms.djangoapps.contentstore.views.course import create_new_course_in_store -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import DuplicateCourseError # pylint: disable=wrong-import-order MODULESTORE_CHOICES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @@ -52,7 +52,7 @@ def get_user(self, user): try: user_object = user_from_str(user) except User.DoesNotExist: - raise CommandError(f"No user {user} found.") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError(f"No user {user} found.") # pylint: disable=raise-missing-from # noqa: B904 return user_object def handle(self, *args, **options): diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 7e9e053443f2..f4cadc98787c 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -8,9 +8,9 @@ from opaque_keys.edx.keys import CourseKey from cms.djangoapps.contentstore.utils import delete_course -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from .prompt import query_yes_no @@ -67,12 +67,12 @@ def handle(self, *args, **options): course_key = str(options['course_key']) course_key = CourseKey.from_string(course_key) except InvalidKeyError: - raise CommandError('Invalid course_key: {}'.format(options['course_key'])) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError('Invalid course_key: {}'.format(options['course_key'])) # pylint: disable=raise-missing-from # noqa: B904 if not modulestore().get_course(course_key): raise CommandError('Course not found: {}'.format(options['course_key'])) - print('Preparing to delete course %s from module store....' % options['course_key']) + print('Preparing to delete course %s from module store....' % options['course_key']) # noqa: UP031 if query_yes_no(f'Are you sure you want to delete course {course_key}?', default='no'): if query_yes_no('Are you sure? This action cannot be undone!', default='no'): @@ -80,6 +80,6 @@ def handle(self, *args, **options): if options['remove_assets']: contentstore().delete_all_course_assets(course_key) - print(f'Deleted assets for course {course_key}') # lint-amnesty, pylint: disable=too-many-format-args + print(f'Deleted assets for course {course_key}') # pylint: disable=too-many-format-args print(f'Deleted course {course_key}') diff --git a/cms/djangoapps/contentstore/management/commands/delete_orphans.py b/cms/djangoapps/contentstore/management/commands/delete_orphans.py index 1994adcde90f..dd0b7b7f296d 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_orphans.py +++ b/cms/djangoapps/contentstore/management/commands/delete_orphans.py @@ -6,7 +6,7 @@ from opaque_keys.edx.keys import CourseKey from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import delete_orphans -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order class Command(BaseCommand): @@ -25,7 +25,7 @@ def handle(self, *args, **options): try: course_key = CourseKey.from_string(options['course_id']) except InvalidKeyError: - raise CommandError("Invalid course key.") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Invalid course key.") # pylint: disable=raise-missing-from # noqa: B904 if options['commit']: print('Deleting orphans from the course:') diff --git a/cms/djangoapps/contentstore/management/commands/delete_v1_libraries.py b/cms/djangoapps/contentstore/management/commands/delete_v1_libraries.py index b9a4368f6d6b..15d41d094b35 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_v1_libraries.py +++ b/cms/djangoapps/contentstore/management/commands/delete_v1_libraries.py @@ -3,16 +3,13 @@ import logging from textwrap import dedent +from celery import group from django.core.management import BaseCommand, CommandError - from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator -from xmodule.modulestore.django import modulestore - -from celery import group - from cms.djangoapps.contentstore.tasks import delete_v1_library +from xmodule.modulestore.django import modulestore from .prompt import query_yes_no @@ -65,7 +62,7 @@ def _parse_library_key(self, raw_value): raise CommandError(f"Argument {raw_value} is not a library key") return result - def handle(self, *args, **options): # lint-amnesty, pylint: disable=unused-argument + def handle(self, *args, **options): # pylint: disable=unused-argument """Parse args and generate tasks for deleting content.""" if (not options['library_ids'] and not options['all']) or (options['library_ids'] and options['all']): diff --git a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py index 529d2f881e2f..0daeb0bc08be 100644 --- a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py +++ b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py @@ -1,4 +1,4 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring ### ### Script for editing the course's tabs ### @@ -38,7 +38,7 @@ def print_course(course): # {u'type': u'progress', u'name': u'Progress'}] -class Command(BaseCommand): # lint-amnesty, pylint: disable=missing-class-docstring +class Command(BaseCommand): # pylint: disable=missing-class-docstring help = """See and edit a course's tabs list. Only supports insertion and deletion. Move and rename etc. can be done with a delete followed by an insert. The tabs are numbered starting with 1. @@ -98,4 +98,4 @@ def handle(self, *args, **options): tabs.primitive_insert(course, num - 1, tab_type, name) # -1 as above except ValueError as e: # Cute: translate to CommandError so the CLI error prints nicely. - raise CommandError(e) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError(e) # pylint: disable=raise-missing-from # noqa: B904 diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index 68f35826905a..5654089b4be2 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -32,10 +32,10 @@ def handle(self, *args, **options): try: course_key = CourseKey.from_string(options['course_id']) except InvalidKeyError: - raise CommandError("Invalid course_key: '%s'." % options['course_id']) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Invalid course_key: '%s'." % options['course_id']) # pylint: disable=raise-missing-from # noqa: B904, UP031 if not modulestore().get_course(course_key): - raise CommandError("Course with %s key not found." % options['course_id']) + raise CommandError("Course with %s key not found." % options['course_id']) # noqa: UP031 output_path = options['output_path'] diff --git a/cms/djangoapps/contentstore/management/commands/export_content_library.py b/cms/djangoapps/contentstore/management/commands/export_content_library.py index b56c172e374e..db1b36664f89 100644 --- a/cms/djangoapps/contentstore/management/commands/export_content_library.py +++ b/cms/djangoapps/contentstore/management/commands/export_content_library.py @@ -12,7 +12,7 @@ from opaque_keys.edx.locator import LibraryLocator from cms.djangoapps.contentstore import tasks -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order class Command(BaseCommand): @@ -34,7 +34,7 @@ def handle(self, *args, **options): try: library_key = CourseKey.from_string(options['library_id']) except InvalidKeyError: - raise CommandError('Invalid library ID: "{}".'.format(options['library_id'])) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError('Invalid library ID: "{}".'.format(options['library_id'])) # pylint: disable=raise-missing-from # noqa: B904 if not isinstance(library_key, LibraryLocator): raise CommandError('Argument "{}" is not a library key'.format(options['library_id'])) @@ -50,7 +50,7 @@ def handle(self, *args, **options): # Generate archive using the handy tasks implementation tarball = tasks.create_export_tarball(library, library_key, {}, None) except Exception as e: - raise CommandError(f'Failed to export "{library_key}" with "{e}"') # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError(f'Failed to export "{library_key}" with "{e}"') # pylint: disable=raise-missing-from # noqa: B904 with tarball: # Save generated archive with keyed filename prefix, suffix, n = str(library_key).replace(':', '+'), '.tar.gz', 0 diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 68b291c01342..cc6d20b2bac5 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -47,9 +47,9 @@ def handle(self, *args, **options): try: course_key = CourseKey.from_string(course_id) except InvalidKeyError: - raise CommandError("Unparsable course_id") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Unparsable course_id") # pylint: disable=raise-missing-from # noqa: B904 except IndexError: - raise CommandError("Insufficient arguments") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Insufficient arguments") # pylint: disable=raise-missing-from # noqa: B904 filename = options['output'] pipe_results = False diff --git a/cms/djangoapps/contentstore/management/commands/force_publish.py b/cms/djangoapps/contentstore/management/commands/force_publish.py index bacf496f68ed..986ddec2f115 100644 --- a/cms/djangoapps/contentstore/management/commands/force_publish.py +++ b/cms/djangoapps/contentstore/management/commands/force_publish.py @@ -36,7 +36,7 @@ def handle(self, *args, **options): try: course_key = CourseKey.from_string(options['course_key']) except InvalidKeyError: - raise CommandError("Invalid course key.") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Invalid course key.") # pylint: disable=raise-missing-from # noqa: B904 if not modulestore().get_course(course_key): raise CommandError("Course not found.") diff --git a/cms/djangoapps/contentstore/management/commands/generate_courses.py b/cms/djangoapps/contentstore/management/commands/generate_courses.py index 935dfe014f32..e1865b468a48 100644 --- a/cms/djangoapps/contentstore/management/commands/generate_courses.py +++ b/cms/djangoapps/contentstore/management/commands/generate_courses.py @@ -6,16 +6,16 @@ import json import logging -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from xblock.fields import Date from cms.djangoapps.contentstore.management.commands.utils import user_from_str from cms.djangoapps.contentstore.views.course import create_new_course_in_store from openedx.core.djangoapps.credit.models import CreditProvider -from xmodule.course_block import CourseFields # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.tabs import CourseTabList # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.course_block import CourseFields # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import DuplicateCourseError # pylint: disable=wrong-import-order +from xmodule.tabs import CourseTabList # pylint: disable=wrong-import-order logger = logging.getLogger(__name__) @@ -33,9 +33,9 @@ def handle(self, *args, **options): try: courses = json.loads(options["courses_json"])["courses"] except ValueError: - raise CommandError("Invalid JSON object") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Invalid JSON object") # pylint: disable=raise-missing-from # noqa: B904 except KeyError: - raise CommandError("JSON object is missing courses list") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("JSON object is missing courses list") # pylint: disable=raise-missing-from # noqa: B904 for course_settings in courses: # Validate course @@ -51,7 +51,7 @@ def handle(self, *args, **options): try: user = user_from_str(user_email) except User.DoesNotExist: - logger.warning(user_email + " user does not exist") # lint-amnesty, pylint: disable=logging-not-lazy + logger.warning(user_email + " user does not exist") # pylint: disable=logging-not-lazy logger.warning("Can't create course, proceeding to next course") continue fields = self._process_course_fields(course_settings["fields"]) @@ -101,7 +101,7 @@ def _process_course_fields(self, fields): if field not in all_fields: # field does not exist as a CourseField del fields[field] - logger.info(field + "is not a valid CourseField") # lint-amnesty, pylint: disable=logging-not-lazy + logger.info(field + "is not a valid CourseField") # pylint: disable=logging-not-lazy elif fields[field] is None: # field is unset del fields[field] @@ -112,7 +112,7 @@ def _process_course_fields(self, fields): fields[field] = Date().from_json(date_json) logger.info(field + " has been set to " + date_json) except Exception: # pylint: disable=broad-except - logger.info("The date string could not be parsed for " + field) # lint-amnesty, pylint: disable=logging-not-lazy + logger.info("The date string could not be parsed for " + field) # pylint: disable=logging-not-lazy del fields[field] elif field in course_tab_list_fields: # Generate CourseTabList object from the json value @@ -121,15 +121,15 @@ def _process_course_fields(self, fields): fields[field] = CourseTabList().from_json(course_tab_list_json) logger.info(field + " has been set to " + course_tab_list_json) except Exception: # pylint: disable=broad-except - logger.info("The course tab list string could not be parsed for " + field) # lint-amnesty, pylint: disable=logging-not-lazy + logger.info("The course tab list string could not be parsed for " + field) # pylint: disable=logging-not-lazy del fields[field] else: # CourseField is valid and has been set - logger.info(field + " has been set to " + str(fields[field])) # lint-amnesty, pylint: disable=logging-not-lazy + logger.info(field + " has been set to " + str(fields[field])) # pylint: disable=logging-not-lazy for field in all_fields: if field not in fields: - logger.info(field + " has not been set") # lint-amnesty, pylint: disable=logging-not-lazy + logger.info(field + " has not been set") # pylint: disable=logging-not-lazy return fields def _course_is_valid(self, course): @@ -146,7 +146,7 @@ def _course_is_valid(self, course): ] for setting in required_course_settings: if setting not in course: - logger.warning("Course json is missing " + setting) # lint-amnesty, pylint: disable=logging-not-lazy + logger.warning("Course json is missing " + setting) # pylint: disable=logging-not-lazy is_valid = False # Check fields settings @@ -156,7 +156,7 @@ def _course_is_valid(self, course): if "fields" in course: for setting in required_field_settings: if setting not in course["fields"]: - logger.warning("Fields json is missing " + setting) # lint-amnesty, pylint: disable=logging-not-lazy + logger.warning("Fields json is missing " + setting) # pylint: disable=logging-not-lazy is_valid = False return is_valid diff --git a/cms/djangoapps/contentstore/management/commands/git_export.py b/cms/djangoapps/contentstore/management/commands/git_export.py index d422cbe2b1fc..1ab30080f801 100644 --- a/cms/djangoapps/contentstore/management/commands/git_export.py +++ b/cms/djangoapps/contentstore/management/commands/git_export.py @@ -50,7 +50,7 @@ def handle(self, *args, **options): try: course_key = CourseKey.from_string(options['course_loc']) except InvalidKeyError: - raise CommandError(str(git_export_utils.GitExportError.BAD_COURSE)) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError(str(git_export_utils.GitExportError.BAD_COURSE)) # pylint: disable=raise-missing-from # noqa: B904 try: git_export_utils.export_to_git( @@ -60,4 +60,4 @@ def handle(self, *args, **options): options.get('rdir', None) ) except git_export_utils.GitExportError as ex: - raise CommandError(str(ex)) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError(str(ex)) # pylint: disable=raise-missing-from # noqa: B904 diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 346f10d933b1..d77634f50b34 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -6,11 +6,11 @@ from django.core.management.base import BaseCommand from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded, seed_permissions_roles -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import SignalHandler, modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.xml_importer import import_course_from_xml # pylint: disable=wrong-import-order +from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME # pylint: disable=wrong-import-order class Command(BaseCommand): @@ -49,7 +49,7 @@ def handle(self, *args, **options): do_import_python_lib = do_import_static or not options.get('nopythonlib', False) python_lib_filename = options.get('python_lib_filename') - output = ( + output = ( # noqa: UP032 "Importing...\n" " data_dir={data}, source_dirs={courses}\n" " Importing static content? {import_static}\n" diff --git a/cms/djangoapps/contentstore/management/commands/import_content_library.py b/cms/djangoapps/contentstore/management/commands/import_content_library.py index 7d2a64825a31..7ee14ca16054 100644 --- a/cms/djangoapps/contentstore/management/commands/import_content_library.py +++ b/cms/djangoapps/contentstore/management/commands/import_content_library.py @@ -7,7 +7,7 @@ import os from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core.exceptions import SuspiciousOperation from django.core.management.base import BaseCommand, CommandError from lxml import etree @@ -16,11 +16,11 @@ from cms.djangoapps.contentstore.utils import add_instructor from openedx.core.lib.extract_archive import safe_extractall -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.xml_importer import import_library_from_xml # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import DuplicateCourseError # pylint: disable=wrong-import-order +from xmodule.modulestore.xml_importer import import_library_from_xml # pylint: disable=wrong-import-order class Command(BaseCommand): diff --git a/cms/djangoapps/contentstore/management/commands/prompt.py b/cms/djangoapps/contentstore/management/commands/prompt.py index b53be18c9ec7..51512c304690 100644 --- a/cms/djangoapps/contentstore/management/commands/prompt.py +++ b/cms/djangoapps/contentstore/management/commands/prompt.py @@ -30,7 +30,7 @@ def query_yes_no(question, default="yes"): elif default == "no": prompt = " [y/N] " else: - raise ValueError("invalid default answer: '%s'" % default) + raise ValueError("invalid default answer: '%s'" % default) # noqa: UP031 while True: sys.stdout.write(question + prompt) diff --git a/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py b/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py index a0f04bb279e9..7234f21ed3ce 100644 --- a/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py +++ b/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py @@ -75,7 +75,7 @@ def handle(self, *args, **options): should_process_all = options['all'] force = options['force'] replace = options['replace'] - time_now = datetime.now(tz=timezone.utc) + time_now = datetime.now(tz=timezone.utc) # noqa: UP017 if not courses and not should_process_all: raise CommandError('Either --course or --all argument should be provided.') diff --git a/cms/djangoapps/contentstore/management/commands/reindex_course.py b/cms/djangoapps/contentstore/management/commands/reindex_course.py index 0bd52b6cc16e..73e2346ab547 100644 --- a/cms/djangoapps/contentstore/management/commands/reindex_course.py +++ b/cms/djangoapps/contentstore/management/commands/reindex_course.py @@ -2,12 +2,12 @@ import logging +from datetime import date, datetime from textwrap import dedent from time import time -from datetime import date, datetime -from django.core.management import BaseCommand, CommandError from django.conf import settings +from django.core.management import BaseCommand, CommandError from elasticsearch import exceptions from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -15,7 +15,7 @@ from search.search_engine_base import SearchEngine from cms.djangoapps.contentstore.courseware_index import CourseAboutSearchIndexer, CoursewareSearchIndexer -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from .prompt import query_yes_no @@ -60,7 +60,7 @@ def _parse_course_key(self, raw_value): try: result = CourseKey.from_string(raw_value) except InvalidKeyError: - raise CommandError("Invalid course_key: '%s'." % raw_value) # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError("Invalid course_key: '%s'." % raw_value) # pylint: disable=raise-missing-from # noqa: B904, UP031 if not isinstance(result, CourseLocator): raise CommandError(f"Argument {raw_value} is not a course key") @@ -83,7 +83,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-statements course_option_flag_option = index_all_courses_option or active_option or inclusion_date_option if (not course_ids and not course_option_flag_option) or (course_ids and course_option_flag_option): - raise CommandError(( + raise CommandError(( # noqa: UP034 "reindex_course requires one or more s" " OR the --all, --active, --setup, or --from_inclusion_date flags." )) @@ -172,7 +172,7 @@ def handle(self, *args, **options): # pylint: disable=too-many-statements t = time() - start remaining = total - success - len(errors) logging.warning(f'{success} courses reindexed in {t:.1f} seconds. {remaining} remaining...') - except Exception as exc: # lint-amnesty, pylint: disable=broad-except + except Exception as exc: # pylint: disable=broad-except errors.append(course_key) logging.exception('Error indexing course %s due to the error: %s.', course_key, exc) diff --git a/cms/djangoapps/contentstore/management/commands/reindex_library.py b/cms/djangoapps/contentstore/management/commands/reindex_library.py index cf352576f6be..a1707c49845a 100644 --- a/cms/djangoapps/contentstore/management/commands/reindex_library.py +++ b/cms/djangoapps/contentstore/management/commands/reindex_library.py @@ -8,7 +8,7 @@ from opaque_keys.edx.locator import LibraryLocator from cms.djangoapps.contentstore.courseware_index import LibrarySearchIndexer -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from .prompt import query_yes_no diff --git a/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py b/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py index 6c19a89cf3ff..b9698607608f 100644 --- a/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py +++ b/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py @@ -3,18 +3,18 @@ edits all xblocks in courses which refer to the v1 library to point to the v2 library. """ -import logging import csv +import logging -from django.core.management import BaseCommand, CommandError from celery import group +from django.core.management import BaseCommand, CommandError -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from cms.djangoapps.contentstore.tasks import ( replace_all_library_source_blocks_ids_for_course, + undo_all_library_source_blocks_ids_for_course, validate_all_library_source_blocks_ids_for_course, - undo_all_library_source_blocks_ids_for_course ) +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview log = logging.getLogger(__name__) @@ -62,7 +62,7 @@ def replace_all_library_source_blocks_ids(self, v1_to_v2_lib_map): def validate(self, v1_to_v2_lib_map): """ Validate that replace_all_library_source_blocks_ids was successful""" course_id_strings = list(CourseOverview.get_all_course_keys()) - tasks = group(validate_all_library_source_blocks_ids_for_course.s(course_id, v1_to_v2_lib_map) for course_id in course_id_strings) # lint-amnesty, pylint: disable=line-too-long + tasks = group(validate_all_library_source_blocks_ids_for_course.s(course_id, v1_to_v2_lib_map) for course_id in course_id_strings) # pylint: disable=line-too-long results = tasks.apply_async() validation = set() @@ -107,7 +107,7 @@ def handle(self, *args, **kwargs): file_path = kwargs['file_path'] v1_to_v2_lib_map = {} try: - with open(file_path, 'r', encoding='utf-8') as csvfile: + with open(file_path, 'r', encoding='utf-8') as csvfile: # noqa: UP015 if not file_path.endswith('.csv'): raise CommandError('Invalid file format. Only CSV files are supported.') @@ -124,7 +124,7 @@ def handle(self, *args, **kwargs): except FileNotFoundError: log.error("File not found at '%s'.", {file_path}) - except Exception as e: # lint-amnesty, pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except log.error("An error occurred: %s", {str(e)}) if kwargs['validate']: diff --git a/cms/djangoapps/contentstore/management/commands/sync_courses.py b/cms/djangoapps/contentstore/management/commands/sync_courses.py index 3fa3a4aede61..12c4c0f48796 100644 --- a/cms/djangoapps/contentstore/management/commands/sync_courses.py +++ b/cms/djangoapps/contentstore/management/commands/sync_courses.py @@ -5,15 +5,15 @@ import logging from textwrap import dedent -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from opaque_keys.edx.keys import CourseKey from cms.djangoapps.contentstore.management.commands.utils import user_from_str from cms.djangoapps.contentstore.views.course import create_new_course_in_store from openedx.core.djangoapps.catalog.utils import get_course_runs -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import DuplicateCourseError # pylint: disable=wrong-import-order logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def get_user(self, user): try: user_object = user_from_str(user) except User.DoesNotExist: - raise CommandError(f"No user {user} found.") # lint-amnesty, pylint: disable=raise-missing-from + raise CommandError(f"No user {user} found.") # pylint: disable=raise-missing-from # noqa: B904 return user_object def handle(self, *args, **options): diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py index 53a6c3b5571a..51b85dacd85b 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py @@ -6,8 +6,13 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.learning_sequences.api import get_course_keys_with_outlines -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + SharedModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) from ....outlines import update_outline_from_modulestore diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_tabs.py b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_tabs.py index a83eb621b30e..7d72020e1bd0 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_tabs.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_tabs.py @@ -5,12 +5,12 @@ import ddt from django.core.management import call_command + +from cms.djangoapps.contentstore.models import BackfillCourseTabsConfig from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from cms.djangoapps.contentstore.models import BackfillCourseTabsConfig - @ddt.ddt class BackfillCourseTabsTest(ModuleStoreTestCase): @@ -140,7 +140,7 @@ def test_command_logs_exception_on_error(self, mock_logger): updated_course = CourseFactory() updated_course.tabs = [tab for tab in updated_course.tabs if tab.type != 'dates'] self.update_course(updated_course, ModuleStoreEnum.UserID.test) - updated_course_tabs_before = updated_course.tabs + updated_course_tabs_before = updated_course.tabs # noqa: F841 with mock.patch( 'lms.djangoapps.ccx.modulestore.CCXModulestoreWrapper.update_item', side_effect=[ValueError, None] diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py index 57deafadae7d..43d011a130eb 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py @@ -12,12 +12,14 @@ add_organization_course, get_organization_by_short_name, get_organization_courses, - get_organizations + get_organizations, ) from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import LibraryFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + SharedModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import LibraryFactory # pylint: disable=wrong-import-order from .. import backfill_orgs_and_org_courses @@ -239,5 +241,5 @@ def test_conflicting_arguments(self): """ Test that calling the command with both "--dry" and "--apply" raises an exception. """ - with self.assertRaises(CommandError): + with self.assertRaises(CommandError): # noqa: PT027 call_command("backfill_orgs_and_org_courses", "--dry", "--apply") diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_clean_stale_certificate_availability_dates.py b/cms/djangoapps/contentstore/management/commands/tests/test_clean_stale_certificate_availability_dates.py index 42b3ca408aa2..6cd722d34d67 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_clean_stale_certificate_availability_dates.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_clean_stale_certificate_availability_dates.py @@ -3,8 +3,8 @@ """ from datetime import datetime, timedelta -from django.core.management import CommandError, call_command import pytz +from django.core.management import CommandError, call_command from cms.djangoapps.contentstore.models import CleanStaleCertificateAvailabilityDatesConfig from openedx.core.lib.courses import get_course_by_id @@ -89,7 +89,7 @@ def test_remove_certificate_available_date_from_instructor_paced_course_expect_e "date can be adjusted via Studio in the UI. Aborting operation." ) - with self.assertRaises(CommandError) as error: + with self.assertRaises(CommandError) as error: # noqa: PT027 call_command( "clean_stale_certificate_available_dates", "--course-runs", @@ -109,7 +109,7 @@ def test_remove_certificate_available_date_with_args_from_database(self): expected_error_message = ( "CleanStaleCertificateAvailabilityDatesConfig is disabled, but --args-from-database was requested." ) - with self.assertRaises(CommandError) as error: + with self.assertRaises(CommandError) as error: # noqa: PT027 call_command("clean_stale_certificate_available_dates", "--args-from-database") assert str(error.exception) == expected_error_message @@ -133,7 +133,7 @@ def test_remove_certificate_available_date_with_args_from_database(self): config.enabled = False config.save() - with self.assertRaises(CommandError) as disabled_error: + with self.assertRaises(CommandError) as disabled_error: # noqa: F841, PT027 call_command("clean_stale_certificate_available_dates", "--args-from-database") assert str(error.exception) == expected_error_message diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py b/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py index e31d566543fe..d5850c065f71 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py @@ -5,6 +5,7 @@ from unittest import skip + from django.conf import settings from django.core.management import call_command from opaque_keys.edx.keys import CourseKey @@ -55,12 +56,12 @@ def test_export_all_courses(self): ) course = self.module_store.get_course(CourseKey.from_string('/'.join(['edX', 'course_ignore', '2014_Fall']))) - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 # check that there are two assets ['example.txt', '.example.txt'] in contentstore for imported course all_assets, count = self.content_store.get_all_content_for_course(course.id) - self.assertEqual(count, 2) - self.assertEqual({asset['_id']['name'] for asset in all_assets}, {'.example.txt', 'example.txt'}) + self.assertEqual(count, 2) # noqa: PT009 + self.assertEqual({asset['_id']['name'] for asset in all_assets}, {'.example.txt', 'example.txt'}) # noqa: PT009 # manually add redundant assets (file ".DS_Store" and filename starts with "._") course_filter = course.id.make_asset_key("asset", None) @@ -74,13 +75,13 @@ def test_export_all_courses(self): # check that now course has four assets all_assets, count = self.content_store.get_all_content_for_course(course.id) - self.assertEqual(count, 4) - self.assertEqual( + self.assertEqual(count, 4) # noqa: PT009 + self.assertEqual( # noqa: PT009 {asset['_id']['name'] for asset in all_assets}, {'.example.txt', 'example.txt', '._example_test.txt', '.DS_Store'} ) # now call asset_cleanup command and check that there is only two proper assets in contentstore for the course call_command('cleanup_assets') all_assets, count = self.content_store.get_all_content_for_course(course.id) - self.assertEqual(count, 2) - self.assertEqual({asset['_id']['name'] for asset in all_assets}, {'.example.txt', 'example.txt'}) + self.assertEqual(count, 2) # noqa: PT009 + self.assertEqual({asset['_id']['name'] for asset in all_assets}, {'.example.txt', 'example.txt'}) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py index f821c8a6d561..60d1d473f6e3 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py @@ -16,26 +16,26 @@ class TestArgParsing(TestCase): """ Tests for parsing arguments for the `create_course` management command """ - def setUp(self): # lint-amnesty, pylint: disable=useless-super-delegation + def setUp(self): # pylint: disable=useless-super-delegation super().setUp() def test_no_args(self): errstring = "Error: the following arguments are required: modulestore, user, org, number, run" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('create_course') def test_invalid_store(self): - with self.assertRaises(CommandError): + with self.assertRaises(CommandError): # noqa: PT027 call_command('create_course', "foo", "user@foo.org", "org", "course", "run") def test_nonexistent_user_id(self): errstring = "No user 99 found" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('create_course', "split", "99", "org", "course", "run") def test_nonexistent_user_email(self): errstring = "No user fake@example.com found" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('create_course', "mongo", "fake@example.com", "org", "course", "run") @@ -52,12 +52,12 @@ def test_all_stores_user_email(self): "org", "course", "run", "dummy-course-name" ) new_key = modulestore().make_course_key("org", "course", "run") - self.assertTrue( + self.assertTrue( # noqa: PT009 modulestore().has_course(new_key), f"Could not find course in {ModuleStoreEnum.Type.split}" ) # pylint: disable=protected-access - self.assertEqual( + self.assertEqual( # noqa: PT009 ModuleStoreEnum.Type.split, modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type() ) @@ -83,7 +83,7 @@ def test_duplicate_course(self): stderr=out ) expected = "Course already exists" - self.assertIn(out.getvalue().strip(), expected) + self.assertIn(out.getvalue().strip(), expected) # noqa: PT009 def test_get_course_with_different_case(self): """ @@ -107,10 +107,10 @@ def test_get_course_with_different_case(self): self.user.id ) course = self.store.get_course(lowercase_course_id) - self.assertIsNotNone(course, 'Course not found using lowercase course key.') - self.assertEqual(str(course.id), str(lowercase_course_id)) + self.assertIsNotNone(course, 'Course not found using lowercase course key.') # noqa: PT009 + self.assertEqual(str(course.id), str(lowercase_course_id)) # noqa: PT009 # Verify store does not return course with different case. uppercase_course_id = self.store.make_course_key(org.upper(), number.upper(), run.upper()) course = self.store.get_course(uppercase_course_id) - self.assertIsNone(course, 'Course should not be accessed with uppercase course id.') + self.assertIsNone(course, 'Course should not be accessed with uppercase course id.') # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_delete_course.py b/cms/djangoapps/contentstore/management/commands/tests/test_delete_course.py index ccdcde990135..504c5ac06291 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_delete_course.py @@ -6,15 +6,15 @@ from unittest import mock from django.core.management import CommandError, call_command + +from common.djangoapps.student.roles import CourseInstructorRole +from common.djangoapps.student.tests.factories import UserFactory from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from common.djangoapps.student.roles import CourseInstructorRole -from common.djangoapps.student.tests.factories import UserFactory - class DeleteCourseTests(ModuleStoreTestCase): """ @@ -25,18 +25,18 @@ class DeleteCourseTests(ModuleStoreTestCase): def test_invalid_course_key(self): course_run_key = 'foo/TestX/TS01/2015_Q7' expected_error_message = 'Invalid course_key: ' + course_run_key - with self.assertRaisesRegex(CommandError, expected_error_message): + with self.assertRaisesRegex(CommandError, expected_error_message): # noqa: PT027 call_command('delete_course', course_run_key) def test_course_not_found(self): course_run_key = 'TestX/TS01/2015_Q7' expected_error_message = 'Course not found: ' + course_run_key - with self.assertRaisesRegex(CommandError, expected_error_message): + with self.assertRaisesRegex(CommandError, expected_error_message): # noqa: PT027 call_command('delete_course', course_run_key) def test_asset_and_course_deletion(self): course_run = CourseFactory() - self.assertIsNotNone(modulestore().get_course(course_run.id)) + self.assertIsNotNone(modulestore().get_course(course_run.id)) # noqa: PT009 store = contentstore() asset_key = course_run.id.make_asset_key('asset', 'test.txt') diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_delete_orphans.py b/cms/djangoapps/contentstore/management/commands/tests/test_delete_orphans.py index 75650d41dcb2..0108bf4fd559 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_delete_orphans.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_delete_orphans.py @@ -4,8 +4,8 @@ from django.core.management import CommandError, call_command from cms.djangoapps.contentstore.tests.test_orphan import TestOrphanBase -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order class TestDeleteOrphan(TestOrphanBase): @@ -18,7 +18,7 @@ def test_no_args(self): Test delete_orphans command with no arguments """ errstring = 'Error: the following arguments are required: course_id' - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('delete_orphans') def test_delete_orphans_no_commit(self): @@ -28,10 +28,10 @@ def test_delete_orphans_no_commit(self): """ course = self.create_course_with_orphans(ModuleStoreEnum.Type.split) call_command('delete_orphans', str(course.id)) - self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html'))) - self.assertTrue(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert'))) - self.assertTrue(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter'))) - self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml'))) + self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html'))) # noqa: PT009 + self.assertTrue(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert'))) # noqa: PT009 + self.assertTrue(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter'))) # noqa: PT009 + self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml'))) # noqa: PT009 def test_delete_orphans_commit(self): """ @@ -43,12 +43,12 @@ def test_delete_orphans_commit(self): call_command('delete_orphans', str(course.id), '--commit') # make sure this block wasn't deleted - self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html'))) + self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html'))) # noqa: PT009 # and make sure that these were - self.assertFalse(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert'))) - self.assertFalse(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter'))) - self.assertFalse(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml'))) + self.assertFalse(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert'))) # noqa: PT009 + self.assertFalse(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter'))) # noqa: PT009 + self.assertFalse(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml'))) # noqa: PT009 def test_delete_orphans_published_branch_split(self): """ @@ -69,14 +69,14 @@ def test_delete_orphans_published_branch_split(self): # now all orphans should be deleted self.assertOrphanCount(course.id, 0) self.assertOrphanCount(published_branch, 0) - self.assertNotIn(orphan, self.store.get_items(published_branch)) + self.assertNotIn(orphan, self.store.get_items(published_branch)) # noqa: PT009 # we should have one fewer item in the published branch of the course - self.assertEqual( + self.assertEqual( # noqa: PT009 len(items_in_published) - 1, len(self.store.get_items(published_branch)), ) # and the same amount of items in the draft branch of the course - self.assertEqual( + self.assertEqual( # noqa: PT009 len(items_in_draft_preferred), len(self.store.get_items(course.id)), ) @@ -111,6 +111,6 @@ def create_split_course_with_published_orphan(self): # there should be one in published self.assertOrphanCount(course.id, 0) self.assertOrphanCount(published_branch, 1) - self.assertIn(orphan.location, [x.location for x in self.store.get_items(published_branch)]) + self.assertIn(orphan.location, [x.location for x in self.store.get_items(published_branch)]) # noqa: PT009 return course, orphan diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_export.py b/cms/djangoapps/contentstore/management/commands/tests/test_export.py index b467fd558f09..d4c7a8fbcc1c 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_export.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_export.py @@ -24,7 +24,7 @@ def test_no_args(self): Test export command with no arguments """ errstring = "Error: the following arguments are required: course_id, output_path" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('export') @@ -49,13 +49,13 @@ def test_export_course_with_directory_name(self): """ course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) course_id = str(course.id) - self.assertTrue( + self.assertTrue( # noqa: PT009 modulestore().has_course(course.id), f"Could not find course in {ModuleStoreEnum.Type.split}" ) # Test `export` management command with invalid course_id errstring = "Invalid course_key: 'InvalidCourseID'." - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('export', "InvalidCourseID", self.temp_dir_1) # Test `export` management command with correct course_id @@ -67,5 +67,5 @@ def test_course_key_not_found(self): Test export command with a valid course key that doesn't exist """ errstring = "Course with x/y/z key not found." - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('export', "x/y/z", self.temp_dir_1) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py index 5c01abde69ab..9cf658d0ba83 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py @@ -8,10 +8,12 @@ from unittest import skip from cms.djangoapps.contentstore.management.commands.export_all_courses import export_courses_to_output_path -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order @skip("OldMongo Deprecation") @@ -25,7 +27,7 @@ class ExportAllCourses(ModuleStoreTestCase): def setUp(self): """ Common setup. """ super().setUp() - self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # lint-amnesty, pylint: disable=protected-access + self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # pylint: disable=protected-access self.temp_dir = mkdtemp() self.addCleanup(shutil.rmtree, self.temp_dir) self.first_course = CourseFactory.create( @@ -41,8 +43,8 @@ def test_export_all_courses(self): """ # check that both courses exported successfully courses, failed_export_courses = export_courses_to_output_path(self.temp_dir) - self.assertEqual(len(courses), 2) - self.assertEqual(len(failed_export_courses), 0) + self.assertEqual(len(courses), 2) # noqa: PT009 + self.assertEqual(len(failed_export_courses), 0) # noqa: PT009 # manually make second course faulty and check that it fails on export second_course_id = self.second_course.id @@ -51,6 +53,6 @@ def test_export_all_courses(self): {'$set': {'metadata.tags': 'crash'}} ) courses, failed_export_courses = export_courses_to_output_path(self.temp_dir) - self.assertEqual(len(courses), 2) - self.assertEqual(len(failed_export_courses), 1) - self.assertEqual(failed_export_courses[0], str(second_course_id)) + self.assertEqual(len(courses), 2) # noqa: PT009 + self.assertEqual(len(failed_export_courses), 1) # noqa: PT009 + self.assertEqual(failed_export_courses[0], str(second_course_id)) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_export_olx.py b/cms/djangoapps/contentstore/management/commands/tests/test_export_olx.py index 8f5505ede5c1..e8c001a2469f 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_export_olx.py @@ -12,13 +12,12 @@ from django.core.management import CommandError, call_command from path import Path as path +from openedx.core.djangoapps.content_tagging.tests.test_objecttag_export_helpers import TaggedCourseMixin from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from openedx.core.djangoapps.content_tagging.tests.test_objecttag_export_helpers import TaggedCourseMixin - class TestArgParsingCourseExportOlx(unittest.TestCase): """ @@ -29,7 +28,7 @@ def test_no_args(self): Test export command with no arguments """ errstring = "Error: the following arguments are required: course_id" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('export_olx') @@ -43,7 +42,7 @@ def test_invalid_course_key(self): Test export command with an invalid course key. """ errstring = "Unparsable course_id" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('export_olx', 'InvalidCourseID') def test_course_key_not_found(self): @@ -51,13 +50,13 @@ def test_course_key_not_found(self): Test export command with a valid course key that doesn't exist. """ errstring = "Invalid course_id" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('export_olx', 'x/y/z') def create_dummy_course(self, store_type): """Create small course.""" course = CourseFactory.create(default_store=store_type) - self.assertTrue( + self.assertTrue( # noqa: PT009 modulestore().has_course(course.id), f"Could not find course in {store_type}" ) @@ -66,17 +65,17 @@ def create_dummy_course(self, store_type): def check_export_file(self, tar_file, course_key, with_tags=False): """Check content of export file.""" names = tar_file.getnames() - dirname = "{0.org}-{0.course}-{0.run}".format(course_key) - self.assertIn(dirname, names) + dirname = "{0.org}-{0.course}-{0.run}".format(course_key) # noqa: UP032 + self.assertIn(dirname, names) # noqa: PT009 # Check if some of the files are present, without being exhaustive. - self.assertIn(f"{dirname}/about", names) - self.assertIn(f"{dirname}/about/overview.html", names) - self.assertIn(f"{dirname}/assets/assets.xml", names) - self.assertIn(f"{dirname}/policies", names) + self.assertIn(f"{dirname}/about", names) # noqa: PT009 + self.assertIn(f"{dirname}/about/overview.html", names) # noqa: PT009 + self.assertIn(f"{dirname}/assets/assets.xml", names) # noqa: PT009 + self.assertIn(f"{dirname}/policies", names) # noqa: PT009 if with_tags: - self.assertIn(f"{dirname}/tags.csv", names) + self.assertIn(f"{dirname}/tags.csv", names) # noqa: PT009 else: - self.assertNotIn(f"{dirname}/tags.csv", names) + self.assertNotIn(f"{dirname}/tags.csv", names) # noqa: PT009 def test_export_course(self): test_course_key = self.create_dummy_course(ModuleStoreEnum.Type.split) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py b/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py index b9372d15204b..e17ea55ee98f 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py @@ -7,7 +7,7 @@ from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory class TestFixNotFound(ModuleStoreTestCase): @@ -20,7 +20,7 @@ def test_no_args(self): """ msg = "Error: the following arguments are required: course_id" - with self.assertRaisesRegex(CommandError, msg): + with self.assertRaisesRegex(CommandError, msg): # noqa: PT027 call_command('fix_not_found') def test_fix_not_found(self): @@ -38,13 +38,13 @@ def test_fix_not_found(self): # the course block should now point to two children, one of which # doesn't actually exist - self.assertEqual(len(course.children), 2) - self.assertIn(dangling_pointer, course.children) + self.assertEqual(len(course.children), 2) # noqa: PT009 + self.assertIn(dangling_pointer, course.children) # noqa: PT009 call_command("fix_not_found", str(course.id)) # make sure the dangling pointer was removed from # the course block's children course = self.store.get_course(course.id) - self.assertEqual(len(course.children), 1) - self.assertNotIn(dangling_pointer, course.children) + self.assertEqual(len(course.children), 1) # noqa: PT009 + self.assertNotIn(dangling_pointer, course.children) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py b/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py index ea16707d7eb8..cd5a4b675dde 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py @@ -9,9 +9,15 @@ from cms.djangoapps.contentstore.management.commands.force_publish import Command from cms.djangoapps.contentstore.management.commands.utils import get_course_versions -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( # pylint: disable=wrong-import-order + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) class TestForcePublish(SharedModuleStoreTestCase): @@ -31,7 +37,7 @@ def test_no_args(self): """ errstring = "Error: the following arguments are required: course_key" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('force_publish') def test_invalid_course_key(self): @@ -39,7 +45,7 @@ def test_invalid_course_key(self): Test 'force_publish' command with invalid course key """ errstring = "Invalid course key." - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('force_publish', 'TestX/TS01') def test_too_many_arguments(self): @@ -47,7 +53,7 @@ def test_too_many_arguments(self): Test 'force_publish' command with more than 2 arguments """ errstring = "Error: unrecognized arguments: invalid-arg" - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('force_publish', str(self.course.id), '--commit', 'invalid-arg') def test_course_key_not_found(self): @@ -55,7 +61,7 @@ def test_course_key_not_found(self): Test 'force_publish' command with non-existing course key """ errstring = "Course not found." - with self.assertRaisesRegex(CommandError, errstring): + with self.assertRaisesRegex(CommandError, errstring): # noqa: PT027 call_command('force_publish', 'course-v1:org+course+run') @@ -85,7 +91,7 @@ def test_force_publish(self): ) # verify that course has changes. - self.assertTrue(self.store.has_changes(self.store.get_item(self.course.location))) + self.assertTrue(self.store.has_changes(self.store.get_item(self.course.location))) # noqa: PT009 # get draft and publish branch versions versions = get_course_versions(str(self.course.id)) @@ -93,7 +99,7 @@ def test_force_publish(self): published_version = versions['published-branch'] # verify that draft and publish point to different versions - self.assertNotEqual(draft_version, published_version) + self.assertNotEqual(draft_version, published_version) # noqa: PT009 with mock.patch('cms.djangoapps.contentstore.management.commands.force_publish.query_yes_no') as patched_yes_no: patched_yes_no.return_value = True @@ -102,7 +108,7 @@ def test_force_publish(self): call_command('force_publish', str(self.course.id), '--commit') # verify that course has no changes - self.assertFalse(self.store.has_changes(self.store.get_item(self.course.location))) + self.assertFalse(self.store.has_changes(self.store.get_item(self.course.location))) # noqa: PT009 # get new draft and publish branch versions versions = get_course_versions(str(self.course.id)) @@ -110,8 +116,8 @@ def test_force_publish(self): new_published_version = versions['published-branch'] # verify that the draft branch didn't change while the published branch did - self.assertEqual(draft_version, new_draft_version) - self.assertNotEqual(published_version, new_published_version) + self.assertEqual(draft_version, new_draft_version) # noqa: PT009 + self.assertNotEqual(published_version, new_published_version) # noqa: PT009 # verify that draft and publish point to same versions now - self.assertEqual(new_draft_version, new_published_version) + self.assertEqual(new_draft_version, new_published_version) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_generate_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_generate_courses.py index aa3abf777682..e5398a5e6313 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_generate_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_generate_courses.py @@ -34,7 +34,7 @@ def test_generate_course_in_stores(self, mock_logger): arg = json.dumps(settings) call_command("generate_courses", arg) key = modulestore().make_course_key("test-course-generator", "1", "1") - self.assertTrue(modulestore().has_course(key)) + self.assertTrue(modulestore().has_course(key)) # noqa: PT009 mock_logger.info.assert_any_call("Created course-v1:test-course-generator+1+1") mock_logger.info.assert_any_call("announcement has been set to 2010-04-20T20:08:21.634121") mock_logger.info.assert_any_call("display_name has been set to test-course") @@ -43,7 +43,7 @@ def test_invalid_json(self): """ Test that providing an invalid JSON object will result in the appropriate command error """ - with self.assertRaisesRegex(CommandError, "Invalid JSON object"): + with self.assertRaisesRegex(CommandError, "Invalid JSON object"): # noqa: PT027 arg = "invalid_json" call_command("generate_courses", arg) @@ -51,7 +51,7 @@ def test_missing_courses_list(self): """ Test that a missing list of courses in json will result in the appropriate command error """ - with self.assertRaisesRegex(CommandError, "JSON object is missing courses list"): + with self.assertRaisesRegex(CommandError, "JSON object is missing courses list"): # noqa: PT027 settings = {} arg = json.dumps(settings) call_command("generate_courses", arg) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py index 8a2334b34375..bb8aa136f72e 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py @@ -9,13 +9,14 @@ import subprocess import unittest from io import StringIO +from unittest.mock import patch from uuid import uuid4 from django.conf import settings from django.core.management import call_command from django.core.management.base import CommandError from django.test.utils import override_settings -from opaque_keys.edx.locator import CourseLocator +from opaque_keys.edx.locator import CourseLocator, LibraryLocator, LibraryLocatorV2 import cms.djangoapps.contentstore.git_export_utils as git_export_utils from cms.djangoapps.contentstore.git_export_utils import GitExportError @@ -24,7 +25,7 @@ FEATURES_WITH_EXPORT_GIT = settings.FEATURES.copy() FEATURES_WITH_EXPORT_GIT['ENABLE_EXPORT_GIT'] = True TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex # noqa: UP031 @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) @@ -34,6 +35,9 @@ class TestGitExport(CourseTestCase): Excercise the git_export django management command with various inputs. """ + LIBRARY_V2_KEY = LibraryLocatorV2(org='TestOrg', slug='test-lib') + LIBRARY_V1_KEY = LibraryLocator(org='TestOrg', library='test-lib') + def setUp(self): """ Create/reinitialize bare repo and folders needed @@ -44,7 +48,7 @@ def setUp(self): os.mkdir(git_export_utils.GIT_REPO_EXPORT_DIR) self.addCleanup(shutil.rmtree, git_export_utils.GIT_REPO_EXPORT_DIR) - self.bare_repo_dir = '{}/data/test_bare.git'.format( + self.bare_repo_dir = '{}/data/test_bare.git'.format( # noqa: UP032 os.path.abspath(settings.TEST_ROOT)) if not os.path.isdir(self.bare_repo_dir): os.mkdir(self.bare_repo_dir) @@ -57,7 +61,7 @@ def test_command(self): Test that the command interface works. Ignore stderr for clean test output. """ - with self.assertRaisesRegex(CommandError, 'Error: unrecognized arguments:*'): + with self.assertRaisesRegex(CommandError, 'Error: unrecognized arguments:*'): # noqa: PT027 call_command('git_export', 'blah', 'blah', 'blah', stderr=StringIO()) with self.assertRaisesMessage( @@ -67,23 +71,23 @@ def test_command(self): call_command('git_export', stderr=StringIO()) # Send bad url to get course not exported - with self.assertRaisesRegex(CommandError, str(GitExportError.URL_BAD)): + with self.assertRaisesRegex(CommandError, str(GitExportError.URL_BAD)): # noqa: PT027 call_command('git_export', 'foo/bar/baz', 'silly', stderr=StringIO()) # Send bad course_id to get course not exported - with self.assertRaisesRegex(CommandError, str(GitExportError.BAD_COURSE)): + with self.assertRaisesRegex(CommandError, str(GitExportError.BAD_COURSE)): # noqa: PT027 call_command('git_export', 'foo/bar:baz', 'silly', stderr=StringIO()) def test_error_output(self): """ Verify that error output is actually resolved as the correct string """ - with self.assertRaisesRegex(CommandError, str(GitExportError.BAD_COURSE)): + with self.assertRaisesRegex(CommandError, str(GitExportError.BAD_COURSE)): # noqa: PT027 call_command( 'git_export', 'foo/bar:baz', 'silly' ) - with self.assertRaisesRegex(CommandError, str(GitExportError.URL_BAD)): + with self.assertRaisesRegex(CommandError, str(GitExportError.URL_BAD)): # noqa: PT027 call_command( 'git_export', 'foo/bar/baz', 'silly' ) @@ -93,13 +97,13 @@ def test_bad_git_url(self): Test several bad URLs for validation """ course_key = CourseLocator('org', 'course', 'run') - with self.assertRaisesRegex(GitExportError, str(GitExportError.URL_BAD)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.URL_BAD)): # noqa: PT027 git_export_utils.export_to_git(course_key, 'Sillyness') - with self.assertRaisesRegex(GitExportError, str(GitExportError.URL_BAD)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.URL_BAD)): # noqa: PT027 git_export_utils.export_to_git(course_key, 'example.com:edx/notreal') - with self.assertRaisesRegex(GitExportError, str(GitExportError.URL_NO_AUTH)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.URL_NO_AUTH)): # noqa: PT027 git_export_utils.export_to_git(course_key, 'http://blah') def test_bad_git_repos(self): @@ -107,23 +111,23 @@ def test_bad_git_repos(self): Test invalid git repos """ test_repo_path = f'{git_export_utils.GIT_REPO_EXPORT_DIR}/test_repo' - self.assertFalse(os.path.isdir(test_repo_path)) + self.assertFalse(os.path.isdir(test_repo_path)) # noqa: PT009 course_key = CourseLocator('foo', 'blah', '100-') # Test bad clones - with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_PULL)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_PULL)): # noqa: PT027 git_export_utils.export_to_git( course_key, 'https://user:blah@example.com/test_repo.git') - self.assertFalse(os.path.isdir(test_repo_path)) + self.assertFalse(os.path.isdir(test_repo_path)) # noqa: PT009 # Setup good repo with bad course to test xml export - with self.assertRaisesRegex(GitExportError, str(GitExportError.XML_EXPORT_FAIL)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.XML_EXPORT_FAIL)): # noqa: PT027 git_export_utils.export_to_git( course_key, f'file://{self.bare_repo_dir}') # Test bad git remote after successful clone - with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_PULL)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_PULL)): # noqa: PT027 git_export_utils.export_to_git( course_key, 'https://user:blah@example.com/r.git') @@ -153,7 +157,7 @@ def test_git_ident(self): cwd = os.path.abspath(git_export_utils.GIT_REPO_EXPORT_DIR / 'test_bare') git_log = subprocess.check_output(['git', 'log', '-1', '--format=%an|%ae'], cwd=cwd).decode('utf-8') - self.assertEqual(expect_string, git_log) + self.assertEqual(expect_string, git_log) # noqa: PT009 # Make changes to course so there is something to commit self.populate_course() @@ -162,13 +166,13 @@ def test_git_ident(self): f'file://{self.bare_repo_dir}', self.user.username ) - expect_string = '{}|{}\n'.format( + expect_string = '{}|{}\n'.format( # noqa: UP032 self.user.username, self.user.email, ) git_log = subprocess.check_output( ['git', 'log', '-1', '--format=%an|%ae'], cwd=cwd).decode('utf-8') - self.assertEqual(expect_string, git_log) + self.assertEqual(expect_string, git_log) # noqa: PT009 def test_no_change(self): """ @@ -179,6 +183,55 @@ def test_no_change(self): f'file://{self.bare_repo_dir}' ) - with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_COMMIT)): + with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_COMMIT)): # noqa: PT027 git_export_utils.export_to_git( self.course.id, f'file://{self.bare_repo_dir}') + + @patch('cms.djangoapps.contentstore.git_export_utils.cmd_log', return_value=b'main') + @patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir') + def test_library_v2_export_selects_correct_function(self, mock_extract, mock_cmd_log): + """ + When ``export_to_git`` is given a LibraryLocatorV2 key it must call + ``extract_library_v2_zip_to_dir`` and must not call the v1 XML export + functions (``export_library_to_xml`` or ``export_course_to_xml``). + cmd_log is mocked so no real git subprocess or repo state is needed. + """ + mock_extract.return_value = None + repo_url = f'file://{self.bare_repo_dir}' + + with patch('cms.djangoapps.contentstore.git_export_utils.export_course_to_xml') as mock_course_xml, \ + patch('cms.djangoapps.contentstore.git_export_utils.export_library_to_xml') as mock_lib_xml: + git_export_utils.export_to_git(self.LIBRARY_V2_KEY, repo_url, self.user.username) + + assert mock_extract.call_args[0][0] == self.LIBRARY_V2_KEY + mock_course_xml.assert_not_called() + mock_lib_xml.assert_not_called() + + @patch('cms.djangoapps.contentstore.git_export_utils.cmd_log', return_value=b'main') + @patch('cms.djangoapps.contentstore.git_export_utils.export_library_to_xml') + def test_library_v1_export_selects_correct_function(self, mock_lib_xml, mock_cmd_log): + """ + When ``export_to_git`` is given a LibraryLocator (v1) key it must call + ``export_library_to_xml`` and must not call ``extract_library_v2_zip_to_dir``. + cmd_log is mocked so no real git subprocess or repo state is needed. + """ + mock_lib_xml.return_value = None + repo_url = f'file://{self.bare_repo_dir}' + + with patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir') as mock_v2, \ + patch('cms.djangoapps.contentstore.git_export_utils.export_course_to_xml') as mock_course_xml: + git_export_utils.export_to_git(self.LIBRARY_V1_KEY, repo_url, self.user.username) + + assert mock_lib_xml.called + mock_v2.assert_not_called() + mock_course_xml.assert_not_called() + + @patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir', + side_effect=OSError('disk full')) + def test_library_v2_export_failure_raises_xml_export_fail(self, mock_extract): + """ + If ``extract_library_v2_zip_to_dir`` raises, ``export_to_git`` should + wrap it in ``GitExportError.XML_EXPORT_FAIL``. + """ + with self.assertRaisesRegex(GitExportError, str(GitExportError.XML_EXPORT_FAIL)): # noqa: PT027 + git_export_utils.export_to_git(self.LIBRARY_V2_KEY, f'file://{self.bare_repo_dir}') diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_import.py b/cms/djangoapps/contentstore/management/commands/tests/test_import.py index 5b90973b0463..b8a77e628220 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_import.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_import.py @@ -11,8 +11,10 @@ from path import Path as path from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) class TestImport(ModuleStoreTestCase): @@ -20,11 +22,11 @@ class TestImport(ModuleStoreTestCase): Unit tests for importing a course from command line """ - def create_course_xml(self, content_dir, course_id): # lint-amnesty, pylint: disable=missing-function-docstring + def create_course_xml(self, content_dir, course_id): # pylint: disable=missing-function-docstring directory = tempfile.mkdtemp(dir=content_dir) os.makedirs(os.path.join(directory, "course")) with open(os.path.join(directory, "course.xml"), "w+") as f: - f.write(''.format(course_id)) + f.write(''.format(course_id)) # noqa: UP032 with open(os.path.join(directory, "course", f"{course_id.run}.xml"), "w+") as f: f.write('') @@ -53,9 +55,9 @@ def test_forum_seed(self): """ Tests that forum roles were created with import. """ - self.assertFalse(are_permissions_roles_seeded(self.base_course_key)) + self.assertFalse(are_permissions_roles_seeded(self.base_course_key)) # noqa: PT009 call_command('import', self.content_dir, self.good_dir) - self.assertTrue(are_permissions_roles_seeded(self.base_course_key)) + self.assertTrue(are_permissions_roles_seeded(self.base_course_key)) # noqa: PT009 def test_truncated_course_with_url(self): """ @@ -67,8 +69,8 @@ def test_truncated_course_with_url(self): # Load up base course and verify it is available call_command('import', self.content_dir, self.good_dir) store = modulestore() - self.assertIsNotNone(store.get_course(self.base_course_key)) + self.assertIsNotNone(store.get_course(self.base_course_key)) # noqa: PT009 # Now load up the course with a similar course_id and verify it loads call_command('import', self.content_dir, self.course_dir) - self.assertIsNotNone(store.get_course(self.truncated_key)) + self.assertIsNotNone(store.get_course(self.truncated_key)) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py index 6d14b4a339f2..6c733d0501a9 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py @@ -1,16 +1,22 @@ """ Tests for course reindex command """ +from datetime import datetime, timedelta from unittest import mock import ddt from django.core.management import CommandError, call_command + from cms.djangoapps.contentstore.management.commands.reindex_course import Command as ReindexCommand -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory # lint-amnesty, pylint: disable=wrong-import-order -from datetime import datetime, timedelta +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + CourseFactory, + LibraryFactory, +) @ddt.ddt @@ -60,25 +66,25 @@ def _build_calls(self, *courses): def test_given_no_arguments_raises_command_error(self): """ Test that raises CommandError for incorrect arguments """ - with self.assertRaisesRegex(CommandError, ".* requires one or more *"): + with self.assertRaisesRegex(CommandError, ".* requires one or more *"): # noqa: PT027 call_command('reindex_course') @ddt.data('qwerty', 'invalid_key', 'xblockv1:qwerty') def test_given_invalid_course_key_raises_not_found(self, invalid_key): """ Test that raises InvalidKeyError for invalid keys """ err_string = f"Invalid course_key: '{invalid_key}'" - with self.assertRaisesRegex(CommandError, err_string): + with self.assertRaisesRegex(CommandError, err_string): # noqa: PT027 call_command('reindex_course', invalid_key) def test_given_library_key_raises_command_error(self): """ Test that raises CommandError if library key is passed """ - with self.assertRaisesRegex(CommandError, ".* is not a course key"): + with self.assertRaisesRegex(CommandError, ".* is not a course key"): # noqa: PT027 call_command('reindex_course', str(self._get_lib_key(self.first_lib))) - with self.assertRaisesRegex(CommandError, ".* is not a course key"): + with self.assertRaisesRegex(CommandError, ".* is not a course key"): # noqa: PT027 call_command('reindex_course', str(self._get_lib_key(self.second_lib))) - with self.assertRaisesRegex(CommandError, ".* is not a course key"): + with self.assertRaisesRegex(CommandError, ".* is not a course key"): # noqa: PT027 call_command( 'reindex_course', str(self.second_course.id), @@ -90,11 +96,11 @@ def test_given_id_list_indexes_courses(self): with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \ mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)): call_command('reindex_course', str(self.first_course.id)) - self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_course)) + self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_course)) # noqa: PT009 patched_index.reset_mock() call_command('reindex_course', str(self.second_course.id)) - self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_course)) + self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_course)) # noqa: PT009 patched_index.reset_mock() call_command( @@ -103,7 +109,7 @@ def test_given_id_list_indexes_courses(self): str(self.second_course.id) ) expected_calls = self._build_calls(self.first_course, self.second_course) - self.assertEqual(patched_index.mock_calls, expected_calls) + self.assertEqual(patched_index.mock_calls, expected_calls) # noqa: PT009 def test_given_all_key_prompts_and_reindexes_all_courses(self): """ Test that reindexes all courses when --all key is given and confirmed """ @@ -117,7 +123,7 @@ def test_given_all_key_prompts_and_reindexes_all_courses(self): expected_calls = self._build_calls( self.first_course, self.second_course, self.third_course, self.fourth_course ) - self.assertCountEqual(patched_index.mock_calls, expected_calls) + self.assertCountEqual(patched_index.mock_calls, expected_calls) # noqa: PT009 def test_given_all_key_prompts_and_reindexes_all_courses_cancelled(self): """ Test that does not reindex anything when --all key is given and cancelled """ @@ -140,7 +146,7 @@ def test_given_active_key_prompt(self): call_command('reindex_course', active=True) expected_calls = self._build_calls(self.first_course, self.fourth_course) - self.assertCountEqual(patched_index.mock_calls, expected_calls) + self.assertCountEqual(patched_index.mock_calls, expected_calls) # noqa: PT009 @mock.patch.dict( 'django.conf.settings.FEATURES', @@ -156,4 +162,4 @@ def test_given_from_inclusion_date_key_prompt(self): call_command('reindex_course', from_inclusion_date=True) expected_calls = self._build_calls(self.first_course, self.second_course) - self.assertCountEqual(patched_index.mock_calls, expected_calls) + self.assertCountEqual(patched_index.mock_calls, expected_calls) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py index d5013cf69462..3147060cb651 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py @@ -9,10 +9,15 @@ from cms.djangoapps.contentstore.courseware_index import SearchIndexingError from cms.djangoapps.contentstore.management.commands.reindex_library import Command as ReindexCommand -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + CourseFactory, + LibraryFactory, +) @ddt.ddt @@ -52,24 +57,24 @@ def _build_calls(self, *libraries): def test_given_no_arguments_raises_command_error(self): """ Test that raises CommandError for incorrect arguments """ - with self.assertRaisesRegex(CommandError, ".* requires one or more *"): + with self.assertRaisesRegex(CommandError, ".* requires one or more *"): # noqa: PT027 call_command('reindex_library') @ddt.data('qwerty', 'invalid_key', 'xblock-v1:qwe+rty') def test_given_invalid_lib_key_raises_not_found(self, invalid_key): """ Test that raises InvalidKeyError for invalid keys """ - with self.assertRaises(InvalidKeyError): + with self.assertRaises(InvalidKeyError): # noqa: PT027 call_command('reindex_library', invalid_key) def test_given_course_key_raises_command_error(self): """ Test that raises CommandError if course key is passed """ - with self.assertRaisesRegex(CommandError, ".* is not a library key"): + with self.assertRaisesRegex(CommandError, ".* is not a library key"): # noqa: PT027 call_command('reindex_library', str(self.first_course.id)) - with self.assertRaisesRegex(CommandError, ".* is not a library key"): + with self.assertRaisesRegex(CommandError, ".* is not a library key"): # noqa: PT027 call_command('reindex_library', str(self.second_course.id)) - with self.assertRaisesRegex(CommandError, ".* is not a library key"): + with self.assertRaisesRegex(CommandError, ".* is not a library key"): # noqa: PT027 call_command( 'reindex_library', str(self.second_course.id), @@ -81,11 +86,11 @@ def test_given_id_list_indexes_libraries(self): with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \ mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)): call_command('reindex_library', str(self._get_lib_key(self.first_lib))) - self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_lib)) + self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_lib)) # noqa: PT009 patched_index.reset_mock() call_command('reindex_library', str(self._get_lib_key(self.second_lib))) - self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_lib)) + self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_lib)) # noqa: PT009 patched_index.reset_mock() call_command( @@ -94,7 +99,7 @@ def test_given_id_list_indexes_libraries(self): str(self._get_lib_key(self.second_lib)) ) expected_calls = self._build_calls(self.first_lib, self.second_lib) - self.assertEqual(patched_index.mock_calls, expected_calls) + self.assertEqual(patched_index.mock_calls, expected_calls) # noqa: PT009 def test_given_all_key_prompts_and_reindexes_all_libraries(self): """ Test that reindexes all libraries when --all key is given and confirmed """ @@ -106,7 +111,7 @@ def test_given_all_key_prompts_and_reindexes_all_libraries(self): patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no') expected_calls = self._build_calls(self.first_lib, self.second_lib) - self.assertCountEqual(patched_index.mock_calls, expected_calls) + self.assertCountEqual(patched_index.mock_calls, expected_calls) # noqa: PT009 def test_given_all_key_prompts_and_reindexes_all_libraries_cancelled(self): """ Test that does not reindex anything when --all key is given and cancelled """ @@ -124,5 +129,5 @@ def test_fail_fast_if_reindex_fails(self): with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index: patched_index.side_effect = SearchIndexingError("message", []) - with self.assertRaises(SearchIndexingError): + with self.assertRaises(SearchIndexingError): # noqa: PT027 call_command('reindex_library', str(self._get_lib_key(self.second_lib))) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py b/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py index 73a00f36b3c9..843a2795507a 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py @@ -21,15 +21,15 @@ class TestCommand(TestCase): """ def test_bad_course_id(self): - with self.assertRaises(InvalidKeyError): + with self.assertRaises(InvalidKeyError): # noqa: PT027 call_command("reset_course_content", "not_a_course_id", "0123456789abcdef01234567") def test_wrong_length_version_guid(self): - with self.assertRaises(CommandError): + with self.assertRaises(CommandError): # noqa: PT027 call_command("reset_course_content", "course-v1:a+b+c", "0123456789abcdef") def test_non_hex_version_guid(self): - with self.assertRaises(CommandError): + with self.assertRaises(CommandError): # noqa: PT027 call_command("reset_course_content", "course-v1:a+b+c", "0123456789abcdefghijklmn") @mock.patch.object(MixedModuleStore, "reset_course_to_version") diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_sync_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_sync_courses.py index 6894bc49556f..915c57fefb40 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_sync_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_sync_courses.py @@ -11,9 +11,11 @@ from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) COMMAND_MODULE = 'cms.djangoapps.contentstore.management.commands.sync_courses' @@ -33,9 +35,9 @@ def setUp(self): def _validate_courses(self): for run in self.catalog_course_runs: - course_key = CourseKey.from_string(run.get('key')) # lint-amnesty, pylint: disable=no-member - self.assertTrue(modulestore().has_course(course_key)) - CourseOverview.objects.get(id=run.get('key')) # lint-amnesty, pylint: disable=no-member + course_key = CourseKey.from_string(run.get('key')) # pylint: disable=no-member + self.assertTrue(modulestore().has_course(course_key)) # noqa: PT009 + CourseOverview.objects.get(id=run.get('key')) # pylint: disable=no-member def test_courses_sync(self, mock_catalog_course_runs): mock_catalog_course_runs.return_value = self.catalog_course_runs @@ -63,7 +65,7 @@ def test_duplicate_courses_skipped(self, mock_catalog_course_runs): with LogCapture() as capture: call_command('sync_courses', self.user.email) - expected_message = "Course already exists for {}, {}, {}. Skipping".format( + expected_message = "Course already exists for {}, {}, {}. Skipping".format( # noqa: UP032 course_key.org, course_key.course, course_key.run, @@ -75,4 +77,4 @@ def test_duplicate_courses_skipped(self, mock_catalog_course_runs): self._validate_courses() course = modulestore().get_course(course_key) - self.assertEqual(course.display_name, initial_display_name) + self.assertEqual(course.display_name, initial_display_name) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/management/commands/utils.py b/cms/djangoapps/contentstore/management/commands/utils.py index be697e030670..01fc3ad6bf7e 100644 --- a/cms/djangoapps/contentstore/management/commands/utils.py +++ b/cms/djangoapps/contentstore/management/commands/utils.py @@ -3,7 +3,7 @@ """ -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py index d7e93d44de0a..24e0abd05e68 100644 --- a/cms/djangoapps/contentstore/management/commands/xlint.py +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -27,7 +27,7 @@ def handle(self, *args, **options): data_dir = options['data_dir'] source_dirs = options['source_dirs'] - print("Importing. Data_dir={data}, source_dirs={courses}".format( + print("Importing. Data_dir={data}, source_dirs={courses}".format( # noqa: UP032 data=data_dir, courses=source_dirs)) diff --git a/cms/djangoapps/contentstore/migrations/0001_squashed_0015_switch_to_openedx_content.py b/cms/djangoapps/contentstore/migrations/0001_squashed_0015_switch_to_openedx_content.py index f65c9a8cd3b0..d33c265979c5 100644 --- a/cms/djangoapps/contentstore/migrations/0001_squashed_0015_switch_to_openedx_content.py +++ b/cms/djangoapps/contentstore/migrations/0001_squashed_0015_switch_to_openedx_content.py @@ -1,11 +1,12 @@ # Generated by Django 5.2.10 on 2026-01-30 01:23 +import uuid + import django.db.migrations.operations.special import django.db.models.deletion import opaque_keys.edx.django.models -import openedx_learning.lib.fields -import openedx_learning.lib.validators -import uuid +import openedx_django_lib.fields +import openedx_django_lib.validators from django.conf import settings from django.db import migrations, models @@ -107,8 +108,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('context_key', opaque_keys.edx.django.models.CourseKeyField(help_text='Linking status for course context key', max_length=255, unique=True)), ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('failed', 'Failed'), ('completed', 'Completed')], help_text='Status of links in given learning context/course.', max_length=20)), - ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), - ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), ], options={ 'verbose_name': 'Learning Context Links status', @@ -121,13 +122,13 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('upstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(help_text='Upstream block usage key, this value cannot be null and useful to track upstream library blocks that do not exist yet', max_length=255)), - ('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), + ('upstream_context_key', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), ('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)), ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), ('version_synced', models.IntegerField()), ('version_declined', models.IntegerField(blank=True, null=True)), - ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), - ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), ('upstream_block', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='openedx_content.component')), ], options={ @@ -140,13 +141,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), - ('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), + ('upstream_context_key', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), ('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)), ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), ('version_synced', models.IntegerField()), ('version_declined', models.IntegerField(blank=True, null=True)), - ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), - ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), ('upstream_container_key', opaque_keys.edx.django.models.ContainerKeyField(help_text='Upstream block key (e.g. lct:...), this value cannot be null and is useful to track upstream library blocks that do not exist yet or were deleted.', max_length=255)), ('upstream_container', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='openedx_content.container')), ], diff --git a/cms/djangoapps/contentstore/migrations/0007_backfillcoursetabsconfig.py b/cms/djangoapps/contentstore/migrations/0007_backfillcoursetabsconfig.py index 2123798d83e8..235373267153 100644 --- a/cms/djangoapps/contentstore/migrations/0007_backfillcoursetabsconfig.py +++ b/cms/djangoapps/contentstore/migrations/0007_backfillcoursetabsconfig.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.12 on 2022-03-18 13:49 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cms/djangoapps/contentstore/migrations/0008_cleanstalecertificateavailabilitydatesconfig.py b/cms/djangoapps/contentstore/migrations/0008_cleanstalecertificateavailabilitydatesconfig.py index 50187a8cac54..034c8fbbdc13 100644 --- a/cms/djangoapps/contentstore/migrations/0008_cleanstalecertificateavailabilitydatesconfig.py +++ b/cms/djangoapps/contentstore/migrations/0008_cleanstalecertificateavailabilitydatesconfig.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.13 on 2022-07-11 17:08 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py b/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py index 84b80cd63359..9f32a904eb6b 100644 --- a/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py +++ b/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py @@ -4,8 +4,8 @@ import django.db.models.deletion import opaque_keys.edx.django.models -import openedx_learning.lib.fields -import openedx_learning.lib.validators +import openedx_django_lib.fields +import openedx_django_lib.validators from django.db import migrations, models @@ -39,8 +39,8 @@ class Migration(migrations.Migration): max_length=20, ), ), - ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), - ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), ], options={ 'verbose_name': 'Learning Context Links status', @@ -61,7 +61,7 @@ class Migration(migrations.Migration): ), ( 'upstream_context_key', - openedx_learning.lib.fields.MultiCollationCharField( + openedx_django_lib.fields.MultiCollationCharField( db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', @@ -72,8 +72,8 @@ class Migration(migrations.Migration): ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), ('version_synced', models.IntegerField()), ('version_declined', models.IntegerField(blank=True, null=True)), - ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), - ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), ( 'upstream_block', models.ForeignKey( diff --git a/cms/djangoapps/contentstore/migrations/0010_container_link_models.py b/cms/djangoapps/contentstore/migrations/0010_container_link_models.py index 8d42ad96145d..9ec26d84edf8 100644 --- a/cms/djangoapps/contentstore/migrations/0010_container_link_models.py +++ b/cms/djangoapps/contentstore/migrations/0010_container_link_models.py @@ -3,8 +3,8 @@ import django.db.models.deletion import opaque_keys.edx.django.models -import openedx_learning.lib.fields -import openedx_learning.lib.validators +import openedx_django_lib.fields +import openedx_django_lib.validators from django.db import migrations, models @@ -40,13 +40,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), - ('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), + ('upstream_context_key', openedx_django_lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), ('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)), ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), ('version_synced', models.IntegerField()), ('version_declined', models.IntegerField(blank=True, null=True)), - ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), - ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])), ('upstream_container_key', opaque_keys.edx.django.models.ContainerKeyField(help_text='Upstream block key (e.g. lct:...), this value cannot be null and is useful to track upstream library blocks that do not exist yet or were deleted.', max_length=255)), ('upstream_container', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='oel_publishing.container')), ], diff --git a/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py b/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py index 491ae0e4224a..266bcdf9db3a 100644 --- a/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py +++ b/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py @@ -1,8 +1,6 @@ from django.db import migrations -from cms.djangoapps.contentstore.toggles import ( - ENABLE_REACT_MARKDOWN_EDITOR -) +from cms.djangoapps.contentstore.toggles import ENABLE_REACT_MARKDOWN_EDITOR def create_flag(apps, schema_editor): diff --git a/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py b/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py index 02f94f4b6593..e7f3fd688d8b 100644 --- a/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py +++ b/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.23 on 2025-08-04 18:56 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index a4f2ce3c6119..3021f513a10f 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -15,13 +15,14 @@ from opaque_keys.edx.django.models import ContainerKeyField, CourseKeyField, UsageKeyField from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryContainerLocator -from openedx_learning.api.authoring import get_published_version -from openedx_learning.api.authoring_models import Component, Container -from openedx_learning.lib.fields import ( - immutable_uuid_field, - key_field, - manual_date_time_field, -) +from openedx_content.api import get_published_version +from openedx_content.models_api import Component, Container + +try: + from openedx_django_lib.fields import immutable_uuid_field, manual_date_time_field, ref_field +except ImportError: # pragma: no cover - runtime compatibility shim for different openedx_django_lib versions + from openedx_django_lib.fields import immutable_uuid_field, manual_date_time_field + from openedx_django_lib.fields import key_field as ref_field logger = logging.getLogger(__name__) @@ -91,15 +92,15 @@ class EntityLinkBase(models.Model): """ uuid = immutable_uuid_field() # Search by library/upstream context key - upstream_context_key = key_field( + upstream_context_key = ref_field( help_text=_("Upstream context key i.e., learning_package/library key"), db_index=True, ) # A downstream entity can only link to single upstream entity # whereas an entity can be upstream for multiple downstream entities. - downstream_usage_key = UsageKeyField(max_length=255, unique=True) + downstream_usage_key = UsageKeyField(unique=True) # Search by course/downstream key - downstream_context_key = CourseKeyField(max_length=255, db_index=True) + downstream_context_key = CourseKeyField(db_index=True) # This is present if the creation of this link is a consequence of # importing a container that has one or more levels of children. # This represents the parent (container) in the top level @@ -131,7 +132,7 @@ def published_at(self) -> str | None: """ raise NotImplementedError - class Meta: + class Meta: # noqa: DJ012 abstract = True @classmethod @@ -143,6 +144,8 @@ class ComponentLink(EntityLinkBase): """ This represents link between any two publishable entities or link between publishable entity and a course XBlock. It helps in tracking relationship between XBlocks imported from libraries and used in different courses. + + .. no_pii: """ upstream_block = models.ForeignKey( Component, @@ -152,7 +155,6 @@ class ComponentLink(EntityLinkBase): blank=True, ) upstream_usage_key = UsageKeyField( - max_length=255, help_text=_( "Upstream block usage key, this value cannot be null" " and useful to track upstream library blocks that do not exist yet" @@ -270,7 +272,7 @@ def update_or_create( Update or create entity link. This will only update `updated` field if something has changed. """ if not created: - created = datetime.now(tz=timezone.utc) + created = datetime.now(tz=timezone.utc) # noqa: UP017 top_level_parent = None if top_level_parent_usage_key is not None: try: @@ -315,6 +317,8 @@ class ContainerLink(EntityLinkBase): """ This represents link between any two publishable entities or link between publishable entity and a course xblock. It helps in tracking relationship between xblocks imported from libraries and used in different courses. + + .. no_pii: """ upstream_container = models.ForeignKey( Container, @@ -324,7 +328,6 @@ class ContainerLink(EntityLinkBase): blank=True, ) upstream_container_key = ContainerKeyField( - max_length=255, help_text=_( "Upstream block key (e.g. lct:...), this value cannot be null " "and is useful to track upstream library blocks that do not exist yet " @@ -491,7 +494,7 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase" @classmethod def update_or_create( cls, - upstream_container_id: int | None, + upstream_container_id: Container.ID | None, /, upstream_container_key: LibraryContainerLocator, upstream_context_key: str, @@ -507,7 +510,7 @@ def update_or_create( Update or create entity link. This will only update `updated` field if something has changed. """ if not created: - created = datetime.now(tz=timezone.utc) + created = datetime.now(tz=timezone.utc) # noqa: UP017 top_level_parent = None if top_level_parent_usage_key is not None: try: @@ -562,9 +565,10 @@ class LearningContextLinksStatus(models.Model): """ This table stores current processing status of upstream-downstream links in ComponentLink table for a course or a learning context. + + .. no_pii: """ context_key = CourseKeyField( - max_length=255, # Single entry for a learning context or course unique=True, help_text=_("Linking status for course context key"), @@ -596,7 +600,7 @@ def get_or_create(cls, context_key: str, created: datetime | None = None) -> "Le LearningContextLinksStatus object """ if not created: - created = datetime.now(tz=timezone.utc) + created = datetime.now(tz=timezone.utc) # noqa: UP017 status, _ = cls.objects.get_or_create( context_key=context_key, defaults={ @@ -616,5 +620,5 @@ def update_status( Updates entity links processing status of given learning context. """ self.status = status - self.updated = updated or datetime.now(tz=timezone.utc) + self.updated = updated or datetime.now(tz=timezone.utc) # noqa: UP017 self.save() diff --git a/cms/djangoapps/contentstore/outlines.py b/cms/djangoapps/contentstore/outlines.py index 72d5c4f257f0..e31860377a59 100644 --- a/cms/djangoapps/contentstore/outlines.py +++ b/cms/djangoapps/contentstore/outlines.py @@ -4,7 +4,7 @@ learning_sequences at publish time. """ from datetime import timezone -from typing import List, Tuple +from typing import List, Tuple # noqa: UP035 from edx_django_utils.monitoring import function_trace, set_custom_attribute @@ -16,10 +16,10 @@ CourseSectionData, CourseVisibility, ExamData, - VisibilityData + VisibilityData, ) -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order def _remove_version_info(usage_key): @@ -321,7 +321,7 @@ def _make_section_data(section, unique_sequences): @function_trace('get_outline_from_modulestore') -def get_outline_from_modulestore(course_key) -> Tuple[CourseOutlineData, List[ContentErrorData]]: +def get_outline_from_modulestore(course_key) -> Tuple[CourseOutlineData, List[ContentErrorData]]: # noqa: UP006 """ Return a CourseOutlineData and list of ContentErrorData for param:course_key @@ -352,7 +352,7 @@ def get_outline_from_modulestore(course_key) -> Tuple[CourseOutlineData, List[Co # maps to UTC), but for consistency, we're going to use the standard # python timezone.utc (which is what the learning_sequence app will # return from MySQL). They will compare as equal. - published_at=course.subtree_edited_on.replace(tzinfo=timezone.utc), + published_at=course.subtree_edited_on.replace(tzinfo=timezone.utc), # noqa: UP017 # .course_version is a BSON obj, so we convert to str (MongoDB- # specific objects don't go into CourseOutlineData). diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py index bd33049006c4..7e00327eeea7 100644 --- a/cms/djangoapps/contentstore/proctoring.py +++ b/cms/djangoapps/contentstore/proctoring.py @@ -16,7 +16,7 @@ get_exam_by_content_id, remove_review_policy, update_exam, - update_review_policy + update_review_policy, ) from edx_proctoring.exceptions import ProctoredExamNotFoundException, ProctoredExamReviewPolicyNotFoundException @@ -41,7 +41,7 @@ def register_special_exams(course_key): course = modulestore().get_course(course_key) if course is None: - raise ItemNotFoundError("Course {} does not exist", str(course_key)) # lint-amnesty, pylint: disable=raising-format-tuple + raise ItemNotFoundError("Course {} does not exist", str(course_key)) # pylint: disable=raising-format-tuple if not course.enable_proctored_exams and not course.enable_timed_exams: # likewise if course does not have these features turned on @@ -70,7 +70,7 @@ def register_special_exams(course_key): # add/update any exam entries in edx-proctoring for timed_exam in timed_exams: msg = ( - 'Found {location} as a timed-exam in course structure. Inspecting...'.format( + 'Found {location} as a timed-exam in course structure. Inspecting...'.format( # noqa: UP032 location=str(timed_exam.location) ) ) diff --git a/cms/djangoapps/contentstore/rest_api/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/serializers/__init__.py index 9d207ee767ea..0f2da7e95365 100644 --- a/cms/djangoapps/contentstore/rest_api/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/serializers/__init__.py @@ -1,4 +1,4 @@ """ Serializers for all contentstore API versions """ -from .common import StrictSerializer +from .common import StrictSerializer # noqa: F401 diff --git a/cms/djangoapps/contentstore/rest_api/urls.py b/cms/djangoapps/contentstore/rest_api/urls.py index 7296f7bb9858..af2694bfbbc5 100644 --- a/cms/djangoapps/contentstore/rest_api/urls.py +++ b/cms/djangoapps/contentstore/rest_api/urls.py @@ -2,8 +2,7 @@ Contentstore API URLs. """ -from django.urls import path -from django.urls import include +from django.urls import include, path from .v0 import urls as v0_urls from .v1 import urls as v1_urls diff --git a/cms/djangoapps/contentstore/rest_api/v0/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/__init__.py index 4ceefe6ead69..90b9707d8612 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/__init__.py @@ -2,11 +2,8 @@ Views for v0 contentstore API. """ -from cms.djangoapps.contentstore.rest_api.v0.views.assets import ( +from cms.djangoapps.contentstore.rest_api.v0.views.assets import ( # noqa: F401 AssetsCreateRetrieveView, - AssetsUpdateDestroyView -) -from cms.djangoapps.contentstore.rest_api.v0.views.xblock import ( - XblockView, - XblockCreateView + AssetsUpdateDestroyView, ) +from cms.djangoapps.contentstore.rest_api.v0.views.xblock import XblockCreateView, XblockView # noqa: F401 diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py index 171f746be438..65e71ea67cb5 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py @@ -1,10 +1,14 @@ """ Serializers for v0 contentstore API. """ -from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer -from .assets import AssetSerializer -from .authoring_grading import CourseGradingModelSerializer -from .course_optimizer import LinkCheckSerializer -from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer -from .transcripts import TranscriptSerializer, YoutubeTranscriptCheckSerializer, YoutubeTranscriptUploadSerializer -from .xblock import XblockSerializer +from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer # noqa: F401 +from .assets import AssetSerializer # noqa: F401 +from .authoring_grading import CourseGradingModelSerializer # noqa: F401 +from .course_optimizer import LinkCheckSerializer # noqa: F401 +from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer # noqa: F401 +from .transcripts import ( # noqa: F401 + TranscriptSerializer, + YoutubeTranscriptCheckSerializer, + YoutubeTranscriptUploadSerializer, +) +from .xblock import XblockSerializer # noqa: F401 diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py index 152ca95be341..a17c625a0fe0 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py @@ -1,23 +1,15 @@ """ Serializers for course advanced settings""" -from typing import Type, Dict as DictType +from typing import Dict as DictType # noqa: UP035 +from typing import Type # noqa: UP035 from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.fields import Field as SerializerField -from xblock.fields import ( - Boolean, - Date, - DateTime, - Dict, - Field as XBlockField, - Float, - Integer, - List, - String, -) -from xmodule.course_block import CourseFields, EmailString +from xblock.fields import Boolean, Date, DateTime, Dict, Float, Integer, List, String +from xblock.fields import Field as XBlockField from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from xmodule.course_block import CourseFields, EmailString # Maps xblock fields to their corresponding Django Rest Framework serializer field XBLOCK_DRF_FIELD_MAP = [ @@ -59,7 +51,7 @@ class CourseAdvancedSettingsSerializer(serializers.Serializer): # pylint: disab """ @staticmethod - def _get_drf_field_type_from_xblock_field(xblock_field: XBlockField) -> Type[SerializerField]: + def _get_drf_field_type_from_xblock_field(xblock_field: XBlockField) -> Type[SerializerField]: # noqa: UP006 """ Return the corresponding DRF Serializer field for an XBlock field. @@ -75,7 +67,7 @@ def _get_drf_field_type_from_xblock_field(xblock_field: XBlockField) -> Type[Ser return drf_type return serializers.JSONField - def get_fields(self) -> DictType[str, SerializerField]: + def get_fields(self) -> DictType[str, SerializerField]: # noqa: UP006 """ Return the fields for this serializer. diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/assets.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/assets.py index 7ecb473d1ade..d8015baad71b 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/assets.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/assets.py @@ -2,6 +2,7 @@ API Serializers for assets """ from rest_framework import serializers + from cms.djangoapps.contentstore.rest_api.serializers.common import StrictSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py index ad6c1d645e57..ce5eb1589c22 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py @@ -1,11 +1,11 @@ """ Serializers for course tabs """ -from typing import Dict +from typing import Dict # noqa: UP035 from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from xmodule.tabs import CourseTab from openedx.core.lib.api.serializers import UsageKeyField +from xmodule.tabs import CourseTab class CourseTabSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -41,7 +41,7 @@ class CourseTabSerializer(serializers.Serializer): # pylint: disable=abstract-m help_text=_("Additional settings specific to the tab"), ) - def to_representation(self, instance: CourseTab) -> Dict: + def to_representation(self, instance: CourseTab) -> Dict: # noqa: UP006 """ Returns a dict representation of a ``CourseTab`` that contains more data than its ``to_json`` method. @@ -74,7 +74,7 @@ class TabIDLocatorSerializer(serializers.Serializer): # pylint: disable=abstrac tab_id = serializers.CharField(required=False, help_text=_("ID of tab to update")) tab_locator = UsageKeyField(required=False, help_text=_("Location (Usage Key) of tab to update")) - def validate(self, attrs: Dict) -> Dict: + def validate(self, attrs: Dict) -> Dict: # noqa: UP006 """ Validates that either the ``tab_id`` or ``tab_locator`` are specified, but not both. diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/transcripts.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/transcripts.py index d0cb92e9060e..269d6057041e 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/transcripts.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/transcripts.py @@ -2,6 +2,7 @@ API Serializers for transcripts """ from rest_framework import serializers + from cms.djangoapps.contentstore.rest_api.serializers.common import StrictSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py index e95a76e91899..48ef9f82d580 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py @@ -2,6 +2,7 @@ API Serializers for xblocks """ from rest_framework import serializers + from cms.djangoapps.contentstore.rest_api.serializers.common import StrictSerializer # The XblockSerializer is designed to be scalable and generic. As such, its structure diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py index 765246258bf2..8c6392109eb2 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py @@ -2,13 +2,23 @@ Tests for the course advanced settings API. """ import json +from unittest.mock import patch +import casbin import ddt +import pkg_resources from django.test import override_settings from django.urls import reverse from milestones.tests.utils import MilestonesTestCaseMixin +from openedx_authz.api.users import assign_role_to_user_in_scope +from openedx_authz.constants.roles import COURSE_STAFF +from openedx_authz.engine.enforcer import AuthzEnforcer +from openedx_authz.engine.utils import migrate_policy_between_enforcers +from rest_framework.test import APIClient from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core import toggles as core_toggles @ddt.ddt @@ -91,3 +101,105 @@ def test_disabled_fetch_all_query_param(self, setting, excluded_field): with override_settings(FEATURES={setting: False}): resp = self.client.get(self.url, {"fetch_all": 0}) assert excluded_field not in resp.data + + +@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True) +class AdvancedSettingsAuthzTest(CourseTestCase): + """ + Tests for AdvancedCourseSettingsView authorization with openedx-authz. + + These tests enable the AUTHZ_COURSE_AUTHORING_FLAG by default. + """ + + def setUp(self): + super().setUp() + self._seed_database_with_policies() + self.url = reverse( + "cms.djangoapps.contentstore:v0:course_advanced_settings", + kwargs={"course_id": self.course.id}, + ) + + # Create test users + self.authorized_user = UserFactory() + self.unauthorized_user = UserFactory() + + # Assign role to authorized user + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + str(self.course.id) + ) + AuthzEnforcer.get_enforcer().load_policy() + + # Create API clients and force_authenticate + self.authorized_client = APIClient() + self.authorized_client.force_authenticate(user=self.authorized_user) + self.unauthorized_client = APIClient() + self.unauthorized_client.force_authenticate(user=self.unauthorized_user) + + def tearDown(self): + super().tearDown() + AuthzEnforcer.get_enforcer().clear_policy() + + @classmethod + def _seed_database_with_policies(cls): + """Seed the database with policies from the policy file.""" + global_enforcer = AuthzEnforcer.get_enforcer() + global_enforcer.load_policy() + model_path = pkg_resources.resource_filename("openedx_authz.engine", "config/model.conf") + policy_path = pkg_resources.resource_filename("openedx_authz.engine", "config/authz.policy") + migrate_policy_between_enforcers( + source_enforcer=casbin.Enforcer(model_path, policy_path), + target_enforcer=global_enforcer, + ) + + def test_authorized_for_specific_course(self, mock_flag): + """User authorized for specific course can access.""" + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, 200) # noqa: PT009 + + def test_unauthorized_for_specific_course(self, mock_flag): + """User without authorization for specific course cannot access.""" + response = self.unauthorized_client.get(self.url) + self.assertEqual(response.status_code, 403) # noqa: PT009 + + def test_unauthorized_for_different_course(self, mock_flag): + """User authorized for one course cannot access another course.""" + other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.user.id) + other_url = reverse( + "cms.djangoapps.contentstore:v0:course_advanced_settings", + kwargs={"course_id": other_course.id}, + ) + response = self.authorized_client.get(other_url) + self.assertEqual(response.status_code, 403) # noqa: PT009 + + def test_staff_authorized_by_default(self, mock_flag): + """Staff users are authorized by default.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) # noqa: PT009 + + def test_superuser_authorized_by_default(self, mock_flag): + """Superusers are authorized by default.""" + superuser = UserFactory(is_superuser=True, is_staff=False) + superuser_client = APIClient() + superuser_client.force_authenticate(user=superuser) + response = superuser_client.get(self.url) + self.assertEqual(response.status_code, 200) # noqa: PT009 + + def test_patch_authorized_for_specific_course(self, mock_flag): + """User authorized for specific course can PATCH.""" + response = self.authorized_client.patch( + self.url, + {"display_name": {"value": "Test"}}, + content_type="application/json" + ) + self.assertEqual(response.status_code, 200) # noqa: PT009 + + def test_patch_unauthorized_for_specific_course(self, mock_flag): + """User without authorization for specific course cannot PATCH.""" + response = self.unauthorized_client.patch( + self.url, + {"display_name": {"value": "Test"}}, + content_type="application/json" + ) + self.assertEqual(response.status_code, 403) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py index 3772dbb64e77..23959f6264b9 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py @@ -5,17 +5,16 @@ not the underlying Xblock service. It checks that the assets_handler method of the Xblock service is called with the expected parameters. """ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from django.core.files import File from django.http import JsonResponse from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase - +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ASSET_KEY_STRING = "asset-v1:dede+aba+weagi+type@asset+block@_0e37192a-42c4-441e-a3e1-8e40ec304e2e.jpg" mock_image = MagicMock(file=File) @@ -161,7 +160,7 @@ def assert_assets_handler_called(self, *, mock_handle_assets, response): mock_handle_assets.assert_called_once() passed_args = mock_handle_assets.call_args[0][0] - course_id = self.get_course_key_string() + course_id = self.get_course_key_string() # noqa: F841 assert passed_args.data.get("file").name == mock_image.name assert passed_args.method == "POST" @@ -204,7 +203,7 @@ def assert_assets_handler_called(self, *, mock_handle_assets, response): mock_handle_assets.assert_called_once() passed_args = mock_handle_assets.call_args[0][0] - course_id = self.get_course_key_string() + course_id = self.get_course_key_string() # noqa: F841 assert passed_args.data.get("locked") is True assert passed_args.method == "PUT" diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_authoring_grading.py new file mode 100644 index 000000000000..fbca4a6aedf2 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_authoring_grading.py @@ -0,0 +1,119 @@ +""" +Unit tests for authoring grading views. +""" +import json + +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_STAFF +from rest_framework import status +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.api.tests.base import BaseCourseViewTest +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin + + +class AuthoringGradingViewAuthzTest(CourseAuthzTestMixin, BaseCourseViewTest): + """ + Tests Authoring Grading configuration API authorization using openedx-authz. + The endpoint uses the COURSES_EDIT_GRADING_SETTINGS permission. + """ + + view_name = "cms.djangoapps.contentstore:v0:cms_api_update_grading" + authz_roles_to_assign = [COURSE_STAFF.external_key] + post_data = json.dumps({ + "graders": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "", + "weight": 100, + "id": 0 + } + ], + "grade_cutoffs": { + "A": 0.75, + "B": 0.63, + "C": 0.57, + "D": 0.5 + }, + "grace_period": { + "hours": 12, + "minutes": 0 + }, + "minimum_grade_credit": 0.7, + "is_credit_course": True + }) + + def test_authorized_user_can_access_post(self): + """User with COURSE_STAFF role can access.""" + resp = self.authorized_client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_unauthorized_user_cannot_access_post(self): + """User without role cannot access.""" + resp = self.unauthorized_client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_role_scoped_to_course_post(self): + """Authorization should only apply to the assigned course.""" + other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id) + + resp = self.authorized_client.post( + self.get_url(other_course.id), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_staff_user_allowed_via_legacy_post(self): + """ + Staff users should still pass through legacy fallback. + """ + self.client.login(username=self.staff.username, password=self.password) + + resp = self.client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_allowed_post(self): + """Superusers should always be allowed.""" + superuser = UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + resp = client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access_post(self): + """ + User without required permissions should be denied. + This case validates that a non-staff user doesn't get access. + """ + non_staff_user = UserFactory() + non_staff_client = APIClient() + self.add_user_to_role(non_staff_user, COURSE_DATA_RESEARCHER.external_key) + non_staff_client.force_authenticate(user=non_staff_user) + + resp = non_staff_client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py index 14d5a20fb41b..475c60c32403 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -2,10 +2,10 @@ Unit tests for course optimizer """ from django.test import TestCase -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.urls import reverse from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): @@ -30,14 +30,14 @@ def test_produces_4xx_when_invalid_course_id(self): Test course_id validation ''' response = self.make_request(course_id='invalid_course_id') - self.assertIn(response.status_code, range(400, 500)) + self.assertIn(response.status_code, range(400, 500)) # noqa: PT009 def test_produces_4xx_when_additional_kwargs(self): ''' Test additional kwargs validation ''' response = self.make_request(course_id=self.course.id, malicious_kwarg='malicious_kwarg') - self.assertIn(response.status_code, range(400, 500)) + self.assertIn(response.status_code, range(400, 500)) # noqa: PT009 class TestPostLinkCheck(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): @@ -62,18 +62,18 @@ def test_produces_4xx_when_invalid_course_id(self): Test course_id validation ''' response = self.make_request(course_id='invalid_course_id') - self.assertIn(response.status_code, range(400, 500)) + self.assertIn(response.status_code, range(400, 500)) # noqa: PT009 def test_produces_4xx_when_additional_kwargs(self): ''' Test additional kwargs validation ''' response = self.make_request(course_id=self.course.id, malicious_kwarg='malicious_kwarg') - self.assertIn(response.status_code, range(400, 500)) + self.assertIn(response.status_code, range(400, 500)) # noqa: PT009 def test_produces_4xx_when_unexpected_data(self): ''' Test validation when request contains unexpected data ''' response = self.make_request(course_id=self.course.id, data={'unexpected_data': 'unexpected_data'}) - self.assertIn(response.status_code, range(400, 500)) + self.assertIn(response.status_code, range(400, 500)) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_rerun_link_update.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_rerun_link_update.py index fa1489545f29..64206571248f 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_rerun_link_update.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_rerun_link_update.py @@ -86,8 +86,8 @@ def test_post_update_all_links_success(self): data = {"action": "all"} response = self.make_post_request(data=data) - self.assertEqual(response.status_code, 200) - self.assertIn("status", response.json()) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertIn("status", response.json()) # noqa: PT009 mock_task.delay.assert_called_once() def test_post_update_single_links_success(self): @@ -113,8 +113,8 @@ def test_post_update_single_links_success(self): } response = self.make_post_request(data=data) - self.assertEqual(response.status_code, 200) - self.assertIn("status", response.json()) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertIn("status", response.json()) # noqa: PT009 mock_task.delay.assert_called_once() def test_post_update_missing_action_returns_400(self): @@ -126,9 +126,9 @@ def test_post_update_missing_action_returns_400(self): data = {} response = self.make_post_request(data=data) - self.assertEqual(response.status_code, 400) - self.assertIn("error", response.json()) - self.assertIn("action", response.json()["error"]) + self.assertEqual(response.status_code, 400) # noqa: PT009 + self.assertIn("error", response.json()) # noqa: PT009 + self.assertIn("action", response.json()["error"]) # noqa: PT009 def test_error_handling_workflow(self): """Test error handling in the complete workflow""" @@ -142,11 +142,11 @@ def test_error_handling_workflow(self): data = {"action": "all"} response = self.make_post_request(data=data) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 # Step 2: Check failed status with patch(self.task_status_patch) as mock_status: - with patch(self.user_task_artifact_patch) as mock_artifact: + with patch(self.user_task_artifact_patch) as mock_artifact: # noqa: F841 mock_task_status = Mock() mock_task_status.state = UserTaskStatus.FAILED mock_status.return_value = mock_task_status @@ -154,7 +154,7 @@ def test_error_handling_workflow(self): status_url = self.get_status_url(self.course.id) status_response = self.client.get(status_url) - self.assertEqual(status_response.status_code, 200) + self.assertEqual(status_response.status_code, 200) # noqa: PT009 status_data = status_response.json() - self.assertEqual(status_data["status"], "Failed") - self.assertEqual(status_data["results"], []) + self.assertEqual(status_data["status"], "Failed") # noqa: PT009 + self.assertEqual(status_data["results"], []) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py index 5e83c9313617..46a804662c5e 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py @@ -8,10 +8,10 @@ import ddt from django.urls import reverse -from xmodule.modulestore.tests.factories import BlockFactory -from xmodule.tabs import CourseTabList from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from xmodule.modulestore.tests.factories import BlockFactory +from xmodule.tabs import CourseTabList @ddt.ddt diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs_permissions.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs_permissions.py new file mode 100644 index 000000000000..4fcbe453fff2 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs_permissions.py @@ -0,0 +1,109 @@ +""" +Integration tests verifying authz permissions for v0 tabs REST API views. +""" +from urllib.parse import urlencode + +from django.urls import reverse +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_LIMITED_STAFF, COURSE_STAFF +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin + + +class TabsV0AuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Integration tests for v0 tabs API authz permissions. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse( + 'cms.djangoapps.contentstore:v0:course_tab_list', + kwargs={'course_id': self.course.id}, + ) + self.settings_url = reverse( + 'cms.djangoapps.contentstore:v0:course_tab_settings', + kwargs={'course_id': self.course.id}, + ) + self.reorder_url = reverse( + 'cms.djangoapps.contentstore:v0:course_tab_reorder', + kwargs={'course_id': self.course.id}, + ) + + # --- CourseTabListView (GET) - requires courses.view_pages_and_resources --- + + def test_staff_can_list_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.list_url) + assert resp.status_code == 200 + + def test_auditor_can_list_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.list_url) + assert resp.status_code == 200 + + def test_limited_staff_cannot_list_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_LIMITED_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.list_url) + assert resp.status_code == 403 + + def test_unauthorized_cannot_list_tabs(self): + resp = self.unauthorized_client.get(self.list_url) + assert resp.status_code == 403 + + # --- CourseTabSettingsView (POST) - requires courses.manage_pages_and_resources --- + + def test_staff_can_update_tab_settings(self): + """Asserts not-403 rather than 200 because the minimal payload may fail validation.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.post( + f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}', + data={'is_hidden': True}, + format='json', + ) + assert resp.status_code != 403 + + def test_auditor_cannot_update_tab_settings(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.post( + f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}', + data={'is_hidden': True}, + format='json', + ) + assert resp.status_code == 403 + + def test_unauthorized_cannot_update_tab_settings(self): + resp = self.unauthorized_client.post( + f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}', + data={'is_hidden': True}, + format='json', + ) + assert resp.status_code == 403 + + # --- CourseTabReorderView (POST) - requires courses.manage_pages_and_resources --- + + def test_staff_can_reorder_tabs(self): + """Asserts not-403 rather than 200 because the empty tab list may fail validation.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.post(self.reorder_url, data=[], format='json') + assert resp.status_code != 403 + + def test_auditor_cannot_reorder_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.post(self.reorder_url, data=[], format='json') + assert resp.status_code == 403 + + def test_unauthorized_cannot_reorder_tabs(self): + resp = self.unauthorized_client.post(self.reorder_url, data=[], format='json') + assert resp.status_code == 403 + + # --- Superuser bypass --- + + def test_superuser_can_list_tabs(self): + superuser = UserFactory(is_superuser=True) + client = APIClient() + client.force_authenticate(user=superuser) + resp = client.get(self.list_url) + assert resp.status_code == 200 diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py index 512c92f6ffe9..f192e39c757a 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py @@ -4,15 +4,14 @@ It checks that the xblock_handler method of the Xblock service is called with the expected parameters. """ from unittest.mock import patch -from django.http import JsonResponse +from django.http import JsonResponse from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase - +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase TEST_LOCATOR = "block-v1:dede+aba+weagi+type@problem+block@ba6327f840da49289fb27a9243913478" VERSION = "v0" diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py index 974d1b98a0c4..65d45a98126d 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -1,7 +1,7 @@ """ Contenstore API v0 URLs. """ from django.conf import settings -from django.urls import re_path, path +from django.urls import path, re_path from openedx.core.constants import COURSE_ID_PATTERN @@ -19,10 +19,10 @@ TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView, + assets, + authoring_videos, + xblock, ) -from .views import assets -from .views import authoring_videos -from .views import xblock app_name = "v0" diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py index 5714754b191e..9d9be33f97ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -1,9 +1,14 @@ """ Views for v0 contentstore API. """ -from .advanced_settings import AdvancedCourseSettingsView -from .api_heartbeat import APIHeartBeatView -from .authoring_grading import AuthoringGradingView -from .course_optimizer import LinkCheckStatusView, LinkCheckView, RerunLinkUpdateStatusView, RerunLinkUpdateView -from .tabs import CourseTabListView, CourseTabReorderView, CourseTabSettingsView -from .transcripts import TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView +from .advanced_settings import AdvancedCourseSettingsView # noqa: F401 +from .api_heartbeat import APIHeartBeatView # noqa: F401 +from .authoring_grading import AuthoringGradingView # noqa: F401 +from .course_optimizer import ( # noqa: F401 + LinkCheckStatusView, + LinkCheckView, + RerunLinkUpdateStatusView, + RerunLinkUpdateView, +) +from .tabs import CourseTabListView, CourseTabReorderView, CourseTabSettingsView # noqa: F401 +from .transcripts import TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView # noqa: F401 diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py index 7516bba37228..e7baf54b375d 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py @@ -1,20 +1,21 @@ """ API Views for course advanced settings """ -from django import forms import edx_api_doc_tools as apidocs +from django import forms from opaque_keys.edx.keys import CourseKey from rest_framework.exceptions import ValidationError from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from xmodule.modulestore.django import modulestore -from cms.djangoapps.models.settings.course_metadata import CourseMetadata from cms.djangoapps.contentstore.api.views.utils import get_bool_param -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from common.djangoapps.student.auth import check_course_advanced_settings_access from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes -from ..serializers import CourseAdvancedSettingsSerializer +from xmodule.modulestore.django import modulestore + from ....views.course import update_course_advanced_settings +from ..serializers import CourseAdvancedSettingsSerializer @view_auth_classes(is_authenticated=True) @@ -115,7 +116,7 @@ def get(self, request: Request, course_id: str): if not filter_query_data.is_valid(): raise ValidationError(filter_query_data.errors) course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not check_course_advanced_settings_access(request.user, course_key, access_type='read'): self.permission_denied(request) course_block = modulestore().get_course(course_key) fetch_all = get_bool_param(request, 'fetch_all', True) @@ -184,7 +185,7 @@ def patch(self, request: Request, course_id: str): along with all the course's settings similar to a ``GET`` request. """ course_key = CourseKey.from_string(course_id) - if not has_studio_write_access(request.user, course_key): + if not check_course_advanced_settings_access(request.user, course_key, access_type='write'): self.permission_denied(request) course_block = modulestore().get_course(course_key) updated_data = update_course_advanced_settings(course_block, request.data, request.user) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py b/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py index f322f2360939..78aa655f652a 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py @@ -1,9 +1,10 @@ """ View For Getting the Status of The Authoring API """ import edx_api_doc_tools as apidocs -from rest_framework.views import APIView +from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response -from rest_framework import status +from rest_framework.views import APIView + from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes @@ -32,13 +33,13 @@ def get(self, request: Request): **Response Values** If the request is successful, an HTTP 200 "OK" response is returned. - The HTTP 200 response contains a single dict with the "authoring_api_enabled" value "True". + The HTTP 200 response contains a single dict with the "content_api_enabled" value "True". **Example Response** ```json { - "authoring_api_enabled": "True" + "content_api_enabled": "True" } ``` """ diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/assets.py b/cms/djangoapps/contentstore/rest_api/v0/views/assets.py index a7a495256514..868614951fc4 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/assets.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/assets.py @@ -2,20 +2,19 @@ Public rest API endpoints for the CMS API Assets. """ import logging -from rest_framework.generics import CreateAPIView, RetrieveAPIView, UpdateAPIView, DestroyAPIView -from django.views.decorators.csrf import csrf_exempt -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from common.djangoapps.util.json_request import expect_json_in_class_view +from django.views.decorators.csrf import csrf_exempt +from rest_framework.generics import CreateAPIView, DestroyAPIView, RetrieveAPIView, UpdateAPIView +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from cms.djangoapps.contentstore.api import course_author_access_required - from cms.djangoapps.contentstore.asset_storage_handlers import handle_assets +from common.djangoapps.util.json_request import expect_json_in_class_view +from openedx.core.lib.api.parsers import TypedFileUploadParser +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from ..serializers.assets import AssetSerializer from .utils import validate_request_with_serializer -from rest_framework.parsers import (MultiPartParser, FormParser, JSONParser) -from openedx.core.lib.api.parsers import TypedFileUploadParser log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py index ef965bc3d4c8..db3fffd17fa7 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py @@ -2,14 +2,17 @@ import edx_api_doc_tools as apidocs from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_EDIT_GRADING_SETTINGS from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import authz_permission_required from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes + from ..serializers import CourseGradingModelSerializer @@ -31,7 +34,10 @@ class AuthoringGradingView(DeveloperErrorViewMixin, APIView): }, ) @verify_course_exists() - def post(self, request: Request, course_id: str): + # Please note: previous legacy permisison was checking for has_studio_read_access + # So we are using LegacyAuthoringPermission.READ to keep compatibility + @authz_permission_required(COURSES_EDIT_GRADING_SETTINGS.identifier, LegacyAuthoringPermission.READ) + def post(self, request: Request, course_key: CourseKey): """ Update a course's grading. @@ -75,11 +81,6 @@ def post(self, request: Request, course_id: str): If the request is successful, an HTTP 200 "OK" response is returned, """ - course_key = CourseKey.from_string(course_id) - - if not has_studio_read_access(request.user, course_key): - self.permission_denied(request) - if 'minimum_grade_credit' in request.data: update_credit_course_requirements.delay(str(course_key)) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py index f97bf7a98d16..5a7563c2372e 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py @@ -2,32 +2,24 @@ Public rest API endpoints for the Authoring API video assets. """ import logging -from rest_framework.generics import ( - CreateAPIView, - RetrieveAPIView, - DestroyAPIView -) -from rest_framework.parsers import (MultiPartParser, FormParser) -from django.views.decorators.csrf import csrf_exempt -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from openedx.core.lib.api.parsers import TypedFileUploadParser -from common.djangoapps.util.json_request import expect_json_in_class_view - -from ....api import course_author_access_required +from django.views.decorators.csrf import csrf_exempt +from rest_framework.generics import CreateAPIView, DestroyAPIView, RetrieveAPIView +from rest_framework.parsers import FormParser, MultiPartParser +from cms.djangoapps.contentstore.rest_api.v1.serializers import VideoImageSerializer, VideoUploadSerializer from cms.djangoapps.contentstore.video_storage_handlers import ( - handle_videos, + enabled_video_features, get_video_encodings_download, handle_video_images, - enabled_video_features -) -from cms.djangoapps.contentstore.rest_api.v1.serializers import ( - VideoUploadSerializer, - VideoImageSerializer, + handle_videos, ) -from .utils import validate_request_with_serializer +from common.djangoapps.util.json_request import expect_json_in_class_view +from openedx.core.lib.api.parsers import TypedFileUploadParser +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from ....api import course_author_access_required +from .utils import validate_request_with_serializer log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index bd37ae837916..9b012d918e06 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -15,19 +15,15 @@ sort_course_sections, ) from cms.djangoapps.contentstore.rest_api.v0.serializers.course_optimizer import ( + CourseRerunLinkUpdateRequestSerializer, CourseRerunLinkUpdateStatusSerializer, LinkCheckSerializer, - CourseRerunLinkUpdateRequestSerializer, ) from cms.djangoapps.contentstore.tasks import check_broken_links, update_course_rerun_links from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access from common.djangoapps.util.json_request import JsonResponse -from openedx.core.lib.api.view_utils import ( - DeveloperErrorViewMixin, - verify_course_exists, - view_auth_classes, -) +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes @view_auth_classes(is_authenticated=True) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py index 968af2246aa3..e1b9401cd0f9 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py @@ -3,17 +3,23 @@ import edx_api_doc_tools as apidocs from django.utils.translation import gettext_lazy as _ from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import ( + COURSES_MANAGE_PAGES_AND_RESOURCES, + COURSES_VIEW_PAGES_AND_RESOURCES, +) from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView + +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes -from ..serializers import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer from ....views.tabs import edit_tab_handler, get_course_tabs, reorder_tabs_handler +from ..serializers import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer @view_auth_classes(is_authenticated=True) @@ -78,7 +84,12 @@ def get(self, request: Request, course_id: str) -> Response: ``` """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.READ, + ): self.permission_denied(request) course_block = modulestore().get_course(course_key) @@ -149,7 +160,12 @@ def post(self, request: Request, course_id: str) -> Response: without any content. """ course_key = CourseKey.from_string(course_id) - if not has_studio_write_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_MANAGE_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + ): self.permission_denied(request) tab_id_locator = TabIDLocatorSerializer(data=request.query_params) @@ -221,7 +237,12 @@ def post(self, request: Request, course_id: str) -> Response: without any content. """ course_key = CourseKey.from_string(course_id) - if not has_studio_write_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_MANAGE_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + ): self.permission_denied(request) course_block = modulestore().get_course(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py b/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py index 3344fdbd1f43..11c33d7e5a81 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py @@ -2,28 +2,24 @@ Public rest API endpoints for the CMS API video assets. """ import logging -from rest_framework.generics import ( - CreateAPIView, - RetrieveAPIView, - DestroyAPIView -) -from django.views.decorators.csrf import csrf_exempt -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from common.djangoapps.util.json_request import expect_json_in_class_view +from django.views.decorators.csrf import csrf_exempt +from rest_framework.generics import CreateAPIView, DestroyAPIView, RetrieveAPIView +from rest_framework.parsers import FormParser, MultiPartParser from cms.djangoapps.contentstore.api import course_author_access_required -from cms.djangoapps.contentstore.views.transcripts_ajax import check_transcripts, replace_transcripts +from cms.djangoapps.contentstore.rest_api.v0.views.utils import validate_request_with_serializer from cms.djangoapps.contentstore.transcript_storage_handlers import ( - upload_transcript, delete_video_transcript_or_404, handle_transcript_download, + upload_transcript, ) -from ..serializers import TranscriptSerializer, YoutubeTranscriptCheckSerializer, YoutubeTranscriptUploadSerializer -from rest_framework.parsers import (MultiPartParser, FormParser) +from cms.djangoapps.contentstore.views.transcripts_ajax import check_transcripts, replace_transcripts +from common.djangoapps.util.json_request import expect_json_in_class_view from openedx.core.lib.api.parsers import TypedFileUploadParser +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from cms.djangoapps.contentstore.rest_api.v0.views.utils import validate_request_with_serializer +from ..serializers import TranscriptSerializer, YoutubeTranscriptCheckSerializer, YoutubeTranscriptUploadSerializer log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/utils.py b/cms/djangoapps/contentstore/rest_api/v0/views/utils.py index 7175b76c7c26..288c8957bae8 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/utils.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/utils.py @@ -2,6 +2,7 @@ Utilities for the REST API views. """ from functools import wraps + from django.http import HttpResponseBadRequest diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py index 8e678ae845e0..79217971bb67 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py @@ -2,19 +2,18 @@ Public rest API endpoints for the CMS API. """ import logging -from rest_framework.generics import RetrieveUpdateDestroyAPIView, CreateAPIView -from django.views.decorators.csrf import csrf_exempt -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from common.djangoapps.util.json_request import expect_json_in_class_view +from django.views.decorators.csrf import csrf_exempt +from rest_framework.generics import CreateAPIView, RetrieveUpdateDestroyAPIView from cms.djangoapps.contentstore.api import course_author_access_required from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers +from common.djangoapps.util.json_request import expect_json_in_class_view +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from ..serializers import XblockSerializer from .utils import validate_request_with_serializer - log = logging.getLogger(__name__) handle_xblock = view_handlers.handle_xblock diff --git a/cms/djangoapps/contentstore/rest_api/v1/mixins.py b/cms/djangoapps/contentstore/rest_api/v1/mixins.py index 3ac4795680ff..18f1d66149d5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/mixins.py +++ b/cms/djangoapps/contentstore/rest_api/v1/mixins.py @@ -33,8 +33,8 @@ def test_permissions_unauthenticated(self): self.client.logout() response = self.client.get(self.url) error = self.get_and_check_developer_response(response) - self.assertEqual(error, "Authentication credentials were not provided.") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(error, "Authentication credentials were not provided.") # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009 @patch.dict("django.conf.settings.FEATURES", {"DISABLE_ADVANCED_SETTINGS": True}) def test_permissions_unauthorized(self): @@ -44,8 +44,8 @@ def test_permissions_unauthorized(self): client, _ = self.create_non_staff_authed_user_client() response = client.get(self.url) error = self.get_and_check_developer_response(response) - self.assertEqual(error, "You do not have permission to perform this action.") - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(error, "You do not have permission to perform this action.") # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 class ContainerHandlerMixin: diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index f96cb9adeaa8..26403f734775 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -1,28 +1,28 @@ """ Serializers for v1 contentstore API. """ -from .certificates import CourseCertificatesSerializer -from .course_details import CourseDetailsSerializer -from .course_index import CourseIndexSerializer -from .course_rerun import CourseRerunSerializer -from .course_team import CourseTeamSerializer -from .course_waffle_flags import CourseWaffleFlagsSerializer -from .grading import CourseGradingModelSerializer, CourseGradingSerializer -from .group_configurations import CourseGroupConfigurationsSerializer -from .home import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer +from .certificates import CourseCertificatesSerializer # noqa: F401 +from .course_details import CourseDetailsSerializer # noqa: F401 +from .course_index import CourseIndexSerializer # noqa: F401 +from .course_rerun import CourseRerunSerializer # noqa: F401 +from .course_team import CourseTeamSerializer # noqa: F401 +from .course_waffle_flags import CourseWaffleFlagsSerializer # noqa: F401 +from .grading import CourseGradingModelSerializer, CourseGradingSerializer # noqa: F401 +from .group_configurations import CourseGroupConfigurationsSerializer # noqa: F401 +from .home import CourseHomeTabSerializer, LibraryTabSerializer, StudioHomeSerializer # noqa: F401 from .proctoring import ( - LimitedProctoredExamSettingsSerializer, - ProctoredExamConfigurationSerializer, - ProctoredExamSettingsSerializer, - ProctoringErrorsSerializer, + LimitedProctoredExamSettingsSerializer, # noqa: F401 + ProctoredExamConfigurationSerializer, # noqa: F401 + ProctoredExamSettingsSerializer, # noqa: F401 + ProctoringErrorsSerializer, # noqa: F401 ) -from .settings import CourseSettingsSerializer -from .textbooks import CourseTextbooksSerializer -from .vertical_block import ContainerHandlerSerializer, ContainerChildrenSerializer +from .settings import CourseSettingsSerializer # noqa: F401 +from .textbooks import CourseTextbooksSerializer # noqa: F401 +from .vertical_block import ContainerChildrenSerializer, ContainerHandlerSerializer # noqa: F401 from .videos import ( - CourseVideosSerializer, - VideoDownloadSerializer, - VideoImageSerializer, - VideoUploadSerializer, - VideoUsageSerializer, + CourseVideosSerializer, # noqa: F401 + VideoDownloadSerializer, # noqa: F401 + VideoImageSerializer, # noqa: F401 + VideoUploadSerializer, # noqa: F401 + VideoUsageSerializer, # noqa: F401 ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py index fbcf2aca72f4..eec4dc11f38a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py @@ -53,6 +53,7 @@ class CourseDetailsSerializer(serializers.Serializer): pre_requisite_courses = serializers.ListField(child=CourseKeyField()) run = serializers.CharField() self_paced = serializers.BooleanField() + has_changes = serializers.BooleanField() short_description = serializers.CharField(allow_blank=True) start_date = serializers.DateTimeField() subtitle = serializers.CharField(allow_blank=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py index 2d0785459069..8610bd18b8f7 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py @@ -5,6 +5,7 @@ from rest_framework import serializers from cms.djangoapps.contentstore import toggles +from openedx.core import toggles as core_toggles class CourseWaffleFlagsSerializer(serializers.Serializer): @@ -20,6 +21,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): use_new_import_page = serializers.SerializerMethodField() use_new_export_page = serializers.SerializerMethodField() use_new_files_uploads_page = serializers.SerializerMethodField() + use_new_pdf_editor = serializers.SerializerMethodField() use_new_video_uploads_page = serializers.SerializerMethodField() use_new_course_outline_page = serializers.SerializerMethodField() use_new_unit_page = serializers.SerializerMethodField() @@ -31,6 +33,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): use_react_markdown_editor = serializers.SerializerMethodField() use_video_gallery_flow = serializers.SerializerMethodField() enable_course_optimizer_check_prev_run_links = serializers.SerializerMethodField() + enable_authz_course_authoring = serializers.SerializerMethodField() def get_course_key(self): """ @@ -64,24 +67,33 @@ def get_use_new_custom_pages(self, obj): def get_use_new_schedule_details_page(self, obj): """ - Method to get the use_new_schedule_details_page switch + Method to indicate whether we should use the new schedule details page. + + This used to be based on a waffle flag but the flag is being removed so we + default it to true for now until we can remove the need for it from the consumers + of this serializer and the related APIs. + + See https://github.com/openedx/edx-platform/issues/36275 """ - course_key = self.get_course_key() - return toggles.use_new_schedule_details_page(course_key) + return True def get_use_new_advanced_settings_page(self, obj): """ Method to get the use_new_advanced_settings_page switch """ - course_key = self.get_course_key() - return toggles.use_new_advanced_settings_page(course_key) + return True def get_use_new_grading_page(self, obj): """ - Method to get the use_new_grading_page switch + Method to indicate whether we should use the new grading page. + + This used to be based on a waffle flag but the flag is being removed so we + default it to true for now until we can remove the need for it from the consumers + of this serializer and the related APIs. + + See https://github.com/openedx/edx-platform/issues/36275 """ - course_key = self.get_course_key() - return toggles.use_new_grading_page(course_key) + return True def get_use_new_updates_page(self, obj): """ @@ -99,15 +111,13 @@ def get_use_new_import_page(self, obj): """ Method to get the use_new_import_page switch """ - course_key = self.get_course_key() - return toggles.use_new_import_page(course_key) + return True def get_use_new_export_page(self, obj): """ Method to get the use_new_export_page switch """ - course_key = self.get_course_key() - return toggles.use_new_export_page(course_key) + return True def get_use_new_files_uploads_page(self, obj): """ @@ -118,9 +128,20 @@ def get_use_new_files_uploads_page(self, obj): """ return True + def get_use_new_pdf_editor(self, obj): + """ + Method to get the use_new_pdf_editor switch + """ + return toggles.use_new_pdf_editor() + def get_use_new_video_uploads_page(self, obj): """ - Method to get the use_new_video_uploads_page switch + Method to get the use_new_video_uploads_page switch. + + This is off by default because the video uploads page requires the edX + video pipeline which is not available to the open source community. + + See https://github.com/openedx/openedx-platform/issues/37972 """ course_key = self.get_course_key() return toggles.use_new_video_uploads_page(course_key) @@ -146,15 +167,13 @@ def get_use_new_course_team_page(self, obj): """ Method to get the use_new_course_team_page switch """ - course_key = self.get_course_key() - return toggles.use_new_course_team_page(course_key) + return True def get_use_new_certificates_page(self, obj): """ Method to get the use_new_certificates_page switch """ - course_key = self.get_course_key() - return toggles.use_new_certificates_page(course_key) + return True def get_use_new_textbooks_page(self, obj): """ @@ -172,8 +191,7 @@ def get_use_new_group_configurations_page(self, obj): """ Method to get the use_new_group_configurations_page switch """ - course_key = self.get_course_key() - return toggles.use_new_group_configurations_page(course_key) + return True def get_enable_course_optimizer(self, obj): """ @@ -201,3 +219,10 @@ def get_enable_course_optimizer_check_prev_run_links(self, obj): """ course_key = self.get_course_key() return toggles.enable_course_optimizer_check_prev_run_links(course_key) + + def get_enable_authz_course_authoring(self, obj): + """ + Method to get the authz.enable_course_authoring waffle flag + """ + course_key = self.get_course_key() + return core_toggles.enable_authz_course_authoring(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index eb4f333e170a..a12b8922557b 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -5,10 +5,7 @@ from django.urls import reverse from rest_framework import serializers -from cms.djangoapps.contentstore.helpers import ( - xblock_studio_url, - xblock_type_display_name, -) +from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled @@ -125,7 +122,7 @@ class UpstreamLinkSerializer(serializers.Serializer): error_message = serializers.CharField(allow_null=True) ready_to_sync = serializers.BooleanField() downstream_customized = serializers.ListField(child=serializers.CharField(), allow_empty=True) - has_top_level_parent = serializers.BooleanField() + top_level_parent_key = serializers.CharField(allow_null=True) ready_to_sync_children = UpstreamChildrenInfoSerializer(many=True, required=False) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py index bd85fdb77220..11620eb9c353 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py @@ -2,6 +2,7 @@ API Serializers for videos """ from rest_framework import serializers + from cms.djangoapps.contentstore.rest_api.serializers.common import StrictSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index d4fcfd5f2e3f..7654c9e0befc 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -1,22 +1,18 @@ """ Views for v1 contentstore API. """ -from .certificates import CourseCertificatesView -from .course_details import CourseDetailsView -from .course_index import ContainerChildrenView, CourseIndexView -from .course_rerun import CourseRerunView -from .course_team import CourseTeamView -from .course_waffle_flags import CourseWaffleFlagsView -from .grading import CourseGradingView -from .group_configurations import CourseGroupConfigurationsView -from .help_urls import HelpUrlsView -from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView -from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView -from .settings import CourseSettingsView -from .textbooks import CourseTextbooksView -from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view -from .videos import ( - CourseVideosView, - VideoDownloadView, - VideoUsageView, -) +from .certificates import CourseCertificatesView # noqa: F401 +from .course_details import CourseDetailsView # noqa: F401 +from .course_index import ContainerChildrenView, CourseIndexView # noqa: F401 +from .course_rerun import CourseRerunView # noqa: F401 +from .course_team import CourseTeamView # noqa: F401 +from .course_waffle_flags import CourseWaffleFlagsView # noqa: F401 +from .grading import CourseGradingView # noqa: F401 +from .group_configurations import CourseGroupConfigurationsView # noqa: F401 +from .help_urls import HelpUrlsView # noqa: F401 +from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView # noqa: F401 +from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView # noqa: F401 +from .settings import CourseSettingsView # noqa: F401 +from .textbooks import CourseTextbooksView # noqa: F401 +from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view # noqa: F401 +from .videos import CourseVideosView, VideoDownloadView, VideoUsageView # noqa: F401 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py index db93d65da68a..43069553a067 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py @@ -2,20 +2,16 @@ import edx_api_doc_tools as apidocs from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_MANAGE_CERTIFICATES from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseCertificatesSerializer from cms.djangoapps.contentstore.utils import get_certificates_context -from cms.djangoapps.contentstore.rest_api.v1.serializers import ( - CourseCertificatesSerializer, -) -from common.djangoapps.student.auth import has_studio_write_access -from openedx.core.lib.api.view_utils import ( - DeveloperErrorViewMixin, - verify_course_exists, - view_auth_classes, -) +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore @@ -96,7 +92,12 @@ def get(self, request: Request, course_id: str): course_key = CourseKey.from_string(course_id) store = modulestore() - if not has_studio_write_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_MANAGE_CERTIFICATES.identifier, + course_key, + LegacyAuthoringPermission.WRITE + ): self.permission_denied(request) with store.bulk_operations(course_key): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py index d5ccf3c6165e..2bb9c571d2fd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py @@ -2,18 +2,95 @@ import edx_api_doc_tools as apidocs from django.core.exceptions import ValidationError -from common.djangoapps.util.json_request import JsonResponseBadRequest from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import ( + COURSES_EDIT_DETAILS, + COURSES_EDIT_SCHEDULE, + COURSES_VIEW_SCHEDULE_AND_DETAILS, +) from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from common.djangoapps.student.auth import has_studio_read_access + +from common.djangoapps.util.json_request import JsonResponseBadRequest +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore -from ..serializers import CourseDetailsSerializer from ....utils import update_course_details +from ..serializers import CourseDetailsSerializer + + +def _classify_update(payload: dict, course_key: CourseKey) -> tuple[bool, bool]: + """ + Determine whether the payload is updating schedule fields, detail fields, or both + for the course identified by course_key. + + Returns: + (is_schedule_update, is_details_update) + """ + + # Define which fields are considered schedule fields. + # Any field not in this set that is being updated will be considered a details update. + schedule_fields = frozenset( + {"start_date", "end_date", "enrollment_start", "enrollment_end", "certificate_available_date"} + ) + + # Define which fields are date fields to ensure proper comparison after parsing. + # At this time, all schedule fields are also date fields, but this is defined separately for clarity + # and in case this changes in the future. + date_fields = frozenset( + {"start_date", "end_date", "enrollment_start", "enrollment_end", "certificate_available_date"} + ) + + course_details = CourseDetails.fetch(course_key) + + is_schedule_update = False + is_details_update = False + + serializer = CourseDetailsSerializer() + + for field, payload_value in payload.items(): + # Early exit for efficiency + if is_schedule_update and is_details_update: + break + + # Ignore unknown fields if needed + if field not in serializer.fields: + continue + + current_value = getattr(course_details, field, None) + + if field in date_fields: + # For date fields, we need to parse the payload value to compare it with the current value + try: + # Convert payload value to internal value for accurate comparison + # on date fields + if payload_value is not None: + payload_value = serializer.fields[field].to_internal_value(payload_value) + except ValidationError as exc: + raise ValidationError( + f"Invalid date format for field {field}: {payload_value}" + ) from exc + + # Check schedule fields + if field in schedule_fields: + if is_schedule_update: + # Already classified as schedule update, no need to check again + continue + if payload_value != current_value: + is_schedule_update = True + else: + # Any non-schedule field counts as details update + if is_details_update: + # Already classified as details update, no need to check again + continue + if payload_value != current_value: + is_details_update = True + + return is_schedule_update, is_details_update @view_auth_classes(is_authenticated=True) @@ -98,7 +175,12 @@ def get(self, request: Request, course_id: str): ``` """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_SCHEDULE_AND_DETAILS.identifier, + course_key, + LegacyAuthoringPermission.READ + ): self.permission_denied(request) course_details = CourseDetails.fetch(course_key) @@ -141,7 +223,26 @@ def put(self, request: Request, course_id: str): along with all the course's details similar to a ``GET`` request. """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + is_schedule_update, is_details_update = _classify_update(request.data, course_key) + + if not is_schedule_update and not is_details_update: + # No updatable fields provided in the request + is_details_update = True # To trigger permission check and return 403 if user cannot edit details + + if is_schedule_update and not user_has_course_permission( + request.user, + COURSES_EDIT_SCHEDULE.identifier, + course_key, + LegacyAuthoringPermission.READ + ): + self.permission_denied(request) + + if is_details_update and not user_has_course_permission( + request.user, + COURSES_EDIT_DETAILS.identifier, + course_key, + LegacyAuthoringPermission.READ + ): self.permission_denied(request) course_block = modulestore().get_course(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index 42b5b1e9d78d..f95c4e23e895 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -5,17 +5,15 @@ import edx_api_doc_tools as apidocs from django.conf import settings from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE +from rest_framework.fields import BooleanField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.fields import BooleanField from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin -from cms.djangoapps.contentstore.rest_api.v1.serializers import ( - CourseIndexSerializer, - ContainerChildrenSerializer, -) +from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerChildrenSerializer, CourseIndexSerializer from cms.djangoapps.contentstore.utils import ( get_course_index_context, get_user_partition_info, @@ -25,10 +23,10 @@ ) from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock from cms.lib.xblock.upstream_sync import UpstreamLink -from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order @view_auth_classes(is_authenticated=True) @@ -104,7 +102,12 @@ def get(self, request: Request, course_id: str): """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ + ): self.permission_denied(request) course_index_context = get_course_index_context(request, course_key) course_index_context.update({ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py index fe39858c5380..dbec4b0b8441 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py @@ -6,8 +6,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from cms.djangoapps.contentstore.utils import get_course_rerun_context from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseRerunSerializer +from cms.djangoapps.contentstore.utils import get_course_rerun_context from common.djangoapps.student.roles import GlobalStaff from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/grading.py b/cms/djangoapps/contentstore/rest_api/v1/views/grading.py index 9275fecb58ab..a27cfd144a56 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/grading.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/grading.py @@ -3,19 +3,21 @@ import edx_api_doc_tools as apidocs from django.conf import settings from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_EDIT_GRADING_SETTINGS, COURSES_VIEW_GRADING_SETTINGS from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import authz_permission_required from openedx.core.djangoapps.credit.api import is_credit_course from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore -from ..serializers import CourseGradingModelSerializer, CourseGradingSerializer from ....utils import get_course_grading +from ..serializers import CourseGradingModelSerializer, CourseGradingSerializer @view_auth_classes(is_authenticated=True) @@ -36,7 +38,8 @@ class CourseGradingView(DeveloperErrorViewMixin, APIView): }, ) @verify_course_exists() - def get(self, request: Request, course_id: str): + @authz_permission_required(COURSES_VIEW_GRADING_SETTINGS.identifier, LegacyAuthoringPermission.READ) + def get(self, request: Request, course_key: CourseKey): """ Get an object containing course grading settings with model. @@ -90,11 +93,6 @@ def get(self, request: Request, course_id: str): } ``` """ - course_key = CourseKey.from_string(course_id) - - if not has_studio_read_access(request.user, course_key): - self.permission_denied(request) - with modulestore().bulk_operations(course_key): credit_eligibility_enabled = settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) show_credit_eligibility = is_credit_course(course_key) and credit_eligibility_enabled @@ -118,13 +116,16 @@ def get(self, request: Request, course_id: str): }, ) @verify_course_exists() - def post(self, request: Request, course_id: str): + # Please note: previous legacy permisison was checking for has_studio_read_access + # So we are using LegacyAuthoringPermission.READ to keep compatibility + @authz_permission_required(COURSES_EDIT_GRADING_SETTINGS.identifier, LegacyAuthoringPermission.READ) + def post(self, request: Request, course_key: CourseKey): """ Update a course's grading. **Example Request** - PUT /api/contentstore/v1/course_grading/{course_id} + POST /api/contentstore/v1/course_grading/{course_id} **POST Parameters** @@ -162,11 +163,6 @@ def post(self, request: Request, course_id: str): If the request is successful, an HTTP 200 "OK" response is returned, """ - course_key = CourseKey.from_string(course_id) - - if not has_studio_read_access(request.user, course_key): - self.permission_denied(request) - if 'minimum_grade_credit' in request.data: update_credit_course_requirements.delay(str(course_key)) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py index 49fc76850d6e..4de2dd8a16b7 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py @@ -2,20 +2,16 @@ import edx_api_doc_tools as apidocs from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_MANAGE_GROUP_CONFIGURATIONS from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseGroupConfigurationsSerializer from cms.djangoapps.contentstore.utils import get_group_configurations_context -from cms.djangoapps.contentstore.rest_api.v1.serializers import ( - CourseGroupConfigurationsSerializer, -) -from common.djangoapps.student.auth import has_studio_read_access -from openedx.core.lib.api.view_utils import ( - DeveloperErrorViewMixin, - verify_course_exists, - view_auth_classes, -) +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import authz_permission_required +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore @@ -39,7 +35,11 @@ class CourseGroupConfigurationsView(DeveloperErrorViewMixin, APIView): }, ) @verify_course_exists() - def get(self, request: Request, course_id: str): + @authz_permission_required( + authz_permission=COURSES_MANAGE_GROUP_CONFIGURATIONS.identifier, + legacy_permission=LegacyAuthoringPermission.READ + ) + def get(self, request: Request, course_key: CourseKey): """ Get an object containing course's settings group configurations. @@ -139,12 +139,8 @@ def get(self, request: Request, course_id: str): } ``` """ - course_key = CourseKey.from_string(course_id) store = modulestore() - if not has_studio_read_access(request.user, course_key): - self.permission_denied(request) - with store.bulk_operations(course_key): course = modulestore().get_course(course_key) group_configurations_context = get_group_configurations_context(course, store) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/help_urls.py b/cms/djangoapps/contentstore/rest_api/v1/views/help_urls.py index 09798485a37f..bb18b227ee9a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/help_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/help_urls.py @@ -3,6 +3,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView + from openedx.core.lib.api.view_utils import view_auth_classes from ....utils import get_help_urls diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py index f654ab874fad..0c4ea92898fd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py @@ -1,8 +1,8 @@ """ API Views for proctored exam settings and proctoring error """ import copy -from django.conf import settings import edx_api_doc_tools as apidocs +from django.conf import settings from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.exceptions import NotFound @@ -10,17 +10,17 @@ from rest_framework.response import Response from rest_framework.views import APIView -from cms.djangoapps.contentstore.views.course import get_course_and_check_access from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url +from cms.djangoapps.contentstore.views.course import get_course_and_check_access from cms.djangoapps.models.settings.course_metadata import CourseMetadata -from common.djangoapps.student.auth import has_studio_advanced_settings_access -from xmodule.course_block import ( - get_available_providers, - get_requires_escalation_email_providers, -) # lint-amnesty, pylint: disable=wrong-import-order +from common.djangoapps.student.auth import check_course_advanced_settings_access from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.course_block import ( # pylint: disable=wrong-import-order + get_available_providers, + get_requires_escalation_email_providers, +) +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from ..serializers import ( LimitedProctoredExamSettingsSerializer, @@ -260,7 +260,9 @@ def get(self, request: Request, course_id: str) -> Response: ``` """ course_key = CourseKey.from_string(course_id) - if not has_studio_advanced_settings_access(request.user): + if not check_course_advanced_settings_access( + request.user, course_key, access_type='feature_restricted' + ): self.permission_denied(request) course_block = modulestore().get_course(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py index fbb05cba4de2..d5f24fe81ddb 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py @@ -3,17 +3,19 @@ import edx_api_doc_tools as apidocs from django.conf import settings from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from common.djangoapps.student.auth import has_studio_read_access from lms.djangoapps.certificates.api import can_show_certificate_available_date_field +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore -from ..serializers import CourseSettingsSerializer from ....utils import get_course_settings +from ..serializers import CourseSettingsSerializer @view_auth_classes(is_authenticated=True) @@ -99,7 +101,12 @@ def get(self, request: Request, course_id: str): ``` """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ + ): self.permission_denied(request) with modulestore().bulk_operations(course_key): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py index db9d1dacca90..9ae3ba84d8bc 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py @@ -2,10 +2,12 @@ Unit tests for the course's certificate. """ from django.urls import reverse +from openedx_authz.constants.roles import COURSE_EDITOR, COURSE_STAFF from rest_framework import status from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from ...mixins import PermissionAccessMixin @@ -29,10 +31,42 @@ def test_success_response(self): self._add_course_certificates(count=2, signatory_count=2) response = self.client.get(self.url) response_data = response.data - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response_data["certificates"]), 2) - self.assertEqual(len(response_data["certificates"][0]["signatories"]), 2) - self.assertEqual(len(response_data["certificates"][1]["signatories"]), 2) - self.assertEqual(response_data["course_number_override"], self.course.display_coursenumber) - self.assertEqual(response_data["course_title"], self.course.display_name_with_default) - self.assertEqual(response_data["course_number"], self.course.number) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertEqual(len(response_data["certificates"]), 2) # noqa: PT009 + self.assertEqual(len(response_data["certificates"][0]["signatories"]), 2) # noqa: PT009 + self.assertEqual(len(response_data["certificates"][1]["signatories"]), 2) # noqa: PT009 + self.assertEqual(response_data["course_number_override"], self.course.display_coursenumber) # noqa: PT009 + self.assertEqual(response_data["course_title"], self.course.display_name_with_default) # noqa: PT009 + self.assertEqual(response_data["course_number"], self.course.number) # noqa: PT009 + + +class CourseCertificatesAuthzViewTest( + CourseAuthoringAuthzTestMixin, CourseTestCase, PermissionAccessMixin, HelperMethods + ): + """ + Tests for CourseCertificatesView with AuthZ enabled. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:certificates", + kwargs={"course_id": self.course.id}, + ) + + def test_authorized_user_can_access(self): + """User with COURSE_STAFF role can access.""" + self._add_course_certificates(count=2, signatory_count=2) + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access(self): + """ + User without permissions should be denied. + This case validates that a non-staff user cannot access. + """ + self._add_course_certificates(count=2, signatory_count=2) + self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py index cbc2fdc98c3f..d6f404be9a8b 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py @@ -2,13 +2,18 @@ Unit tests for course details views. """ import json -from unittest.mock import patch +from unittest.mock import MagicMock, patch import ddt from django.urls import reverse +from openedx_authz.constants.roles import COURSE_EDITOR, COURSE_STAFF from rest_framework import status +from rest_framework.test import APIClient +from cms.djangoapps.contentstore.rest_api.v1.views.course_details import _classify_update from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from xmodule.modulestore.django import SignalHandler from ...mixins import PermissionAccessMixin @@ -33,18 +38,24 @@ def test_put_permissions_unauthenticated(self): self.client.logout() response = self.client.put(self.url) error = self.get_and_check_developer_response(response) - self.assertEqual(error, "Authentication credentials were not provided.") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(error, "Authentication credentials were not provided.") # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009 def test_put_permissions_unauthorized(self): """ Test that an error is returned if the user is unauthorised. """ client, _ = self.create_non_staff_authed_user_client() - response = client.put(self.url) + pre_requisite_course_keys = [str(self.course.id), "invalid_key"] + request_data = {"pre_requisite_courses": pre_requisite_course_keys} + response = client.put( + path=self.url, + data=json.dumps(request_data), + content_type="application/json", + ) error = self.get_and_check_developer_response(response) - self.assertEqual(error, "You do not have permission to perform this action.") - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(error, "You do not have permission to perform this action.") # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 @patch.dict("django.conf.settings.FEATURES", {"ENABLE_PREREQUISITE_COURSES": True}) def test_put_invalid_pre_requisite_course(self): @@ -55,8 +66,8 @@ def test_put_invalid_pre_requisite_course(self): data=json.dumps(request_data), content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["error"], "Invalid prerequisite course key") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + self.assertEqual(response.json()["error"], "Invalid prerequisite course key") # noqa: PT009 def test_put_course_details(self): request_data = { @@ -110,4 +121,483 @@ def test_put_course_details(self): data=json.dumps(request_data), content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_put_emits_course_published_signal_once(self): + """ + Verify that a single PUT to the course details API emits the + course_published signal exactly once. + + The bulk_operations context inside update_from_json coalesces all + individual update_item / delete_item calls into a single signal + emission. Without it, each call would fire its own signal. + """ + signal_handler = MagicMock() + SignalHandler.course_published.connect(signal_handler) + try: + request_data = { + "overview": "

Updated overview

", + "short_description": "Updated short description", + "effort": "3 hours/week", + "language": "en", + "intro_video": None, + } + # ModuleStoreTestCase disables all signals by default; re-enable + # course_published for this test so we can assert on its emission. + with SignalHandler.course_published.for_state(is_enabled=True): + response = self.client.put( + path=self.url, + data=json.dumps(request_data), + content_type="application/json", + ) + assert response.status_code == status.HTTP_200_OK + signal_handler.assert_called_once() + finally: + SignalHandler.course_published.disconnect(signal_handler) + + +@ddt.ddt +class CourseDetailsAuthzViewTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Tests for CourseDetailsView using AuthZ permissions. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:course_details", + kwargs={"course_id": self.course.id}, + ) + self.request_data = { + "about_sidebar_html": "", + "banner_image_name": "images_course_image.jpg", + "banner_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg", + "certificate_available_date": "2029-01-02T00:00:00Z", + "certificates_display_behavior": "end", + "course_id": "E2E-101", + "course_image_asset_path": "/static/studio/images/pencils.jpg", + "course_image_name": "bar_course_image_name", + "description": "foo_description", + "duration": "", + "effort": None, + "end_date": "2023-08-01T01:30:00Z", + "enrollment_end": "2023-05-30T01:00:00Z", + "enrollment_start": "2023-05-29T01:00:00Z", + "entrance_exam_enabled": "", + "entrance_exam_id": "", + "entrance_exam_minimum_score_pct": "50", + "intro_video": None, + "language": "creative-commons: ver=4.0 BY NC ND", + "learning_info": ["foo", "bar"], + "license": "creative-commons: ver=4.0 BY NC ND", + "org": "edX", + "overview": '
', + "pre_requisite_courses": [], + "run": "course", + "self_paced": None, + "short_description": "", + "start_date": "2023-06-01T01:30:00Z", + "subtitle": "", + "syllabus": None, + "title": "", + "video_thumbnail_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg", + "video_thumbnail_image_name": "images_course_image.jpg", + "instructor_info": { + "instructors": [ + { + "name": "foo bar", + "title": "title", + "organization": "org", + "image": "image", + "bio": "", + } + ] + }, + } + + def test_put_permissions_unauthenticated(self): + """ + Test that an error is returned in the absence of auth credentials. + """ + client = APIClient() # no auth + response = client.put(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009 + + def test_put_permissions_unauthorized(self): + """ + Test that an error is returned if the user is unauthorised. + """ + response = self.unauthorized_client.put( + path=self.url, + data=json.dumps(self.request_data), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_get_course_details_authorized(self): + """ + Authorized user with COURSE_EDITOR role can access course details. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_get_course_details_unauthorized(self): + """ + Unauthorized user should receive 403. + """ + response = self.unauthorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_get_course_details_staff_user(self): + """ + Django staff user should bypass AuthZ and access course details. + """ + response = self.staff_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_get_course_details_super_user(self): + """ + Superuser should bypass AuthZ and access course details. + """ + response = self.super_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + @ddt.data( + # No changes + ({}, (False, False)), + ( + {"certificates_display_behavior": "end"}, # same value as existing course detail + (False, False), + ), + + # Schedule-only fields + ({"start_date": "2023-01-01"}, (True, False)), + ({"end_date": "2023-02-01"}, (True, False)), + ({"enrollment_start": "2023-01-01"}, (True, False)), + ({"enrollment_end": "2023-01-10"}, (True, False)), + + # Details-only fields + ({"title": "New Title"}, (False, True)), + ({"description": "New description"}, (False, True)), + ({"short_description": "Short"}, (False, True)), + ({"overview": "

HTML

"}, (False, True)), + + # Mixed fields + ( + {"title": "New Title", "start_date": "2023-01-01"}, + (True, True) + ), + + # Non-updatable / irrelevant fields + ({"random_field": "value"}, (False, False)), + ) + @ddt.unpack + def test_classify_update(self, payload, expected): + result = _classify_update(payload, self.course.id) + self.assertEqual(result, expected) # noqa: PT009 + + def test_classyfy_update_with_get_request(self): + """ + GET request with no changes should not be classified as schedule or details update. + """ + # Get the current status of the course details to use + # as the basis for the update request + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + expected = (False, False) + result = _classify_update(current_course_details, self.course.id) + self.assertEqual(result, expected) # noqa: PT009 + + def test_course_editor_can_edit_course_details(self): + """ + User with COURSE_EDITOR role can update course details. + COURSE_EDITOR does not have permission to edit schedule fields. + """ + + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + # Get the current status of the course details to use + # as the basis for the update request + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + # Update the course details with new values, + # keeping schedule fields the same to ensure we are only + # testing edit details permission + current_course_details["title"] = "Updated Title" + + response = self.authorized_client.put( + path=self.url, + data=json.dumps(current_course_details), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_course_staff_can_edit_course_schedule(self): + """ + User with COURSE_STAFF role can update course schedule. + Only COURSE_STAFF and COURSE_ADMIN can edit schedule related fields. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_STAFF.external_key, + self.course.id + ) + + # Get the current status of the course details to use + # as the basis for the update request + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + # Update the course details with new values, + # changing schedule fields to ensure we are only + # testing edit schedule permission + current_course_details["end_date"] = "2023-08-01T01:30:00Z" + + response = self.authorized_client.put( + path=self.url, + data=json.dumps(current_course_details), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_course_editor_cannot_edit_course_schedule(self): + """ + User with COURSE_EDITOR role cannot update course schedule. + Only COURSE_STAFF and COURSE_ADMIN can edit schedule-related fields. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + # Get the current status of the course details to use + # as the basis for the update request + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + # Update the course details with new values, + # changing schedule fields to ensure we are only + # testing edit schedule permission + current_course_details["end_date"] = "2023-08-01T01:30:00Z" + + response = self.authorized_client.put( + path=self.url, + data=json.dumps(current_course_details), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_course_staff_can_edit_course_schedule_and_details(self): + """ + User with COURSE_STAFF role can update course + schedule and details. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_STAFF.external_key, + self.course.id + ) + + # Get the current status of the course details to use + # as the basis for the update request + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + # Update the course details with new values, + # changing schedule and details fields to ensure user + # has permission to edit both + current_course_details["end_date"] = "2023-08-01T01:30:00Z" + current_course_details["title"] = "Updated Title" + + response = self.authorized_client.put( + path=self.url, + data=json.dumps(current_course_details), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_course_editor_cannot_edit_course_schedule_and_details(self): + """ + User with COURSE_EDITOR role cannot update course + schedule or course details. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + # Get the current status of the course details to use + # as the basis for the update request + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + # Update the course details with new values, + # changing schedule and details fields to ensure user + # has permission to edit both + current_course_details["end_date"] = "2023-08-01T01:30:00Z" + current_course_details["title"] = "Updated Title" + + response = self.authorized_client.put( + path=self.url, + data=json.dumps(current_course_details), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_unauthorized_user_cannot_edit_with_any_change_on_the_payload(self): + """ + An unauthorized user should receive 403 even if the payload contains + no changes that do not require edit permissions. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + # Get the current status of the course details to use + # as the basis for the update request + response = self.authorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + current_course_details = response.json() + # This field is flagged as a details update because of a type mismatch: + # the GET response returns an invalid string, while the stored value has a different type. + # As a result, the equality check fails even though the values are logically the same. + current_course_details["certificates_display_behavior"] = "end" + + # Update the course details with the same values. + response = self.unauthorized_client.put( + path=self.url, + data=json.dumps(current_course_details), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_put_user_without_role_then_added_can_update(self): + """ + Validate dynamic role assignment works for PUT. + """ + # Initially unauthorized + response = self.unauthorized_client.put( + path=self.url, + data=json.dumps(self.request_data), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + # Assign role dynamically + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_STAFF.external_key, + self.course.id + ) + + response = self.unauthorized_client.put( + path=self.url, + data=json.dumps(self.request_data), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_PREREQUISITE_COURSES": True}) + def test_put_invalid_pre_requisite_course_with_authz(self): + """ + Ensure validation still applies under AuthZ. + """ + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + pre_requisite_course_keys = [str(self.course.id), "invalid_key"] + request_data = {"pre_requisite_courses": pre_requisite_course_keys} + + response = self.authorized_client.put( + path=self.url, + data=json.dumps(request_data), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + self.assertEqual(response.json()["error"], "Invalid prerequisite course key") # noqa: PT009 + + def test_staff_user_can_update_without_authz_role(self): + """ + Django staff user should bypass AuthZ. + """ + response = self.staff_client.put( + path=self.url, + data=json.dumps(self.request_data), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_can_update_without_authz_role(self): + """ + Superuser should bypass AuthZ. + """ + response = self.super_client.put( + path=self.url, + data=json.dumps(self.request_data), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index 310d0ba80adc..e12c3f6e7770 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -4,9 +4,9 @@ from django.conf import settings from django.test import RequestFactory from django.urls import reverse -from rest_framework import status - from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_authz.constants.roles import COURSE_EDITOR +from rest_framework import status from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES from cms.djangoapps.contentstore.rest_api.v1.mixins import PermissionAccessMixin @@ -14,6 +14,7 @@ from cms.djangoapps.contentstore.utils import get_lms_link_for_item, get_pages_and_resources_url from cms.djangoapps.contentstore.views.course import _course_outline_json from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from xmodule.modulestore.tests.factories import BlockFactory, check_mongo_calls @@ -93,8 +94,8 @@ def test_course_index_response(self): 'created_on': None, } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 @override_waffle_flag(CUSTOM_RELATIVE_DATES, active=False) def test_course_index_response_with_show_locators(self): @@ -144,14 +145,14 @@ def test_course_index_response_with_show_locators(self): 'created_on': None, } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 def test_course_index_response_with_invalid_course(self): """Check error response for invalid course id""" response = self.client.get(self.url + "1") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.data, { + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # noqa: PT009 + self.assertEqual(response.data, { # noqa: PT009 "developer_message": f"Unknown course {self.course.id}1", "error_code": "course_does_not_exist" }) @@ -163,3 +164,63 @@ def test_number_of_calls_to_db(self): with self.assertNumQueries(34, table_ignorelist=WAFFLE_TABLES): with check_mongo_calls(3): self.client.get(self.url) + + +class CourseIndexAuthzViewTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Tests for CourseIndexView using AuthZ permissions. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:course_index", + kwargs={"course_id": self.course.id}, + ) + + def test_authorized_user_can_access_course_index(self): + """Authorized user with COURSE_EDITOR role can access course index.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.authorized_client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in response.data + + def test_unauthorized_user_cannot_access_course_index(self): + """Unauthorized user should receive 403.""" + response = self.unauthorized_client.get(self.url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_user_without_role_then_added_can_access(self): + """Validate dynamic role assignment works as expected.""" + response = self.unauthorized_client.get(self.url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.unauthorized_client.get(self.url) + assert response.status_code == status.HTTP_200_OK + + def test_staff_user_can_access_without_authz_role(self): + """Django staff user should access without AuthZ role.""" + response = self.staff_client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in response.data + + def test_superuser_can_access_without_authz_role(self): + """Superuser should access without AuthZ role.""" + response = self.super_client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in response.data diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py index e25904ad465f..f0e970c41c7c 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py @@ -4,8 +4,8 @@ from django.urls import reverse from rest_framework import status -from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.rest_api.v1.mixins import PermissionAccessMixin +from cms.djangoapps.contentstore.tests.utils import CourseTestCase class CourseRerunViewTest(CourseTestCase, PermissionAccessMixin): @@ -32,5 +32,5 @@ def test_course_rerun_response(self): "run": self.course.id.run, } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_team.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_team.py index c0abca08196c..2fb46dd47ea9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_team.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_team.py @@ -5,9 +5,9 @@ from django.urls import reverse from rest_framework import status +from cms.djangoapps.contentstore.tests.utils import CourseTestCase from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory -from cms.djangoapps.contentstore.tests.utils import CourseTestCase from ...mixins import PermissionAccessMixin @@ -65,8 +65,8 @@ def test_course_team_response(self): response = self.client.get(self.url) expected_response = self.get_expected_course_data() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 def test_users_response(self): """Test the response for users in the course.""" @@ -74,5 +74,5 @@ def test_users_response(self): response = self.client.get(self.url) users_response = [dict(item) for item in response.data["users"]] expected_response = self.get_expected_course_data(instructor, staff) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertListEqual(expected_response["users"], users_response) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertListEqual(expected_response["users"], users_response) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py index f45cc48810d6..39783e191da4 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py @@ -35,9 +35,11 @@ class CourseWaffleFlagsViewTest(CourseTestCase): "use_new_unit_page": True, "use_new_updates_page": True, "use_new_video_uploads_page": False, + "use_new_pdf_editor": True, "use_react_markdown_editor": False, "use_video_gallery_flow": False, "enable_course_optimizer_check_prev_run_links": False, + "enable_authz_course_authoring": False, } def setUp(self): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py index 6123096048df..bc7bd17e1757 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py @@ -6,11 +6,16 @@ import ddt from django.urls import reverse +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_STAFF from rest_framework import status +from rest_framework.test import APIClient +from cms.djangoapps.contentstore.api.tests.base import BaseCourseViewTest from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url from cms.djangoapps.models.settings.course_grading import CourseGradingModel +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory from ...mixins import PermissionAccessMixin @@ -45,8 +50,8 @@ def test_course_grading_response(self): "default_grade_designations": ['A', 'B', 'C', 'D'], } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 @patch("django.conf.settings.DEFAULT_GRADE_DESIGNATIONS", ['A', 'B']) def test_default_grade_designations_setting(self): @@ -55,8 +60,8 @@ def test_default_grade_designations_setting(self): """ response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(['A', 'B'], response.data["default_grade_designations"]) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertEqual(['A', 'B'], response.data["default_grade_designations"]) # noqa: PT009 @patch.dict("django.conf.settings.FEATURES", {"ENABLE_CREDIT_ELIGIBILITY": True}) def test_credit_eligibility_setting(self): @@ -65,9 +70,9 @@ def test_credit_eligibility_setting(self): """ _ = CreditCourseFactory(course_key=self.course.id, enabled=True) response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(response.data["show_credit_eligibility"]) - self.assertTrue(response.data["is_credit_course"]) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertTrue(response.data["show_credit_eligibility"]) # noqa: PT009 + self.assertTrue(response.data["is_credit_course"]) # noqa: PT009 def test_post_permissions_unauthenticated(self): """ @@ -76,8 +81,8 @@ def test_post_permissions_unauthenticated(self): self.client.logout() response = self.client.post(self.url) error = self.get_and_check_developer_response(response) - self.assertEqual(error, "Authentication credentials were not provided.") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(error, "Authentication credentials were not provided.") # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # noqa: PT009 def test_post_permissions_unauthorized(self): """ @@ -86,8 +91,8 @@ def test_post_permissions_unauthorized(self): client, _ = self.create_non_staff_authed_user_client() response = client.post(self.url) error = self.get_and_check_developer_response(response) - self.assertEqual(error, "You do not have permission to perform this action.") - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(error, "You do not have permission to perform this action.") # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 @patch( "openedx.core.djangoapps.credit.tasks.update_credit_course_requirements.delay" @@ -115,5 +120,151 @@ def test_post_course_grading(self, mock_update_credit_course_requirements): data=json.dumps(request_data), content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 mock_update_credit_course_requirements.assert_called_once() + + +class CourseGradingViewAuthzTest(CourseAuthzTestMixin, BaseCourseViewTest): + """ + Tests Course Grading Configuration API authorization using openedx-authz. + The endpoint uses COURSES_VIEW_GRADING_SETTINGS and COURSES_EDIT_GRADING_SETTINGS permissions. + """ + + view_name = "cms.djangoapps.contentstore:v1:course_grading" + authz_roles_to_assign = [COURSE_STAFF.external_key] + post_data = json.dumps({ + "graders": [{ + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "", + "weight": 100, + "id": 0 + }], + "grade_cutoffs": {"A": 0.75, "B": 0.63, "C": 0.57, "D": 0.5}, + "grace_period": {"hours": 12, "minutes": 0}, + "minimum_grade_credit": 0.7, + "is_credit_course": False, + }) + + def test_authorized_user_can_access_get(self): + """User with COURSE_STAFF role can access.""" + resp = self.authorized_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_unauthorized_user_cannot_access_get(self): + """User without role cannot access.""" + resp = self.unauthorized_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_role_scoped_to_course_get(self): + """Authorization should only apply to the assigned course.""" + other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id) + + resp = self.authorized_client.get(self.get_url(other_course.id)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_staff_user_allowed_via_legacy_get(self): + """ + Staff users should still pass through legacy fallback. + """ + self.client.login(username=self.staff.username, password=self.password) + + resp = self.client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_allowed_get(self): + """Superusers should always be allowed.""" + superuser = UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + resp = client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access_get(self): + """ + User without required permissions should be denied. + This case validates that a non-staff user doesn't get access. + """ + non_staff_user = UserFactory() + non_staff_client = APIClient() + self.add_user_to_role(non_staff_user, COURSE_DATA_RESEARCHER.external_key) + non_staff_client.force_authenticate(user=non_staff_user) + + resp = non_staff_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_authorized_user_can_access_post(self): + """User with COURSE_STAFF role can access.""" + resp = self.authorized_client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_unauthorized_user_cannot_access_post(self): + """User without role cannot access.""" + resp = self.unauthorized_client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_role_scoped_to_course_post(self): + """Authorization should only apply to the assigned course.""" + other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id) + + resp = self.authorized_client.post( + self.get_url(other_course.id), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_staff_user_allowed_via_legacy_post(self): + """ + Staff users should still pass through legacy fallback. + """ + self.client.login(username=self.staff.username, password=self.password) + + resp = self.client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_allowed_post(self): + """Superusers should always be allowed.""" + superuser = UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + resp = client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access_post(self): + """ + User without required permissions should be denied. + This case validates that a non-staff user doesn't get access. + """ + non_staff_user = UserFactory() + non_staff_client = APIClient() + self.add_user_to_role(non_staff_user, COURSE_DATA_RESEARCHER.external_key) + non_staff_client.force_authenticate(user=non_staff_user) + + resp = non_staff_client.post( + self.get_url(self.course_key), + data=self.post_data, + content_type="application/json" + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py index 1cc548378301..3db602dc42f3 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py @@ -2,16 +2,16 @@ Unit tests for the course's setting group configuration. """ from django.urls import reverse +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_STAFF from rest_framework import status +from rest_framework.test import APIClient -from cms.djangoapps.contentstore.course_group_config import ( - CONTENT_GROUP_CONFIGURATION_NAME, -) +from cms.djangoapps.contentstore.api.tests.base import BaseCourseViewTest +from cms.djangoapps.contentstore.course_group_config import CONTENT_GROUP_CONFIGURATION_NAME from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from xmodule.partitions.partitions import ( - Group, - UserPartition, -) # lint-amnesty, pylint: disable=wrong-import-order +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin +from xmodule.partitions.partitions import Group, UserPartition # pylint: disable=wrong-import-order from ...mixins import PermissionAccessMixin @@ -38,7 +38,7 @@ def test_success_response(self): "First name", "First description", [Group(0, "Group A"), Group(1, "Group B"), Group(2, "Group C")], - ), # lint-amnesty, pylint: disable=line-too-long + ), # pylint: disable=line-too-long ] self.save_course() @@ -47,9 +47,67 @@ def test_success_response(self): self.store.update_item(self.course, self.user.id) response = self.client.get(self.url) - self.assertEqual(len(response.data["all_group_configurations"]), 1) - self.assertEqual(len(response.data["experiment_group_configurations"]), 1) + self.assertEqual(len(response.data["all_group_configurations"]), 1) # noqa: PT009 + self.assertEqual(len(response.data["experiment_group_configurations"]), 1) # noqa: PT009 self.assertContains(response, "First name", count=1) self.assertContains(response, "Group C") self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + +class CourseGroupConfigurationsAuthzTest(CourseAuthzTestMixin, BaseCourseViewTest): + """ + Tests Course Group Configuration API authorization using openedx-authz. + The endpoint uses COURSES_MANAGE_GROUP_CONFIGURATIONS permission. + """ + + view_name = "cms.djangoapps.contentstore:v1:group_configurations" + authz_roles_to_assign = [COURSE_STAFF.external_key] + + def test_authorized_user_can_access(self): + """User with COURSE_STAFF role can access.""" + resp = self.authorized_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_unauthorized_user_cannot_access(self): + """User without role cannot access.""" + resp = self.unauthorized_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_role_scoped_to_course(self): + """Authorization should only apply to the assigned course.""" + other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id) + + resp = self.authorized_client.get(self.get_url(other_course.id)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_staff_user_allowed_via_legacy(self): + """ + Staff users should still pass through legacy fallback. + """ + self.client.login(username=self.staff.username, password=self.password) + + resp = self.client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_superuser_allowed(self): + """Superusers should always be allowed.""" + superuser = UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + resp = client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009 + + def test_non_staff_user_cannot_access(self): + """ + User without required permissions should be denied. + This case validates that a non-staff user doesn't get access. + """ + non_staff_user = UserFactory() + non_staff_client = APIClient() + self.add_user_to_role(non_staff_user, COURSE_DATA_RESEARCHER.external_key) + non_staff_client.force_authenticate(user=non_staff_user) + + resp = non_staff_client.get(self.get_url(self.course_key)) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index 72d58fa00dfa..ccdd906609a4 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -10,7 +10,7 @@ from django.test import override_settings from django.urls import reverse from opaque_keys.edx.locator import LibraryLocatorV2 -from openedx_learning.api import authoring as authoring_api +from openedx_content import api as content_api from organizations.tests.factories import OrganizationFactory from rest_framework import status @@ -66,8 +66,8 @@ def test_home_page_studio_response(self): """Check successful response content""" response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(self.expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(self.expected_response, response.data) # noqa: PT009 @override_settings(MEILISEARCH_ENABLED=True) def test_home_page_studio_with_meilisearch_enabled(self): @@ -77,8 +77,8 @@ def test_home_page_studio_with_meilisearch_enabled(self): expected_response = self.expected_response expected_response["libraries_v2_enabled"] = True - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 @override_settings(ORGANIZATIONS_AUTOCREATE=False) def test_home_page_studio_with_org_autocreate_disabled(self): @@ -88,13 +88,13 @@ def test_home_page_studio_with_org_autocreate_disabled(self): expected_response = self.expected_response expected_response["allow_to_create_new_org"] = False - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 def test_taxonomy_list_link(self): response = self.client.get(self.url) - self.assertTrue(response.data['taxonomies_enabled']) - self.assertEqual( + self.assertTrue(response.data['taxonomies_enabled']) # noqa: PT009 + self.assertEqual( # noqa: PT009 response.data['taxonomy_list_mfe_url'], f'{settings.COURSE_AUTHORING_MICROFRONTEND_URL}/taxonomies' ) @@ -137,8 +137,8 @@ def test_home_page_response(self): "in_process_course_actions": [], } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 def test_home_page_response_with_api_v2(self): """Check successful response content with api v2 modifications. @@ -166,8 +166,8 @@ def test_home_page_response_with_api_v2(self): response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 @ddt.data( ("active_only", "true", 2, 0), @@ -207,9 +207,9 @@ def test_filter_and_ordering_courses( response = self.client.get(self.url, {filter_key: filter_value}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["archived_courses"]), expected_archived_length) - self.assertEqual(len(response.data["courses"]), expected_active_length) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertEqual(len(response.data["archived_courses"]), expected_archived_length) # noqa: PT009 + self.assertEqual(len(response.data["courses"]), expected_active_length) # noqa: PT009 @ddt.data( ("active_only", "true"), @@ -224,8 +224,8 @@ def test_filter_and_ordering_no_courses_staff(self, filter_key, filter_value): response = self.client.get(self.url, {filter_key: filter_value}) - self.assertEqual(len(response.data["courses"]), 0) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["courses"]), 0) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 @ddt.data( ("active_only", "true"), @@ -240,8 +240,8 @@ def test_home_page_response_no_courses_non_staff(self, filter_key, filter_value) response = self.non_staff_client.get(self.url, {filter_key: filter_value}) - self.assertEqual(len(response.data["courses"]), 0) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["courses"]), 0) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 @ddt.ddt @@ -272,9 +272,9 @@ def setUp(self): self.url = reverse("cms.djangoapps.contentstore:v1:libraries") # Create a collection to migrate this library to collection_key = "test-collection" - authoring_api.create_collection( + content_api.create_collection( learning_package_id=learning_package.id, - key=collection_key, + collection_code=collection_key, title="Test Collection", created_by=self.user.id, ) @@ -329,7 +329,7 @@ def test_home_page_libraries_response(self): 'can_edit': True, 'is_migrated': True, 'migrated_to_title': 'Test Library', - 'migrated_to_key': 'lib:name0:test-key', + 'migrated_to_key': str(self.lib_key_v2), 'migrated_to_collection_key': 'test-collection', 'migrated_to_collection_title': 'Test Collection', }, @@ -347,8 +347,8 @@ def test_home_page_libraries_response(self): ] } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.json()) # noqa: PT009 # Fetch legacy libraries that were migrated to v2 response = self.client.get(self.url + '?is_migrated=true') @@ -364,15 +364,15 @@ def test_home_page_libraries_response(self): 'can_edit': True, 'is_migrated': True, 'migrated_to_title': 'Test Library', - 'migrated_to_key': 'lib:name0:test-key', + 'migrated_to_key': str(self.lib_key_v2), 'migrated_to_collection_key': 'test-collection', 'migrated_to_collection_title': 'Test Collection', } ], } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.json()) # noqa: PT009 # Fetch legacy libraries that were not migrated to v2 response = self.client.get(self.url + '?is_migrated=false') @@ -400,5 +400,5 @@ def test_home_page_libraries_response(self): ], } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.json()) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py index 9eabf03f1b41..6d84fad453d0 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py @@ -13,13 +13,13 @@ from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from openedx.core import toggles as core_toggles from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA -from xmodule.modulestore.django import ( - modulestore, -) # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ( +from xmodule.course_metadata_utils import DEFAULT_START_DATE +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( # pylint: disable=wrong-import-order ModuleStoreTestCase, -) # lint-amnesty, pylint: disable=wrong-import-order +) from ...mixins import PermissionAccessMixin @@ -60,7 +60,7 @@ def get_expected_response_data( "proctoring_escalation_email": course.proctoring_escalation_email, "create_zendesk_tickets": course.create_zendesk_tickets, }, - "course_start_date": "2030-01-01T00:00:00Z", + "course_start_date": DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ'), "available_proctoring_providers": ["null"], "requires_escalation_email_providers": [], } @@ -99,7 +99,7 @@ def test_providers_with_disabled_lti(self): "proctoring_escalation_email": self.course.proctoring_escalation_email, "create_zendesk_tickets": self.course.create_zendesk_tickets, }, - "course_start_date": "2030-01-01T00:00:00Z", + "course_start_date": DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ'), "available_proctoring_providers": ["null"], "requires_escalation_email_providers": [], } @@ -122,7 +122,7 @@ def test_providers_with_enabled_lti(self): "proctoring_escalation_email": self.course.proctoring_escalation_email, "create_zendesk_tickets": self.course.create_zendesk_tickets, }, - "course_start_date": "2030-01-01T00:00:00Z", + "course_start_date": DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ'), "available_proctoring_providers": ["lti_external", "null"], "requires_escalation_email_providers": ["lti_external"], } @@ -182,7 +182,7 @@ def test_update_exam_settings_200_escalation_email(self): # response is correct assert response.status_code == status.HTTP_200_OK - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 response.data, { "proctored_exam_settings": { @@ -219,7 +219,7 @@ def test_update_exam_settings_200_no_escalation_email(self): # response is correct assert response.status_code == status.HTTP_200_OK - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 response.data, { "proctored_exam_settings": { @@ -250,7 +250,7 @@ def test_update_exam_settings_excluded_field(self): # response is correct assert response.status_code == status.HTTP_200_OK - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 response.data, { "proctored_exam_settings": { @@ -283,7 +283,7 @@ def test_update_exam_settings_invalid_value(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertIn( + self.assertIn( # noqa: PT009 { "proctoring_provider": ( "The selected proctoring provider, notvalidprovider, is not a valid provider. " @@ -350,7 +350,7 @@ def test_nonadmin_with_zendesk_ticket( assert response.status_code == status.HTTP_200_OK if expect_log: logger_string = ( - "create_zendesk_tickets set to {ticket_value} but proctoring " + "create_zendesk_tickets set to {ticket_value} but proctoring " # noqa: UP032 "provider is {provider} for course {course_id}. create_zendesk_tickets " "should be updated for this course.".format( ticket_value=create_zendesk_tickets, @@ -378,7 +378,7 @@ def test_200_for_lti_provider(self): # response is correct assert response.status_code == status.HTTP_200_OK - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 response.data, { "proctored_exam_settings": { @@ -410,7 +410,7 @@ def test_400_for_disabled_lti(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertIn( + self.assertIn( # noqa: PT009 { "proctoring_provider": ( "The selected proctoring provider, lti_external, is not a valid provider. " @@ -450,6 +450,42 @@ def test_disable_advanced_settings_feature(self, disable_advanced_settings): FEATURES={"DISABLE_ADVANCED_SETTINGS": disable_advanced_settings} ): response = self.non_staff_client.get(self.url) - self.assertEqual( + self.assertEqual( # noqa: PT009 response.status_code, 403 if disable_advanced_settings else 200 ) + + @patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True) + @patch('common.djangoapps.student.auth.authz_api.is_user_allowed') + def test_authz_user_allowed(self, mock_is_user_allowed, mock_flag): + """User with authz permission can access proctoring errors.""" + mock_is_user_allowed.return_value = True + response = self.non_staff_client.get(self.url) + self.assertEqual(response.status_code, 200) # noqa: PT009 + mock_is_user_allowed.assert_called_once() + + @patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True) + @patch('common.djangoapps.student.auth.authz_api.is_user_allowed') + def test_authz_user_not_allowed(self, mock_is_user_allowed, mock_flag): + """User without authz permission cannot access proctoring errors.""" + mock_is_user_allowed.return_value = False + response = self.non_staff_client.get(self.url) + self.assertEqual(response.status_code, 403) # noqa: PT009 + mock_is_user_allowed.assert_called_once() + + @patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True) + @patch('common.djangoapps.student.auth.authz_api.is_user_allowed') + def test_authz_with_disable_advanced_settings_staff_allowed(self, mock_is_user_allowed, mock_flag): + """Staff user can access when DISABLE_ADVANCED_SETTINGS is enabled, bypassing authz.""" + with override_settings(FEATURES={"DISABLE_ADVANCED_SETTINGS": True}): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) # noqa: PT009 + mock_is_user_allowed.assert_not_called() + + @patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True) + @patch('common.djangoapps.student.auth.authz_api.is_user_allowed') + def test_authz_with_disable_advanced_settings_non_staff_denied(self, mock_is_user_allowed, mock_flag): + """Non-staff user is denied when DISABLE_ADVANCED_SETTINGS is enabled, bypassing authz.""" + with override_settings(FEATURES={"DISABLE_ADVANCED_SETTINGS": True}): + response = self.non_staff_client.get(self.url) + self.assertEqual(response.status_code, 403) # noqa: PT009 + mock_is_user_allowed.assert_not_called() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py index 15b0992fdf1a..83ee03294790 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py @@ -6,11 +6,13 @@ import ddt from django.conf import settings from django.urls import reverse +from openedx_authz.constants.roles import COURSE_EDITOR from rest_framework import status from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url from common.djangoapps.util.course import get_link_for_about_page +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory from ...mixins import PermissionAccessMixin @@ -57,8 +59,8 @@ def test_course_settings_response(self): "licensing_enabled": False, } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 @patch.dict("django.conf.settings.FEATURES", {"ENABLE_CREDIT_ELIGIBILITY": True}) def test_credit_eligibility_setting(self): @@ -67,9 +69,9 @@ def test_credit_eligibility_setting(self): """ _ = CreditCourseFactory(course_key=self.course.id, enabled=True) response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("credit_requirements", response.data) - self.assertTrue(response.data["is_credit_course"]) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertIn("credit_requirements", response.data) # noqa: PT009 + self.assertTrue(response.data["is_credit_course"]) # noqa: PT009 @patch.dict( "django.conf.settings.FEATURES", @@ -83,5 +85,80 @@ def test_prerequisite_courses_enabled_setting(self): Make sure if the feature flags are enabled we have updated the dict keys in response. """ response = self.client.get(self.url) - self.assertIn("possible_pre_requisite_courses", response.data) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("possible_pre_requisite_courses", response.data) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + +@ddt.ddt +class CourseSettingsAuthzViewTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Tests for CourseSettingsView using AuthZ permissions. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:course_settings", + kwargs={"course_id": self.course.id}, + ) + + def test_authorized_user_can_access_course_settings(self): + """Authorized user with COURSE_EDITOR role can access course settings.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id) + response = self.authorized_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertIn("course_display_name", response.data) # noqa: PT009 + + def test_unauthorized_user_cannot_access_course_settings(self): + """Unauthorized user should receive 403.""" + response = self.unauthorized_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + def test_user_without_role_then_added_can_access(self): + """ + Validate dynamic role assignment works as expected. + """ + # Initially unauthorized + response = self.unauthorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + # Assign role dynamically + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.unauthorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_CREDIT_ELIGIBILITY": True}) + def test_credit_eligibility_setting_with_authz(self): + """ + Ensure feature flags still affect response under AuthZ. + """ + _ = CreditCourseFactory(course_key=self.course.id, enabled=True) + + self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id) + response = self.authorized_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertIn("credit_requirements", response.data) # noqa: PT009 + self.assertTrue(response.data["is_credit_course"]) # noqa: PT009 + + def test_staff_user_can_access_without_authz_role(self): + """Django staff user should access course settings without AuthZ role.""" + + response = self.staff_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertIn("course_display_name", response.data) # noqa: PT009 + + def test_superuser_can_access_without_authz_role(self): + """Superuser should access course settings without AuthZ role.""" + response = self.super_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertIn("course_display_name", response.data) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py index d4dd80f8ab05..26ec42f839a3 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py @@ -2,9 +2,12 @@ Unit tests for the course's textbooks. """ from django.urls import reverse -from rest_framework import status +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_LIMITED_STAFF, COURSE_STAFF +from rest_framework.test import APIClient from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from ...mixins import PermissionAccessMixin @@ -39,5 +42,44 @@ def test_success_response(self): self.save_course() response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["textbooks"], expected_textbook) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertEqual(response.data["textbooks"], expected_textbook) # noqa: PT009 + + +class CourseTextbooksAuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Integration tests for CourseTextbooksView authz permissions. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:textbooks", + kwargs={"course_id": self.course.id}, + ) + + def test_staff_can_view_textbooks(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + assert resp.status_code == 200 + + def test_auditor_can_view_textbooks(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + assert resp.status_code == 200 + + def test_limited_staff_cannot_view_textbooks(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_LIMITED_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + assert resp.status_code == 403 + + def test_unauthorized_cannot_view_textbooks(self): + resp = self.unauthorized_client.get(self.url) + assert resp.status_code == 403 + + def test_superuser_can_view_textbooks(self): + superuser = UserFactory(is_superuser=True) + client = APIClient() + client.force_authenticate(user=superuser) + resp = client.get(self.url) + assert resp.status_code == 200 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index a7cf3a452627..9ef86157e463 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -5,27 +5,17 @@ from urllib.parse import quote from django.urls import reverse -from rest_framework import status from edx_toggles.toggles.testutils import override_waffle_flag +from rest_framework import status from xblock.validation import ValidationMessage from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from openedx.core.djangoapps.content_tagging.toggles import DISABLE_TAGGING_FEATURE from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest -from xmodule.partitions.partitions import ( - ENROLLMENT_TRACK_PARTITION_ID, - Group, - UserPartition, -) -from xmodule.modulestore.django import ( - modulestore, -) # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import ( - BlockFactory, -) # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ( - ModuleStoreEnum, -) # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.content_tagging.toggles import DISABLE_TAGGING_FEATURE +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import BlockFactory # pylint: disable=wrong-import-order +from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID, Group, UserPartition class BaseXBlockContainer(CourseTestCase, ContentLibrariesRestApiTest): @@ -148,7 +138,7 @@ def test_success_response(self): """ url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 def test_ancestor_xblocks_response(self): """ @@ -194,7 +184,7 @@ def test_ancestor_xblocks_response(self): def sort_key(block): return block.get("title", "") - self.assertEqual( + self.assertEqual( # noqa: PT009 sorted(response_ancestor_xblocks, key=sort_key), sorted(expected_ancestor_xblocks, key=sort_key) ) @@ -208,7 +198,7 @@ def test_not_valid_usage_key_string(self): ) url = self.get_reverse_url(usage_key_string) response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # noqa: PT009 class ContainerVerticalViewTest(BaseXBlockContainer): @@ -224,13 +214,13 @@ def test_success_response(self): """ url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 data = response.json() - self.assertEqual(len(data["children"]), 2) - self.assertFalse(data["is_published"]) - self.assertTrue(data["can_paste_component"]) - self.assertEqual(data["display_name"], "Unit") - self.assertEqual(data["upstream_ready_to_sync_children_info"], []) + self.assertEqual(len(data["children"]), 2) # noqa: PT009 + self.assertFalse(data["is_published"]) # noqa: PT009 + self.assertTrue(data["can_paste_component"]) # noqa: PT009 + self.assertEqual(data["display_name"], "Unit") # noqa: PT009 + self.assertEqual(data["upstream_ready_to_sync_children_info"], []) # noqa: PT009 def test_success_response_with_upstream_info(self): """ @@ -238,13 +228,13 @@ def test_success_response_with_upstream_info(self): """ url = self.get_reverse_url(self.vertical.location) response = self.client.get(f"{url}?get_upstream_info=true") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 data = response.json() - self.assertEqual(len(data["children"]), 2) - self.assertFalse(data["is_published"]) - self.assertTrue(data["can_paste_component"]) - self.assertEqual(data["display_name"], "Unit") - self.assertEqual(data["upstream_ready_to_sync_children_info"], [{ + self.assertEqual(len(data["children"]), 2) # noqa: PT009 + self.assertFalse(data["is_published"]) # noqa: PT009 + self.assertTrue(data["can_paste_component"]) # noqa: PT009 + self.assertEqual(data["display_name"], "Unit") # noqa: PT009 + self.assertEqual(data["upstream_ready_to_sync_children_info"], [{ # noqa: PT009 "id": str(self.html_unit_second.usage_key), "upstream": self.html_block["id"], "block_type": "html", @@ -259,7 +249,7 @@ def test_xblock_is_published(self): self.publish_item(self.store, self.vertical.location) url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) - self.assertTrue(response.data["is_published"]) + self.assertTrue(response.data["is_published"]) # noqa: PT009 def test_children_content(self): """ @@ -323,7 +313,7 @@ def test_children_content(self): "version_declined": None, "error_message": None, "ready_to_sync": True, - "has_top_level_parent": False, + "top_level_parent_key": None, "downstream_customized": [], }, "user_partition_info": expected_user_partition_info, @@ -334,7 +324,7 @@ def test_children_content(self): ] self.maxDiff = None # Using json() shows meaningful diff in case of error - self.assertEqual(response.json()["children"], expected_response) + self.assertEqual(response.json()["children"], expected_response) # noqa: PT009 def test_not_valid_usage_key_string(self): """ @@ -345,7 +335,7 @@ def test_not_valid_usage_key_string(self): ) url = self.get_reverse_url(usage_key_string) response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # noqa: PT009 @override_waffle_flag(DISABLE_TAGGING_FEATURE, True) def test_actions_with_turned_off_taxonomy_flag(self): @@ -355,7 +345,7 @@ def test_actions_with_turned_off_taxonomy_flag(self): url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) for children in response.data["children"]: - self.assertFalse(children["actions"]["can_manage_tags"]) + self.assertFalse(children["actions"]["can_manage_tags"]) # noqa: PT009 def test_validation_errors(self): """ @@ -391,7 +381,7 @@ def test_validation_errors(self): children_response = response.data["children"] # Verify that html_unit_first access settings contradict its parent's access settings. - self.assertEqual(children_response[0]["validation_messages"][0]["type"], ValidationMessage.ERROR) + self.assertEqual(children_response[0]["validation_messages"][0]["type"], ValidationMessage.ERROR) # noqa: PT009 # Verify that html_unit_second has no validation messages. - self.assertFalse(children_response[1]["validation_messages"]) + self.assertFalse(children_response[1]["validation_messages"]) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_videos.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_videos.py index 1d086cb9fda0..252a70764dcf 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_videos.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_videos.py @@ -1,24 +1,29 @@ """ Unit tests for course settings views. """ -from unittest.mock import patch +from datetime import datetime +from unittest.mock import MagicMock, patch import ddt +import pytz from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.urls import reverse from edx_toggles.toggles import WaffleSwitch from edx_toggles.toggles.testutils import override_waffle_switch from edxval.api import ( + create_profile, + create_video, get_3rd_party_transcription_plans, get_transcript_credentials_state_for_org, get_transcript_preferences, ) from rest_framework import status +from rest_framework.test import APIClient -from cms.djangoapps.contentstore.video_storage_handlers import get_all_transcript_languages from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url +from cms.djangoapps.contentstore.video_storage_handlers import get_all_transcript_languages from ...mixins import PermissionAccessMixin @@ -73,10 +78,10 @@ def test_course_videos_response(self): "pagination_context": {} } - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 - @override_waffle_switch(WaffleSwitch( # lint-amnesty, pylint: disable=toggle-missing-annotation + @override_waffle_switch(WaffleSwitch( # pylint: disable=toggle-missing-annotation 'videos.video_image_upload_enabled', __name__ ), True) def test_video_image_upload_enabled(self): @@ -84,12 +89,12 @@ def test_video_image_upload_enabled(self): Make sure if the feature flag is enabled we have updated the dict keys in response. """ response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("video_image_settings", response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertIn("video_image_settings", response.data) # noqa: PT009 imageSettings = response.data["video_image_settings"] - self.assertIn("video_image_upload_enabled", imageSettings) - self.assertTrue(imageSettings["video_image_upload_enabled"]) + self.assertIn("video_image_upload_enabled", imageSettings) # noqa: PT009 + self.assertTrue(imageSettings["video_image_upload_enabled"]) # noqa: PT009 def test_VideoTranscriptEnabledFlag_enabled(self): """ @@ -98,40 +103,143 @@ def test_VideoTranscriptEnabledFlag_enabled(self): with patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') as feature: feature.return_value = True response = self.client.get(self.url) - self.assertIn("is_video_transcript_enabled", response.data) - self.assertTrue(response.data["is_video_transcript_enabled"]) + self.assertIn("is_video_transcript_enabled", response.data) # noqa: PT009 + self.assertTrue(response.data["is_video_transcript_enabled"]) # noqa: PT009 expect_active_preferences = get_transcript_preferences(str(self.course.id)) - self.assertIn("active_transcript_preferences", response.data) - self.assertEqual(expect_active_preferences, response.data["active_transcript_preferences"]) + self.assertIn("active_transcript_preferences", response.data) # noqa: PT009 + self.assertEqual(expect_active_preferences, response.data["active_transcript_preferences"]) # noqa: PT009 expected_credentials = get_transcript_credentials_state_for_org(self.course.id.org) - self.assertIn("transcript_credentials", response.data) - self.assertDictEqual(expected_credentials, response.data["transcript_credentials"]) + self.assertIn("transcript_credentials", response.data) # noqa: PT009 + self.assertDictEqual(expected_credentials, response.data["transcript_credentials"]) # noqa: PT009 transcript_settings = response.data["video_transcript_settings"] expected_plans = get_3rd_party_transcription_plans() - self.assertIn("transcription_plans", transcript_settings) - self.assertDictEqual(expected_plans, transcript_settings["transcription_plans"]) + self.assertIn("transcription_plans", transcript_settings) # noqa: PT009 + self.assertDictEqual(expected_plans, transcript_settings["transcription_plans"]) # noqa: PT009 expected_preference_handler = reverse_course_url( 'transcript_preferences_handler', str(self.course.id) ) - self.assertIn("transcript_preferences_handler_url", transcript_settings) - self.assertEqual(expected_preference_handler, transcript_settings["transcript_preferences_handler_url"]) + self.assertIn("transcript_preferences_handler_url", transcript_settings) # noqa: PT009 + self.assertEqual(expected_preference_handler, transcript_settings["transcript_preferences_handler_url"]) # noqa: PT009 # pylint: disable=line-too-long expected_credentials_handler = reverse_course_url( 'transcript_credentials_handler', str(self.course.id) ) - self.assertIn("transcript_credentials_handler_url", transcript_settings) - self.assertEqual(expected_credentials_handler, transcript_settings["transcript_credentials_handler_url"]) + self.assertIn("transcript_credentials_handler_url", transcript_settings) # noqa: PT009 + self.assertEqual(expected_credentials_handler, transcript_settings["transcript_credentials_handler_url"]) # noqa: PT009 # pylint: disable=line-too-long with patch( 'openedx.core.djangoapps.video_config.toggles.XPERT_TRANSLATIONS_UI.is_enabled' ) as xpertTranslationfeature: xpertTranslationfeature.return_value = True response = self.client.get(self.url) - self.assertIn("is_ai_translations_enabled", response.data) - self.assertTrue(response.data["is_ai_translations_enabled"]) + self.assertIn("is_ai_translations_enabled", response.data) # noqa: PT009 + self.assertTrue(response.data["is_ai_translations_enabled"]) # noqa: PT009 + + +class VideoDownloadViewTest(CourseTestCase): + """ + Tests for VideoDownloadView. + + The download endpoint fetches each requested ``files[].url`` server-side and + returns the bytes inside a zip. Those URLs must therefore be restricted to + the course's own video URLs, otherwise the endpoint is an SSRF primitive + (see GHSA-fpf9-9rpr-jvrx). + """ + + ALLOWED_URL = "http://example.com/profile1/test.mp4" + # An internal address an attacker might try to reach via SSRF. + SSRF_URL = "http://169.254.169.254/latest/meta-data/" + + def setUp(self): + super().setUp() + # reverse() with only course_id resolves to the download route (the + # usage route with the same name additionally requires edx_video_id). + self.url = reverse( + "cms.djangoapps.contentstore:v1:video_usage", + kwargs={"course_id": self.course.id}, + ) + self.api_client = APIClient() + self.api_client.force_authenticate(user=self.user) + create_profile("profile1") + create_video({ + "edx_video_id": "test-video", + "client_video_id": "test.mp4", + "duration": 42.0, + "status": "file_complete", + "courses": [str(self.course.id)], + "created": datetime.now(pytz.utc), + "encoded_videos": [ + { + "profile": "profile1", + "url": self.ALLOWED_URL, + "file_size": 1600, + "bitrate": 100, + }, + ], + }) + + @patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get") + def test_download_allowed_url(self, mock_get): + """A URL that belongs to the course's videos is fetched and zipped.""" + mock_get.return_value = MagicMock( + content=b"video-bytes", + headers={"Content-Type": "video/mp4"}, + ) + response = self.api_client.put( + self.url, + data={"files": [{"url": self.ALLOWED_URL, "name": "test.mp4"}]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + mock_get.assert_called_once_with(self.ALLOWED_URL, allow_redirects=True) + + @patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get") + def test_rejects_url_not_belonging_to_course(self, mock_get): + """ + A URL that is not one of the course's video URLs is rejected before any + server-side request is made (SSRF protection). + """ + response = self.api_client.put( + self.url, + data={"files": [{"url": self.SSRF_URL, "name": "evil.txt"}]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + mock_get.assert_not_called() + + @patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get") + def test_rejects_when_any_url_is_disallowed(self, mock_get): + """ + A request mixing an allowed URL with a disallowed one is rejected + outright, without fetching the allowed URL either. + """ + response = self.api_client.put( + self.url, + data={"files": [ + {"url": self.ALLOWED_URL, "name": "test.mp4"}, + {"url": self.SSRF_URL, "name": "evil.txt"}, + ]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009 + mock_get.assert_not_called() + + @patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get") + def test_non_staff_user_denied(self, mock_get): + """A user without studio read access cannot reach the fetch path.""" + __, nonstaff_user = self.create_non_staff_authed_user_client() + client = APIClient() + client.force_authenticate(user=nonstaff_user) + response = client.put( + self.url, + data={"files": [{"url": self.ALLOWED_URL, "name": "test.mp4"}]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + mock_get.assert_not_called() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py index 620b40235b73..6fe358a1b80e 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py @@ -2,20 +2,16 @@ import edx_api_doc_tools as apidocs from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseTextbooksSerializer from cms.djangoapps.contentstore.utils import get_textbooks_context -from cms.djangoapps.contentstore.rest_api.v1.serializers import ( - CourseTextbooksSerializer, -) -from common.djangoapps.student.auth import has_studio_read_access -from openedx.core.lib.api.view_utils import ( - DeveloperErrorViewMixin, - verify_course_exists, - view_auth_classes, -) +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore @@ -80,7 +76,12 @@ def get(self, request: Request, course_id: str): course_key = CourseKey.from_string(course_id) store = modulestore() - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.READ, + ): self.permission_denied(request) with store.bulk_operations(course_key): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 0af99137e9e2..034f57ba31ed 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -10,16 +10,12 @@ from rest_framework.views import APIView from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin -from cms.djangoapps.contentstore.rest_api.v1.serializers import ( - ContainerHandlerSerializer, -) -from cms.djangoapps.contentstore.utils import ( - get_container_handler_context, -) +from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerHandlerSerializer +from cms.djangoapps.contentstore.utils import get_container_handler_context from cms.djangoapps.contentstore.views.component import _get_item_in_course from openedx.core.lib.api.view_utils import view_auth_classes -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/videos.py b/cms/djangoapps/contentstore/rest_api/v1/views/videos.py index c41deec0e5da..08d67b7e33cd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/videos.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/videos.py @@ -1,29 +1,25 @@ """ Public rest API endpoints for contentstore API video assets (outside authoring API) """ -import edx_api_doc_tools as apidocs import logging + +import edx_api_doc_tools as apidocs from opaque_keys.edx.keys import CourseKey from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes, verify_course_exists -from common.djangoapps.student.auth import has_studio_read_access - -from ....utils import get_course_videos_context - -from cms.djangoapps.contentstore.video_storage_handlers import ( - get_video_usage_path, - create_video_zip, -) +import cms.djangoapps.contentstore.toggles as contentstore_toggles from cms.djangoapps.contentstore.rest_api.v1.serializers import ( CourseVideosSerializer, + VideoDownloadSerializer, VideoUsageSerializer, - VideoDownloadSerializer ) -import cms.djangoapps.contentstore.toggles as contentstore_toggles +from cms.djangoapps.contentstore.video_storage_handlers import create_video_zip, get_video_usage_path +from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes +from ....utils import get_course_videos_context log = logging.getLogger(__name__) toggles = contentstore_toggles diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py index 4a48fd6395ea..3db7172bdfb9 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py @@ -3,8 +3,8 @@ from cms.djangoapps.contentstore.rest_api.v2.serializers.downstreams import ( ComponentLinksSerializer, ContainerLinksSerializer, + PublishableEntityLinkSerializer, PublishableEntityLinksSummarySerializer, - PublishableEntityLinkSerializer ) from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 16dc977c8af8..8a669a782ed6 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -81,20 +81,21 @@ """ import logging +from itertools import chain from attrs import asdict as attrs_asdict -from django.db.models import QuerySet from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.db.models import QuerySet from edx_rest_framework_extensions.paginators import DefaultPagination from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryContainerLocator, LibraryLocatorV2 -from rest_framework.exceptions import NotFound, ValidationError, PermissionDenied +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.fields import BooleanField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from itertools import chain from xblock.core import XBlock from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink, EntityLinkBase @@ -115,14 +116,12 @@ from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block from cms.lib.xblock.upstream_sync_container import fetch_customizable_fields_from_container from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access -from openedx.core.lib.api.view_utils import ( - DeveloperErrorViewMixin, - view_auth_classes, -) +from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission from openedx.core.djangoapps.content_libraries import api as lib_api +from openedx.core.djangoapps.video_config.transcripts_utils import clear_transcripts +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from openedx.core.djangoapps.video_config.transcripts_utils import clear_transcripts logger = logging.getLogger(__name__) @@ -305,7 +304,12 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str): except InvalidKeyError as exc: raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ + ): raise PermissionDenied # Gets all links of the Course, using the diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/home.py b/cms/djangoapps/contentstore/rest_api/v2/views/home.py index 9d37684bfd81..404d088b3e3e 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/home.py @@ -1,16 +1,16 @@ """HomePageCoursesViewV2 APIView for getting content available to the logged in user.""" -import edx_api_doc_tools as apidocs from collections import OrderedDict -from rest_framework.response import Response + +import edx_api_doc_tools as apidocs +from rest_framework.pagination import PageNumberPagination from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.pagination import PageNumberPagination - -from openedx.core.lib.api.view_utils import view_auth_classes -from cms.djangoapps.contentstore.utils import get_course_context_v2 from cms.djangoapps.contentstore.rest_api.v2.serializers import CourseHomeTabSerializerV2 +from cms.djangoapps.contentstore.utils import get_course_context_v2 +from openedx.core.lib.api.view_utils import view_auth_classes class HomePageCoursesPaginator(PageNumberPagination): diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py index 9733f9878210..ffa8b0fa7089 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py @@ -7,13 +7,13 @@ from xml.etree import ElementTree import ddt -from opaque_keys.edx.keys import UsageKey from freezegun import freeze_time +from opaque_keys.edx.keys import UsageKey -from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string +from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin +from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, ModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @@ -26,7 +26,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ImmediateOnCommitMixi def setUp(self): super().setUp() - self.now = datetime.now(timezone.utc) + self.now = datetime.now(timezone.utc) # noqa: UP017 freezer = freeze_time(self.now) self.addCleanup(freezer.stop) freezer.start() @@ -128,7 +128,7 @@ def _create_block_from_upstream( "library_content_key": upstream_key, }, expect_response=expect_response) - def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] = None): + def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] | None = None): """ Update fields of an XBlock """ return self._api('patch', f"/xblock/{usage_key}", { "metadata": fields, @@ -158,9 +158,9 @@ def _get_downstream_links( data["use_top_level_parents"] = str(use_top_level_parents) return self.client.get("/api/contentstore/v2/downstreams/", data=data) - def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool: + def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> None: """ Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """ - self.assertEqual( + self.assertEqual( # noqa: PT009 ElementTree.canonicalize(xml_str_a, strip_text=True), ElementTree.canonicalize(xml_str_b, strip_text=True), ) @@ -441,8 +441,8 @@ def test_unit_sync(self): } ] data = downstreams.json() - self.assertEqual(data["count"], 4) - self.assertListEqual(data["results"], expected_downstreams) + self.assertEqual(data["count"], 4) # noqa: PT009 + self.assertListEqual(data["results"], expected_downstreams) # noqa: PT009 # 2️⃣ Now, lets modify the upstream problem 1: self._set_library_block_olx( @@ -594,8 +594,8 @@ def test_unit_sync(self): } ] data = downstreams.json() - self.assertEqual(data["count"], 4) - self.assertListEqual(data["results"], expected_downstreams) + self.assertEqual(data["count"], 4) # noqa: PT009 + self.assertListEqual(data["results"], expected_downstreams) # noqa: PT009 # 3️⃣ Now, add and delete a component upstream_problem3 = self._add_block_to_library( @@ -746,8 +746,8 @@ def test_unit_sync(self): } ] data = downstreams.json() - self.assertEqual(data["count"], 4) - self.assertListEqual(data["results"], expected_downstreams) + self.assertEqual(data["count"], 4) # noqa: PT009 + self.assertListEqual(data["results"], expected_downstreams) # noqa: PT009 # 4️⃣ Now, reorder components self._patch_container_children(self.upstream_unit["id"], [ @@ -879,8 +879,8 @@ def test_unit_sync(self): } ] data = downstreams.json() - self.assertEqual(data["count"], 4) - self.assertListEqual(data["results"], expected_downstreams) + self.assertEqual(data["count"], 4) # noqa: PT009 + self.assertListEqual(data["results"], expected_downstreams) # noqa: PT009 def test_unit_sync_with_modified_downstream(self): """ @@ -1120,8 +1120,8 @@ def test_modified_html_copy_paste(self): }, ] data = downstreams.json() - self.assertEqual(data["count"], 1) - self.assertListEqual(data["results"], expected_downstreams) + self.assertEqual(data["count"], 1) # noqa: PT009 + self.assertListEqual(data["results"], expected_downstreams) # noqa: PT009 # 2️⃣ Now, lets modify the upstream html AND the downstream display_name: self._update_course_block_fields(downstream_html1["locator"], { @@ -1182,8 +1182,8 @@ def test_modified_html_copy_paste(self): }, ] data = downstreams.json() - self.assertEqual(data["count"], 1) - self.assertListEqual(data["results"], expected_downstreams) + self.assertEqual(data["count"], 1) # noqa: PT009 + self.assertListEqual(data["results"], expected_downstreams) # noqa: PT009 # 3️⃣ Now, sync and check the resulting OLX of the downstream diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index f32d25a6a5b0..149732437215 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -11,7 +11,10 @@ from freezegun import freeze_time from opaque_keys.edx.keys import ContainerKey, UsageKey from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_authz.constants.roles import COURSE_EDITOR +from openedx_content import models_api as content_models from organizations.models import Organization +from rest_framework import status from cms.djangoapps.contentstore.helpers import StaticFileNotices from cms.djangoapps.contentstore.tests.utils import CourseTestCase @@ -21,6 +24,7 @@ from common.djangoapps.student.auth import add_users from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase @@ -50,7 +54,7 @@ def _get_upstream_link_good_and_syncable(downstream): version_declined=downstream.upstream_version_declined, error_message=None, downstream_customized=[], - has_top_level_parent=False, + top_level_parent_key=None, upstream_name=downstream.upstream_display_name, ) @@ -69,7 +73,7 @@ def setUp(self): """ # pylint: disable=too-many-statements super().setUp() - self.now = datetime.now(timezone.utc) + self.now = datetime.now(timezone.utc) # noqa: UP017 freezer = freeze_time(self.now) self.addCleanup(freezer.stop) freezer.start() @@ -221,6 +225,11 @@ def setUp(self): self._publish_library_block(self.video_lib_id) self._publish_library_block(self.html_lib_id) + def tearDown(self): + # If we're working with Containers in test cases, we need this line: + content_models.Container.reset_cache() + return super().tearDown() + def _api(self, method, url, data, expect_response): """ Call a REST API @@ -647,7 +656,7 @@ def test_200_single_upstream_container(self): assert data['ready_to_sync'] is True assert len(data['ready_to_sync_children']) == 1 html_block = modulestore().get_item(self.top_level_downstream_html_key) - self.assertDictEqual(data['ready_to_sync_children'][0], { + self.assertDictEqual(data['ready_to_sync_children'][0], { # noqa: PT009 'name': html_block.display_name, 'upstream': str(self.html_lib_id_2), 'block_type': 'html', @@ -864,8 +873,8 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_customized': [], }, ] - self.assertListEqual(data["results"], expected) - self.assertEqual(data["count"], 11) + self.assertListEqual(data["results"], expected) # noqa: PT009 + self.assertEqual(data["count"], 11) # noqa: PT009 def test_permission_denied_with_course_filter(self): self.client.login(username="simple_user", password="password") @@ -958,8 +967,8 @@ def test_200_component_downstreams_for_a_course(self): 'downstream_customized': [], }, ] - self.assertListEqual(data["results"], expected) - self.assertEqual(data["count"], 4) + self.assertListEqual(data["results"], expected) # noqa: PT009 + self.assertEqual(data["count"], 4) # noqa: PT009 def test_200_container_downstreams_for_a_course(self): """ @@ -1101,8 +1110,8 @@ def test_200_container_downstreams_for_a_course(self): 'downstream_customized': [], }, ] - self.assertListEqual(data["results"], expected) - self.assertEqual(data["count"], 7) + self.assertListEqual(data["results"], expected) # noqa: PT009 + self.assertEqual(data["count"], 7) # noqa: PT009 @ddt.data( ('all', 2), @@ -1121,8 +1130,8 @@ def test_200_downstreams_ready_to_sync(self, item_type, expected_count): ) assert response.status_code == 200 data = response.json() - self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) - self.assertEqual(data["count"], expected_count) + self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) # noqa: PT009 + self.assertEqual(data["count"], expected_count) # noqa: PT009 def test_permission_denied_without_filter(self): self.client.login(username="simple_user", password="password") @@ -1139,8 +1148,8 @@ def test_200_component_downstream_context_list(self): data = response.json() expected = [str(self.downstream_video_key)] + [str(key) for key in self.another_video_keys] got = [str(o["downstream_usage_key"]) for o in data["results"]] - self.assertListEqual(got, expected) - self.assertEqual(data["count"], 4) + self.assertListEqual(got, expected) # noqa: PT009 + self.assertEqual(data["count"], 4) # noqa: PT009 def test_200_container_downstream_context_list(self): """ @@ -1152,8 +1161,8 @@ def test_200_container_downstream_context_list(self): data = response.json() expected = [str(self.downstream_unit_key)] got = [str(o["downstream_usage_key"]) for o in data["results"]] - self.assertListEqual(got, expected) - self.assertEqual(data["count"], 1) + self.assertListEqual(got, expected) # noqa: PT009 + self.assertEqual(data["count"], 1) # noqa: PT009 def test_200_get_ready_to_sync_top_level_parents_with_components(self): """ @@ -1174,7 +1183,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): ) assert response.status_code == 200 data = response.json() - self.assertEqual(data["count"], 4) + self.assertEqual(data["count"], 4) # noqa: PT009 date_format = self.now.isoformat().split("+")[0] + 'Z' # The expected results are @@ -1255,7 +1264,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'downstream_customized': [], }, ] - self.assertListEqual(data["results"], expected) + self.assertListEqual(data["results"], expected) # noqa: PT009 def test_200_get_ready_to_sync_top_level_parents_with_containers(self): """ @@ -1274,7 +1283,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): ) assert response.status_code == 200 data = response.json() - self.assertEqual(data["count"], 3) + self.assertEqual(data["count"], 3) # noqa: PT009 date_format = self.now.isoformat().split("+")[0] + 'Z' # The expected results are @@ -1336,7 +1345,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): 'downstream_customized': [], }, ] - self.assertListEqual(data["results"], expected) + self.assertListEqual(data["results"], expected) # noqa: PT009 def test_200_get_ready_to_sync_duplicated_top_level_parents(self): """ @@ -1364,7 +1373,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): ) assert response.status_code == 200 data = response.json() - self.assertEqual(data["count"], 3) + self.assertEqual(data["count"], 3) # noqa: PT009 date_format = self.now.isoformat().split("+")[0] + 'Z' # The expected results are @@ -1426,7 +1435,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): 'downstream_customized': [], }, ] - self.assertListEqual(data["results"], expected) + self.assertListEqual(data["results"], expected) # noqa: PT009 class GetDownstreamSummaryViewTest( @@ -1456,7 +1465,7 @@ def test_200_summary(self): 'total_count': 3, 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] - self.assertListEqual(data, expected) + self.assertListEqual(data, expected) # noqa: PT009 response = self.call_api(str(self.course.id)) assert response.status_code == 200 data = response.json() @@ -1476,7 +1485,7 @@ def test_200_summary(self): 'total_count': 7, 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] - self.assertListEqual(data, expected) + self.assertListEqual(data, expected) # noqa: PT009 # Publish Subsection self._update_container(self.top_level_subsection_id, display_name="Subsection 3") @@ -1492,7 +1501,7 @@ def test_200_summary(self): 'total_count': 7, 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] - self.assertListEqual(data, expected) + self.assertListEqual(data, expected) # noqa: PT009 # Publish Section self._update_container(self.top_level_section_id, display_name="Section 3") @@ -1508,7 +1517,69 @@ def test_200_summary(self): 'total_count': 7, 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] - self.assertListEqual(data, expected) + self.assertListEqual(data, expected) # noqa: PT009 + + +class GetDownstreamSummaryAuthzViewTest( + CourseAuthoringAuthzTestMixin, + _BaseDownstreamViewTestMixin, + ImmediateOnCommitMixin, + SharedModuleStoreTestCase, +): + """ + AuthZ tests for: + GET /api/contentstore/v2/downstreams//summary + """ + + def call_api(self, client, course_id): # pylint: disable=arguments-differ + return client.get(f"/api/contentstore/v2/downstreams/{course_id}/summary") + + def test_authorized_user_can_access_summary(self): + """Authorized user with COURSE_EDITOR role can access summary.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.call_api(self.authorized_client, str(self.course.id)) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) + + def test_unauthorized_user_cannot_access_summary(self): + """Unauthorized user should receive 403.""" + response = self.call_api(self.unauthorized_client, str(self.course.id)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_user_without_role_then_added_can_access(self): + """Validate dynamic role assignment works.""" + response = self.call_api(self.unauthorized_client, str(self.course.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.call_api(self.unauthorized_client, str(self.course.id)) + assert response.status_code == status.HTTP_200_OK + + def test_staff_user_can_access_without_authz_role(self): + """Staff user should access without explicit AuthZ role.""" + response = self.call_api(self.staff_client, str(self.course.id)) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) + + def test_superuser_can_access_without_authz_role(self): + """Superuser should access without explicit AuthZ role.""" + response = self.call_api(self.super_client, str(self.course.id)) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) class GetDownstreamDeletedUpstream( @@ -1614,4 +1685,4 @@ def test_delete_component_should_be_ready_to_sync(self): 'version_synced': 2, } - self.assertDictEqual(data[0], expected_results) + self.assertDictEqual(data[0], expected_results) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py index 6a51610ac9f2..eb2d8adb1d51 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py @@ -3,10 +3,9 @@ """ from collections import OrderedDict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import ddt -import pytz from django.conf import settings from django.urls import reverse from rest_framework import status @@ -36,7 +35,7 @@ def setUp(self): display_name="Demo Course (Sample)", id=archived_course_key, org=archived_course_key.org, - end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), + end=(datetime.now() - timedelta(days=365)).replace(tzinfo=timezone.utc), # noqa: UP017 ) self.non_staff_client, _ = self.create_non_staff_authed_user_client() @@ -94,8 +93,8 @@ def test_home_page_response(self): ('results', expected_data), ]) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertDictEqual(expected_response, response.data) # noqa: PT009 def test_active_only_query_if_passed(self): """Get list of active courses only. @@ -105,8 +104,8 @@ def test_active_only_query_if_passed(self): """ response = self.client.get(self.api_v2_url, {"active_only": "true"}) - self.assertEqual(len(response.data["results"]["courses"]), 1) - self.assertEqual(response.data["results"]["courses"], [OrderedDict([ + self.assertEqual(len(response.data["results"]["courses"]), 1) # noqa: PT009 + self.assertEqual(response.data["results"]["courses"], [OrderedDict([ # noqa: PT009 ("course_key", str(self.course.id)), ("display_name", self.course.display_name), ("lms_link", f'{settings.LMS_ROOT_URL}/courses/{str(self.course.id)}/jump_to/{self.course.location}'), @@ -118,7 +117,7 @@ def test_active_only_query_if_passed(self): ("url", f'/course/{str(self.course.id)}'), ("is_active", True), ])]) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 def test_archived_only_query_if_passed(self): """Get list of archived courses only. @@ -128,13 +127,13 @@ def test_archived_only_query_if_passed(self): """ response = self.client.get(self.api_v2_url, {"archived_only": "true"}) - self.assertEqual(len(response.data["results"]["courses"]), 1) - self.assertEqual(response.data["results"]["courses"], [OrderedDict([ + self.assertEqual(len(response.data["results"]["courses"]), 1) # noqa: PT009 + self.assertEqual(response.data["results"]["courses"], [OrderedDict([ # noqa: PT009 ("course_key", str(self.archived_course.id)), ("display_name", self.archived_course.display_name), ( "lms_link", - '{url_root}/courses/{course_id}/jump_to/{location}'.format( + '{url_root}/courses/{course_id}/jump_to/{location}'.format( # noqa: UP032 url_root=settings.LMS_ROOT_URL, course_id=str(self.archived_course.id), location=self.archived_course.location @@ -148,7 +147,7 @@ def test_archived_only_query_if_passed(self): ("url", f'/course/{str(self.archived_course.id)}'), ("is_active", False), ])]) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 def test_search_query_if_passed(self): """Get list of courses when search filter passed as a query param. @@ -158,13 +157,13 @@ def test_search_query_if_passed(self): """ response = self.client.get(self.api_v2_url, {"search": "sample"}) - self.assertEqual(len(response.data["results"]["courses"]), 1) - self.assertEqual(response.data["results"]["courses"], [OrderedDict([ + self.assertEqual(len(response.data["results"]["courses"]), 1) # noqa: PT009 + self.assertEqual(response.data["results"]["courses"], [OrderedDict([ # noqa: PT009 ("course_key", str(self.archived_course.id)), ("display_name", self.archived_course.display_name), ( "lms_link", - '{url_root}/courses/{course_id}/jump_to/{location}'.format( + '{url_root}/courses/{course_id}/jump_to/{location}'.format( # noqa: UP032 url_root=settings.LMS_ROOT_URL, course_id=str(self.archived_course.id), location=self.archived_course.location @@ -178,7 +177,7 @@ def test_search_query_if_passed(self): ("url", f'/course/{str(self.archived_course.id)}'), ("is_active", False), ])]) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 def test_order_query_if_passed(self): """Get list of courses when order filter passed as a query param. @@ -188,9 +187,9 @@ def test_order_query_if_passed(self): """ response = self.client.get(self.api_v2_url, {"order": "org"}) - self.assertEqual(len(response.data["results"]["courses"]), 2) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["results"]["courses"][0]["org"], "demo-org") + self.assertEqual(len(response.data["results"]["courses"]), 2) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertEqual(response.data["results"]["courses"][0]["org"], "demo-org") # noqa: PT009 def test_page_query_if_passed(self): """Get list of courses when page filter passed as a query param. @@ -200,8 +199,8 @@ def test_page_query_if_passed(self): """ response = self.client.get(self.api_v2_url, {"page": 1}) - self.assertEqual(response.data["count"], 2) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 @ddt.data( ("active_only", "true"), @@ -222,8 +221,8 @@ def test_if_empty_list_of_courses(self, query_param, value): response = self.client.get(self.api_v2_url, {query_param: value}) - self.assertEqual(len(response.data['results']['courses']), 0) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']['courses']), 0) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 @ddt.data( ("active_only", "true", 2, 0), @@ -256,7 +255,7 @@ def test_filter_and_ordering_courses( display_name="Course (Demo)", id=archived_course_key, org=archived_course_key.org, - end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), + end=(datetime.now() - timedelta(days=365)).replace(tzinfo=timezone.utc), # noqa: UP017 ) active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run") CourseOverviewFactory.create( @@ -267,12 +266,12 @@ def test_filter_and_ordering_courses( response = self.client.get(self.api_v2_url, {filter_key: filter_value}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + self.assertEqual( # noqa: PT009 len([course for course in response.data["results"]["courses"] if course["is_active"]]), expected_active_length ) - self.assertEqual( + self.assertEqual( # noqa: PT009 len([course for course in response.data["results"]["courses"] if not course["is_active"]]), expected_archived_length ) @@ -296,5 +295,5 @@ def test_if_empty_list_of_courses_non_staff(self, query_param, value): response = self.non_staff_client.get(self.api_v2_url, {query_param: value}) - self.assertEqual(len(response.data["results"]["courses"]), 0) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]["courses"]), 0) # noqa: PT009 + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/utils.py b/cms/djangoapps/contentstore/rest_api/v2/views/utils.py index 8a6fe461fdb7..840ba2c0e0b0 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/utils.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/utils.py @@ -1,11 +1,12 @@ """ Common utilities for V2 APIs. """ -from rest_framework.response import Response -from rest_framework.generics import GenericAPIView from rest_framework import permissions +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from xblocks_contrib.problem.capa.inputtypes import preview_numeric_input + from cms.djangoapps.contentstore.rest_api.v2.serializers.utils import NumericalInputValidationRequestSerializer -from xmodule.capa.inputtypes import preview_numeric_input class NumericalInputValidationView(GenericAPIView): diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index e28cbf313acb..74ed55ec95ed 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -50,9 +50,9 @@ from ..tasks import ( create_or_update_upstream_links, handle_create_xblock_upstream_link, - handle_update_xblock_upstream_link, handle_unlink_upstream_block, handle_unlink_upstream_container, + handle_update_xblock_upstream_link, ) from .signals import GRADING_POLICY_CHANGED @@ -61,7 +61,7 @@ GRADING_POLICY_COUNTDOWN_SECONDS = 3600 -def locked(expiry_seconds, key): # lint-amnesty, pylint: disable=missing-function-docstring +def locked(expiry_seconds, key): # pylint: disable=missing-function-docstring def task_decorator(func): @wraps(func) def wrapper(*args, **kwargs): @@ -75,7 +75,7 @@ def wrapper(*args, **kwargs): return task_decorator -def _create_catalog_data_for_signal(course_key: CourseKey) -> (Optional[datetime], Optional[CourseCatalogData]): +def _create_catalog_data_for_signal(course_key: CourseKey) -> (Optional[datetime], Optional[CourseCatalogData]): # noqa: UP045 # pylint: disable=line-too-long """ Creates data for catalog-info-changed signal when course is published. @@ -94,7 +94,7 @@ def _create_catalog_data_for_signal(course_key: CourseKey) -> (Optional[datetime store = modulestore() with store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): course = store.get_course(course_key) - timestamp = course.subtree_edited_on.replace(tzinfo=timezone.utc) + timestamp = course.subtree_edited_on.replace(tzinfo=timezone.utc) # noqa: UP017 return timestamp, CourseCatalogData( course_key=course_key.for_branch(None), # Shouldn't be necessary, but just in case... name=course.display_name, @@ -132,7 +132,7 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable= from cms.djangoapps.contentstore.tasks import ( update_outline_from_modulestore_task, update_search_index, - update_special_exams_and_publish + update_special_exams_and_publish, ) # DEVELOPER README: probably all tasks here should use transaction.on_commit @@ -238,7 +238,7 @@ def handle_grading_policy_changed(sender, **kwargs): 'event_transaction_type': str(get_event_transaction_type()), } result = task_compute_all_grades_for_course.apply_async(kwargs=kwargs, countdown=GRADING_POLICY_COUNTDOWN_SECONDS) - log.info("Grades: Created {task_name}[{task_id}] with arguments {kwargs}".format( + log.info("Grades: Created {task_name}[{task_id}] with arguments {kwargs}".format( # noqa: UP032 task_name=task_compute_all_grades_for_course.name, task_id=result.task_id, kwargs=kwargs, diff --git a/cms/djangoapps/contentstore/signals/tests/test_handlers.py b/cms/djangoapps/contentstore/signals/tests/test_handlers.py index e70a62508f42..25e7512a1f71 100644 --- a/cms/djangoapps/contentstore/signals/tests/test_handlers.py +++ b/cms/djangoapps/contentstore/signals/tests/test_handlers.py @@ -9,8 +9,9 @@ from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData import cms.djangoapps.contentstore.signals.handlers as sh -from xmodule.modulestore.edit_info import EditInfoMixin +from xmodule.course_metadata_utils import DEFAULT_START_DATE from xmodule.modulestore.django import SignalHandler +from xmodule.modulestore.edit_info import EditInfoMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import SampleCourseFactory @@ -34,7 +35,7 @@ def setUp(self): course_key=CourseLocator(org='TestU', course='sig101', run='Summer2022', branch=None, version_guid=None), name='Signals 101', schedule_data=CourseScheduleData( - start=datetime.fromisoformat('2030-01-01T00:00+00:00'), + start=DEFAULT_START_DATE, pacing='instructor', end=None, enrollment_start=None, @@ -48,7 +49,7 @@ def setUp(self): autospec=True, side_effect=lambda func: func(), # run right away ) @patch('cms.djangoapps.contentstore.signals.handlers.emit_catalog_info_changed_signal', autospec=True) - def test_signal_chain(self, mock_emit, _mock_on_commit): + def test_signal_chain(self, mock_emit, _mock_on_commit): # noqa: PT019 """ Test that the course_published signal handler invokes the catalog info signal emitter. @@ -66,7 +67,7 @@ def test_emit_regular_course(self, mock_signal): with patch.object(EditInfoMixin, 'subtree_edited_on', now): sh.emit_catalog_info_changed_signal(self.course_key) mock_signal.send_event.assert_called_once_with( - time=now.replace(tzinfo=timezone.utc), + time=now.replace(tzinfo=timezone.utc), # noqa: UP017 catalog_info=self.expected_data) @patch('cms.djangoapps.contentstore.signals.handlers.COURSE_CATALOG_INFO_CHANGED', autospec=True) diff --git a/cms/djangoapps/contentstore/storage.py b/cms/djangoapps/contentstore/storage.py index 83308ebd86b0..b1e9a37bc761 100644 --- a/cms/djangoapps/contentstore/storage.py +++ b/cms/djangoapps/contentstore/storage.py @@ -4,10 +4,11 @@ from django.conf import settings -from common.djangoapps.util.storage import resolve_storage_backend from storages.backends.s3boto3 import S3Boto3Storage from storages.utils import setting +from common.djangoapps.util.storage import resolve_storage_backend + class ImportExportS3Storage(S3Boto3Storage): # pylint: disable=abstract-method """ diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index ac9cc3d831ad..a492b50c7c10 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -29,13 +29,13 @@ set_code_owner_attribute, set_code_owner_attribute_from_module, set_custom_attribute, - set_custom_attributes_for_course_key + set_custom_attributes_for_course_key, ) from olxcleaner.exceptions import ErrorLevel from olxcleaner.reporting import report_error_summary, report_errors from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator, BlockUsageLocator +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator from openedx_events.content_authoring.data import CourseData from openedx_events.content_authoring.signals import COURSE_RERUN_COMPLETED from organizations.api import add_organization_course, ensure_organization @@ -50,7 +50,7 @@ from cms.djangoapps.contentstore.courseware_index import ( CoursewareSearchIndexer, LibrarySearchIndexer, - SearchIndexingError + SearchIndexingError, ) from cms.djangoapps.contentstore.storage import course_import_export_storage from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links @@ -63,7 +63,7 @@ get_previous_run_course_key, initialize_permissions, reverse_usage_url, - translation_language + translation_language, ) from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_block_info from cms.djangoapps.models.settings.course_metadata import CourseMetadata @@ -94,12 +94,12 @@ from xmodule.tabs import StaticTab from xmodule.util.keys import BlockKey +from .api import get_ready_to_migrate_legacy_library_content_blocks from .models import ComponentLink, ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices from .outlines import update_outline_from_modulestore from .outlines_regenerate import CourseOutlineRegenerate from .toggles import bypass_olx_failure_enabled from .utils import course_import_olx_validation_is_enabled -from .api import get_ready_to_migrate_legacy_library_content_blocks User = get_user_model() @@ -194,7 +194,7 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i CourseRerunState.objects.succeeded(course_key=destination_course_key) COURSE_RERUN_COMPLETED.send_event( - time=datetime.now(timezone.utc), + time=datetime.now(timezone.utc), # noqa: UP017 course=CourseData( course_key=destination_course_key ) @@ -409,7 +409,7 @@ def create_export_tarball(course_block, course_key, context, status=None): """ name = course_block.url_name export_file = NamedTemporaryFile(prefix=name + '.', - suffix=".tar.gz") # lint-amnesty, pylint: disable=consider-using-with + suffix=".tar.gz") # pylint: disable=consider-using-with root_dir = path(mkdtemp()) try: @@ -523,8 +523,14 @@ def sync_discussion_settings(course_key, user): discussion_config.provider_type = Provider.OPEN_EDX - discussion_config.enable_graded_units = discussion_settings['enable_graded_units'] - discussion_config.unit_level_visibility = discussion_settings['unit_level_visibility'] + fields = ["enable_graded_units", "unit_level_visibility", "enable_in_context", "posting_restrictions"] + # Plugin configuration is stored in the course settings under the provider name. + field_mappings = dict(zip(fields, fields)) | {"plugin_configuration": discussion_config.provider_type} # noqa: B905 # pylint: disable=line-too-long + + for attr_name, settings_key in field_mappings.items(): + if settings_key in discussion_settings: + setattr(discussion_config, attr_name, discussion_settings[settings_key]) + discussion_config.save() LOGGER.info(f'Course import {course.id}: DiscussionsConfiguration synced as per course') except Exception as exc: # pylint: disable=broad-except @@ -534,7 +540,7 @@ def sync_discussion_settings(course_key, user): @shared_task(base=CourseImportTask, bind=True) # Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin # does stack inspection and can't handle additional decorators. -# lint-amnesty, pylint: disable=too-many-statements +# pylint: disable=too-many-statements def import_olx(self, user_id, course_key_string, archive_path, archive_name, language): """ Import a course or library from a provided OLX .tar.gz or .zip archive. @@ -941,7 +947,7 @@ def _get_users_by_access_level(v1_library_key): return permissions permissions = _get_users_by_access_level(v1_library_key) - for access_level in permissions.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary + for access_level in permissions.keys(): # pylint: disable=consider-iterating-dictionary for user in permissions[access_level]: v2contentlib_api.set_library_user_permissions(v2_library_key, user, access_level) @@ -958,7 +964,7 @@ def delete_v1_library(v1_library_key_string): try: delete_course(v1_library_key, ModuleStoreEnum.UserID.mgmt_command, True) LOGGER.info(f"Deleted course {v1_library_key}") - except Exception as error: # lint-amnesty, pylint: disable=broad-except + except Exception as error: # pylint: disable=broad-except return { "v1_library_id": v1_library_key_string, "status": "FAILED", @@ -990,7 +996,7 @@ def validate_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v ) for xblock in blocks: if xblock.source_library_id not in v1_to_v2_lib_map.values(): - # lint-amnesty, pylint: disable=broad-except + # pylint: disable=broad-except raise Exception( f'{xblock.source_library_id} in {course_id} is not found in mapping. Validation failed' ) @@ -1001,7 +1007,7 @@ def validate_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v @shared_task(time_limit=30) @set_code_owner_attribute -def replace_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return +def replace_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # pylint: disable=useless-return """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v2 value from the map. @@ -1060,7 +1066,7 @@ def replace_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2 @shared_task(time_limit=30) @set_code_owner_attribute -def undo_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return +def undo_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # pylint: disable=useless-return """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v1 value from the inverted map. This is exists to undo changes made previously. @@ -1165,7 +1171,7 @@ def _check_broken_links(task_instance, user_id, course_key_string, language): Checks for broken links in a course and stores the results in a file. Also checks for previous run links if the feature is enabled. """ - user = _validate_user(task_instance, user_id, language) + user = _validate_user(task_instance, user_id, language) # noqa: F841 task_instance.status.set_state(UserTaskStatus.IN_PROGRESS) course_key = CourseKey.from_string(course_key_string) @@ -1220,7 +1226,7 @@ def _validate_user(task, user_id, language): """Validate if the user exists. Otherwise log an unknown user id error.""" try: return User.objects.get(pk=user_id) - except User.DoesNotExist as exc: + except User.DoesNotExist as exc: # noqa: F841 with translation_language(language): task.status.fail(UserErrors.UNKNOWN_USER_ID.format(user_id)) return @@ -1477,14 +1483,14 @@ async def _validate_url_access(session, url_data, course_key): parsed = urlparse(url) domain = parsed.netloc.lower() headers = DOMAIN_HEADERS.get(domain, DEFAULT_HEADERS) - except Exception as e: # lint-amnesty, pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except LOGGER.debug(f'[Link Check] Error parsing URL {url}: {str(e)}') headers = DEFAULT_HEADERS try: async with session.get(standardized_url, headers=headers, timeout=5) as response: result.update({'status': response.status}) - except Exception as e: # lint-amnesty, pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except result.update({'status': None}) LOGGER.debug(f'[Link Check] Request error when validating {url}: {str(e)}') return result @@ -1641,11 +1647,7 @@ def handle_create_xblock_upstream_link(usage_key): return if xblock.top_level_downstream_parent_key is not None: block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key) - top_level_parent_usage_key = BlockUsageLocator( - xblock.course_id, - block_key.type, - block_key.id, - ) + top_level_parent_usage_key = block_key.to_usage_key(xblock.course_id) try: ContainerLink.get_by_downstream_usage_key(top_level_parent_usage_key) except ContainerLink.DoesNotExist: @@ -1686,7 +1688,7 @@ def create_or_update_upstream_links( ensure_cms("create_or_update_upstream_links may only be executed in a CMS context") if not created: - created = datetime.now(timezone.utc) + created = datetime.now(timezone.utc) # noqa: UP017 course_status = LearningContextLinksStatus.get_or_create(course_key_str, created) if course_status.status in [ LearningContextLinksStatusChoices.COMPLETED, @@ -2165,7 +2167,7 @@ def _update_broken_links_file_with_updated_links(course_key, updated_links): try: with latest_artifact.file.open("r") as file: existing_broken_links = json.load(file) - except (json.JSONDecodeError, IOError) as e: + except (json.JSONDecodeError, IOError) as e: # noqa: UP024 LOGGER.error( f"Failed to read broken links file for course {course_key}: {e}" ) diff --git a/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py index 149a7a53182f..69a6d1a39beb 100644 --- a/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py +++ b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py @@ -5,12 +5,12 @@ from django.urls import reverse from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase): @@ -49,13 +49,13 @@ def setUp(self): category='sequential', display_name="Generated Sequence", ) - unit1 = BlockFactory.create( + unit1 = BlockFactory.create( # noqa: F841 parent=sequence, category='vertical', display_name="Unit in Section1", discussion_enabled=True, ) - unit2 = BlockFactory.create( + unit2 = BlockFactory.create( # noqa: F841 parent=sequence, category='vertical', display_name="Unit in Section2", @@ -82,16 +82,16 @@ def enable_disable_discussions_for_all_units(self, is_enabled): "discussion_enabled": is_enabled } response = self.client.put(self.url, data=json.dumps(data), content_type='application/json') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 response_data = response.json() print(response_data) - self.assertEqual(response_data['units_updated_and_republished'], 0 if is_enabled else 2) + self.assertEqual(response_data['units_updated_and_republished'], 0 if is_enabled else 2) # noqa: PT009 # Check that all verticals now have discussion_enabled set to the expected value with self.store.bulk_operations(self.course_key): verticals = self.store.get_items(self.course_key, qualifiers={'block_type': 'vertical'}) for vertical in verticals: - self.assertEqual(vertical.discussion_enabled, is_enabled) + self.assertEqual(vertical.discussion_enabled, is_enabled) # noqa: PT009 def test_permission_denied_for_non_staff(self): """ @@ -107,11 +107,11 @@ def test_permission_denied_for_non_staff(self): non_staff_client.login(username=non_staff_user.username, password=self.user_password) response = non_staff_client.put(self.url, content_type='application/json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 def test_badrequest_for_empty_request_body(self): """ Test that the API returns a 400 for an empty request body. """ response = self.client.put(self.url, data=json.dumps({}), content_type='application/json') - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_clone_course.py b/cms/djangoapps/contentstore/tests/test_clone_course.py index 6d45ae5469e1..7de6fcdb63a5 100644 --- a/cms/djangoapps/contentstore/tests/test_clone_course.py +++ b/cms/djangoapps/contentstore/tests/test_clone_course.py @@ -14,10 +14,10 @@ from common.djangoapps.course_action_state.managers import CourseRerunUIStateManager from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.auth import has_course_author_access -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -69,8 +69,8 @@ def test_space_in_asset_name_for_rerun_course(self): # Get & verify all assets of the course assets, count = contentstore().get_all_content_for_course(course.id) - self.assertEqual(count, 1) - self.assertEqual({asset['asset_key'].block_id for asset in assets}, course_assets) # lint-amnesty, pylint: disable=consider-using-set-comprehension + self.assertEqual(count, 1) # noqa: PT009 + self.assertEqual({asset['asset_key'].block_id for asset in assets}, course_assets) # pylint: disable=consider-using-set-comprehension # noqa: PT009 # rerun from split into split split_rerun_id = CourseLocator(org=org, course=course_number, run="2012_Q2") @@ -83,9 +83,9 @@ def test_space_in_asset_name_for_rerun_course(self): ) # Check if re-run was successful - self.assertEqual(result.get(), "succeeded") + self.assertEqual(result.get(), "succeeded") # noqa: PT009 rerun_state = CourseRerunState.objects.find_first(course_key=split_rerun_id) - self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) # noqa: PT009 def test_rerun_course(self): """ @@ -114,14 +114,14 @@ def test_rerun_course(self): CourseRerunState.objects.initiated(split_course.id, split_course3_id, self.user, fields['display_name']) result = rerun_course.delay(str(split_course.id), str(split_course3_id), self.user.id, json.dumps(fields, cls=EdxJSONEncoder)) - self.assertEqual(result.get(), "succeeded") - self.assertTrue(has_course_author_access(self.user, split_course3_id), "Didn't grant access") + self.assertEqual(result.get(), "succeeded") # noqa: PT009 + self.assertTrue(has_course_author_access(self.user, split_course3_id), "Didn't grant access") # noqa: PT009 rerun_state = CourseRerunState.objects.find_first(course_key=split_course3_id) - self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) # noqa: PT009 # try creating rerunning again to same name and ensure it generates error result = rerun_course.delay(str(split_course.id), str(split_course3_id), self.user.id) - self.assertEqual(result.get(), "duplicate course") + self.assertEqual(result.get(), "duplicate course") # noqa: PT009 # the below will raise an exception if the record doesn't exist CourseRerunState.objects.find_first( course_key=split_course3_id, @@ -129,14 +129,14 @@ def test_rerun_course(self): ) # try to hit the generic exception catch - with patch('xmodule.modulestore.split_mongo.mongo_connection.MongoPersistenceBackend.insert_course_index', Mock(side_effect=Exception)): # lint-amnesty, pylint: disable=line-too-long + with patch('xmodule.modulestore.split_mongo.mongo_connection.MongoPersistenceBackend.insert_course_index', Mock(side_effect=Exception)): # pylint: disable=line-too-long split_course4_id = CourseLocator(org="edx3", course="split3", run="rerun_fail") fields = {'display_name': 'total failure'} CourseRerunState.objects.initiated(split_course3_id, split_course4_id, self.user, fields['display_name']) result = rerun_course.delay(str(split_course3_id), str(split_course4_id), self.user.id, json.dumps(fields, cls=EdxJSONEncoder)) - self.assertIn("exception: ", result.get()) - self.assertIsNone(self.store.get_course(split_course4_id), "Didn't delete course after error") + self.assertIn("exception: ", result.get()) # noqa: PT009 + self.assertIsNone(self.store.get_course(split_course4_id), "Didn't delete course after error") # noqa: PT009 # pylint: disable=line-too-long CourseRerunState.objects.find_first( course_key=split_course4_id, state=CourseRerunUIStateManager.State.FAILED diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index c7c4cd6921ff..4561926c1b8d 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,4 +1,4 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring # TODO: Rewrite several of these assertions so that they check the output of the REST or Python # APIs rather than parsing HTML from the deprecated legacy frontend pages. In particular, any @@ -17,11 +17,11 @@ import ddt from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_switch, override_waffle_flag +from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch from edxval.api import create_video, get_videos_for_course from fs.osfs import OSFS from lxml import etree @@ -29,6 +29,19 @@ from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey from opaque_keys.edx.locations import CourseLocator from path import Path as path + +from cms.djangoapps.contentstore import toggles +from cms.djangoapps.contentstore.config import waffle +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json +from cms.djangoapps.contentstore.utils import delete_course, reverse_course_url, reverse_url +from cms.djangoapps.contentstore.views.component import ADVANCED_COMPONENT_TYPES +from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError +from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager +from common.djangoapps.student import auth +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.roles import CourseCreatorRole, CourseInstructorRole +from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded +from openedx.core.lib.tempdir import mkdtemp_clean from xmodule.capa_block import ProblemBlock from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore @@ -41,31 +54,14 @@ from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.split_mongo import BlockKey from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, check_mongo_calls from xmodule.modulestore.xml_exporter import export_course_to_xml from xmodule.modulestore.xml_importer import import_course_from_xml, perform_xlint from xmodule.seq_block import SequenceBlock from xmodule.video_block import VideoBlock -from cms.djangoapps.contentstore import toggles -from cms.djangoapps.contentstore.config import waffle -from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json -from cms.djangoapps.contentstore.utils import ( - delete_course, - reverse_course_url, - reverse_url, -) -from cms.djangoapps.contentstore.views.component import ADVANCED_COMPONENT_TYPES -from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError -from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager -from common.djangoapps.student import auth -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseCreatorRole, CourseInstructorRole -from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded -from openedx.core.lib.tempdir import mkdtemp_clean - TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex # noqa: UP031 TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -83,7 +79,7 @@ def decorated_func(*args, **kwargs): try: from PIL import Image except ImportError: - raise SkipTest("Pillow is not installed (or not found)") # lint-amnesty, pylint: disable=raise-missing-from + raise SkipTest("Pillow is not installed (or not found)") # pylint: disable=raise-missing-from # noqa: B904 if not getattr(Image.core, "jpeg_decoder", False): raise SkipTest("Pillow cannot open JPEG files") return func(*args, **kwargs) @@ -111,15 +107,15 @@ def test_no_static_link_rewrites_on_import(self): handouts_usage_key = course.id.make_usage_key('course_info', 'handouts') handouts = self.store.get_item(handouts_usage_key) - self.assertIn('/static/', handouts.data) + self.assertIn('/static/', handouts.data) # noqa: PT009 handouts_usage_key = course.id.make_usage_key('html', 'toyhtml') handouts = self.store.get_item(handouts_usage_key) - self.assertIn('/static/', handouts.data) + self.assertIn('/static/', handouts.data) # noqa: PT009 def test_xlint_fails(self): err_cnt = perform_xlint(TEST_DATA_DIR, ['toy']) - self.assertGreater(err_cnt, 0) + self.assertGreater(err_cnt, 0) # noqa: PT009 def test_invalid_asset_overwrite(self): """ @@ -150,7 +146,7 @@ def test_invalid_asset_overwrite(self): 'import_draft_order', 'import_draft_order' )) - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 # Add a new asset in the course, and make sure to name it such that it overwrite the one existing # asset in the course. (i.e. _invalid_displayname_subs-esLhHcdKGWvKs.srt) @@ -162,11 +158,11 @@ def test_invalid_asset_overwrite(self): # Get & verify that course actually has two assets assets, count = content_store.get_all_content_for_course(course.id) - self.assertEqual(count, 2) + self.assertEqual(count, 2) # noqa: PT009 # Verify both assets have similar `displayname` after saving. for asset in assets: - self.assertEqual(asset['displayname'], expected_displayname) + self.assertEqual(asset['displayname'], expected_displayname) # noqa: PT009 # Test course export does not fail root_dir = path(mkdtemp_clean()) @@ -177,9 +173,9 @@ def test_invalid_asset_overwrite(self): exported_static_files = filesystem.listdir('/') # Verify that asset have been overwritten during export. - self.assertEqual(len(exported_static_files), 1) - self.assertTrue(filesystem.exists(expected_displayname)) - self.assertEqual(exported_static_files[0], expected_displayname) + self.assertEqual(len(exported_static_files), 1) # noqa: PT009 + self.assertTrue(filesystem.exists(expected_displayname)) # noqa: PT009 + self.assertEqual(exported_static_files[0], expected_displayname) # noqa: PT009 # Remove exported course shutil.rmtree(root_dir) @@ -195,11 +191,11 @@ def test_about_overrides(self): ) course_key = course_items[0].id effort = self.store.get_item(course_key.make_usage_key('about', 'effort')) - self.assertEqual(effort.data, '6 hours') + self.assertEqual(effort.data, '6 hours') # noqa: PT009 # this one should be in a non-override folder effort = self.store.get_item(course_key.make_usage_key('about', 'end_date')) - self.assertEqual(effort.data, 'TBD') + self.assertEqual(effort.data, 'TBD') # noqa: PT009 @requires_pillow_jpeg def test_asset_import(self): @@ -215,23 +211,23 @@ def test_asset_import(self): course = self.store.get_course(self.store.make_course_key('edX', 'toy', '2012_Fall')) - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 # make sure we have some assets in our contentstore all_assets, __ = content_store.get_all_content_for_course(course.id) - self.assertGreater(len(all_assets), 0) + self.assertGreater(len(all_assets), 0) # noqa: PT009 # make sure we have some thumbnails in our contentstore all_thumbnails = content_store.get_all_content_thumbnails_for_course(course.id) - self.assertGreater(len(all_thumbnails), 0) + self.assertGreater(len(all_thumbnails), 0) # noqa: PT009 location = AssetKey.from_string('asset-v1:edX+toy+2012_Fall+type@asset+block@just_a_test.jpg') content = content_store.find(location) - self.assertIsNotNone(content) + self.assertIsNotNone(content) # noqa: PT009 - self.assertIsNotNone(content.thumbnail_location) + self.assertIsNotNone(content.thumbnail_location) # noqa: PT009 thumbnail = content_store.find(content.thumbnail_location) - self.assertIsNotNone(thumbnail) + self.assertIsNotNone(thumbnail) # noqa: PT009 def test_course_info_updates_import_export(self): """ @@ -245,26 +241,26 @@ def test_course_info_updates_import_export(self): ) course = courses[0] - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 course_updates = self.store.get_item(course.id.make_usage_key('course_info', 'updates')) - self.assertIsNotNone(course_updates) + self.assertIsNotNone(course_updates) # noqa: PT009 # check that course which is imported has files 'updates.html' and 'updates.items.json' filesystem = OSFS(str(data_dir + '/course_info_updates/info')) - self.assertTrue(filesystem.exists('updates.html')) - self.assertTrue(filesystem.exists('updates.items.json')) + self.assertTrue(filesystem.exists('updates.html')) # noqa: PT009 + self.assertTrue(filesystem.exists('updates.items.json')) # noqa: PT009 # verify that course info update module has same data content as in data file from which it is imported # check 'data' field content with filesystem.open('updates.html', 'r') as course_policy: on_disk = course_policy.read() - self.assertEqual(course_updates.data, on_disk) + self.assertEqual(course_updates.data, on_disk) # noqa: PT009 # check 'items' field content with filesystem.open('updates.items.json', 'r') as course_policy: on_disk = loads(course_policy.read()) - self.assertEqual(course_updates.items, on_disk) + self.assertEqual(course_updates.items, on_disk) # noqa: PT009 # now export the course to a tempdir and test that it contains files 'updates.html' and 'updates.items.json' # with same content as in course 'info' directory @@ -274,17 +270,17 @@ def test_course_info_updates_import_export(self): # check that exported course has files 'updates.html' and 'updates.items.json' filesystem = OSFS(str(root_dir / 'test_export/info')) - self.assertTrue(filesystem.exists('updates.html')) - self.assertTrue(filesystem.exists('updates.items.json')) + self.assertTrue(filesystem.exists('updates.html')) # noqa: PT009 + self.assertTrue(filesystem.exists('updates.items.json')) # noqa: PT009 # verify that exported course has same data content as in course_info_update module with filesystem.open('updates.html', 'r') as grading_policy: on_disk = grading_policy.read() - self.assertEqual(on_disk, course_updates.data) + self.assertEqual(on_disk, course_updates.data) # noqa: PT009 with filesystem.open('updates.items.json', 'r') as grading_policy: on_disk = loads(grading_policy.read()) - self.assertEqual(on_disk, course_updates.items) + self.assertEqual(on_disk, course_updates.items) # noqa: PT009 def test_rewrite_nonportable_links_on_import(self): content_store = contentstore() @@ -298,22 +294,22 @@ def test_rewrite_nonportable_links_on_import(self): course_key = self.store.make_course_key('edX', 'toy', '2012_Fall') html_block_location = course_key.make_usage_key('html', 'nonportable') html_block = self.store.get_item(html_block_location) - self.assertIn('/static/foo.jpg', html_block.data) + self.assertIn('/static/foo.jpg', html_block.data) # noqa: PT009 # then check a intra courseware link html_block_location = course_key.make_usage_key('html', 'nonportable_link') html_block = self.store.get_item(html_block_location) - self.assertIn('/jump_to_id/nonportable_link', html_block.data) + self.assertIn('/jump_to_id/nonportable_link', html_block.data) # noqa: PT009 - def verify_content_existence(self, store, root_dir, course_id, dirname, category_name, filename_suffix=''): # lint-amnesty, pylint: disable=missing-function-docstring + def verify_content_existence(self, store, root_dir, course_id, dirname, category_name, filename_suffix=''): # pylint: disable=missing-function-docstring filesystem = OSFS(root_dir / 'test_export') - self.assertTrue(filesystem.exists(dirname)) + self.assertTrue(filesystem.exists(dirname)) # noqa: PT009 items = store.get_items(course_id, qualifiers={'category': category_name}) for item in items: filesystem = OSFS(root_dir / ('test_export/' + dirname)) - self.assertTrue(filesystem.exists(item.location.block_id + filename_suffix)) + self.assertTrue(filesystem.exists(item.location.block_id + filename_suffix)) # noqa: PT009 def test_export_course_with_metadata_only_video(self): content_store = contentstore() @@ -326,7 +322,7 @@ def test_export_course_with_metadata_only_video(self): # anything in 'data' field, the export was blowing up verticals = self.store.get_items(course_id, qualifiers={'category': 'vertical'}) - self.assertGreater(len(verticals), 0) + self.assertGreater(len(verticals), 0) # noqa: PT009 parent = verticals[0] @@ -352,7 +348,7 @@ def test_export_course_with_metadata_only_word_cloud(self): verticals = self.store.get_items(course_id, qualifiers={'category': 'vertical'}) - self.assertGreater(len(verticals), 0) + self.assertGreater(len(verticals), 0) # noqa: PT009 parent = verticals[0] @@ -384,14 +380,14 @@ def test_import_after_renaming_xml_data(self): ) all_items = split_store.get_items(course_after_rename[0].id, qualifiers={'category': 'chapter'}) renamed_chapter = [item for item in all_items if item.location.block_id == 'renamed_chapter'][0] - self.assertIsNotNone(renamed_chapter.published_on) - self.assertIsNotNone(renamed_chapter.parent) - self.assertIn(renamed_chapter.location, course_after_rename[0].children) + self.assertIsNotNone(renamed_chapter.published_on) # noqa: PT009 + self.assertIsNotNone(renamed_chapter.parent) # noqa: PT009 + self.assertIn(renamed_chapter.location, course_after_rename[0].children) # noqa: PT009 original_chapter = [item for item in all_items if item.location.block_id == 'b9870b9af59841a49e6e02765d0e3bbf'][0] - self.assertIsNone(original_chapter.published_on) - self.assertIsNone(original_chapter.parent) - self.assertNotIn(original_chapter.location, course_after_rename[0].children) + self.assertIsNone(original_chapter.published_on) # noqa: PT009 + self.assertIsNone(original_chapter.parent) # noqa: PT009 + self.assertNotIn(original_chapter.location, course_after_rename[0].children) # noqa: PT009 def test_empty_data_roundtrip(self): """ @@ -405,7 +401,7 @@ def test_empty_data_roundtrip(self): verticals = self.store.get_items(course_id, qualifiers={'category': 'vertical'}) - self.assertGreater(len(verticals), 0) + self.assertGreater(len(verticals), 0) # noqa: PT009 parent = verticals[0] @@ -413,7 +409,7 @@ def test_empty_data_roundtrip(self): word_cloud = BlockFactory.create( parent_location=parent.location, category="word_cloud", display_name="untitled") del word_cloud.data - self.assertEqual(word_cloud.data, '') + self.assertEqual(word_cloud.data, '') # noqa: PT009 # Export the course root_dir = path(mkdtemp_clean()) @@ -424,7 +420,7 @@ def test_empty_data_roundtrip(self): imported_word_cloud = self.store.get_item(course_id.make_usage_key('word_cloud', 'untitled')) # It should now contain empty data - self.assertEqual(imported_word_cloud.data, '') + self.assertEqual(imported_word_cloud.data, '') # noqa: PT009 def test_html_export_roundtrip(self): """ @@ -445,11 +441,11 @@ def test_html_export_roundtrip(self): # get the sample HTML with styling information html_block = self.store.get_item(course_id.make_usage_key('html', 'with_styling')) - self.assertIn('

', html_block.data) + self.assertIn('

', html_block.data) # noqa: PT009 # pylint: disable=line-too-long # get the sample HTML with just a simple tag information html_block = self.store.get_item(course_id.make_usage_key('html', 'just_img')) - self.assertIn('', html_block.data) + self.assertIn('', html_block.data) # noqa: PT009 def test_export_course_without_content_store(self): # Create toy course @@ -483,7 +479,7 @@ def test_export_course_without_content_store(self): 'name': 'vertical_sequential', } ) - self.assertEqual(len(items), 1) + self.assertEqual(len(items), 1) # noqa: PT009 def test_export_course_no_xml_attributes(self): """ @@ -508,7 +504,7 @@ def test_export_course_no_xml_attributes(self): ) # note that it has no `xml_attributes` attribute - self.assertFalse(hasattr(draft_open_assessment, "xml_attributes")) + self.assertFalse(hasattr(draft_open_assessment, "xml_attributes")) # noqa: PT009 # export should still complete successfully root_dir = path(mkdtemp_clean()) @@ -626,9 +622,9 @@ def test_export_on_invalid_displayname(self, invalid_displayname): # Verify that the course has only one asset and it has been added with an invalid asset name. assets, count = content_store.get_all_content_for_course(self.course.id) - self.assertEqual(count, 1) + self.assertEqual(count, 1) # noqa: PT009 display_name = assets[0]['displayname'] - self.assertEqual(display_name, invalid_displayname) + self.assertEqual(display_name, invalid_displayname) # noqa: PT009 # Now export the course to a tempdir and test that it contains assets. The export should pass root_dir = path(mkdtemp_clean()) @@ -639,8 +635,8 @@ def test_export_on_invalid_displayname(self, invalid_displayname): exported_static_files = filesystem.listdir('/') # Verify that only single asset has been exported with the expected asset name. - self.assertTrue(filesystem.exists(exported_asset_name)) - self.assertEqual(len(exported_static_files), 1) + self.assertTrue(filesystem.exists(exported_asset_name)) # noqa: PT009 + self.assertEqual(len(exported_static_files), 1) # noqa: PT009 # Remove tempdir shutil.rmtree(root_dir) @@ -663,7 +659,7 @@ def test_export_with_orphan_vertical(self): # mocking get_item to for drafts. Expect no draft is exported. # Specifically get_item is used in `xmodule.modulestore.xml_exporter._export_drafts` export_draft_dir = OSFS(root_dir / 'test_export/drafts') - self.assertEqual(len(export_draft_dir.listdir('/')), 0) + self.assertEqual(len(export_draft_dir.listdir('/')), 0) # noqa: PT009 # Remove tempdir shutil.rmtree(root_dir) @@ -684,11 +680,11 @@ def test_assets_overwrite(self): # Fetch & verify course assets to be equal to 2. assets, count = content_store.get_all_content_for_course(self.course.id) - self.assertEqual(count, 2) + self.assertEqual(count, 2) # noqa: PT009 # Verify both assets have similar 'displayname' after saving. for asset in assets: - self.assertEqual(asset['displayname'], asset_displayname) + self.assertEqual(asset['displayname'], asset_displayname) # noqa: PT009 # Now export the course to a tempdir and test that it contains assets. root_dir = path(mkdtemp_clean()) @@ -698,8 +694,8 @@ def test_assets_overwrite(self): # Verify that asset have been overwritten during export. filesystem = OSFS(root_dir / 'test_export/static') exported_static_files = filesystem.listdir('/') - self.assertTrue(filesystem.exists(asset_displayname)) - self.assertEqual(len(exported_static_files), 1) + self.assertTrue(filesystem.exists(asset_displayname)) # noqa: PT009 + self.assertEqual(len(exported_static_files), 1) # noqa: PT009 # Remove tempdir shutil.rmtree(root_dir) @@ -713,7 +709,7 @@ def test_edit_unit(self): """Verifies rendering the editor in all the verticals in the given test course""" self._check_verticals([self.vert_loc]) - def _get_draft_counts(self, item): # lint-amnesty, pylint: disable=missing-function-docstring + def _get_draft_counts(self, item): # pylint: disable=missing-function-docstring cnt = 1 if not self.store.has_published_version(item) else 0 for child in item.get_children(): cnt = cnt + self._get_draft_counts(child) @@ -733,14 +729,14 @@ def test_get_items(self): self.course.id, revision=ModuleStoreEnum.RevisionOption.published_only ) items_from_direct_store = [item for item in direct_store_items if item.location == self.problem.location] - self.assertEqual(len(items_from_direct_store), 0) + self.assertEqual(len(items_from_direct_store), 0) # noqa: PT009 # Fetch from the draft store. draft_store_items = self.store.get_items( self.course.id, revision=ModuleStoreEnum.RevisionOption.draft_only ) items_from_draft_store = [item for item in draft_store_items if item.location == self.problem.location] - self.assertEqual(len(items_from_draft_store), 1) + self.assertEqual(len(items_from_draft_store), 1) # noqa: PT009 def test_draft_metadata(self): """ @@ -754,16 +750,16 @@ def test_draft_metadata(self): course = self.store.update_item(course, self.user.id) problem = self.store.get_item(self.problem.location) - self.assertEqual(problem.graceperiod, course.graceperiod) - self.assertNotIn('graceperiod', own_metadata(problem)) + self.assertEqual(problem.graceperiod, course.graceperiod) # noqa: PT009 + self.assertNotIn('graceperiod', own_metadata(problem)) # noqa: PT009 self.store.convert_to_draft(problem.location, self.user.id) # refetch to check metadata problem = self.store.get_item(problem.location) - self.assertEqual(problem.graceperiod, course.graceperiod) - self.assertNotIn('graceperiod', own_metadata(problem)) + self.assertEqual(problem.graceperiod, course.graceperiod) # noqa: PT009 + self.assertNotIn('graceperiod', own_metadata(problem)) # noqa: PT009 # publish block self.store.publish(problem.location, self.user.id) @@ -771,8 +767,8 @@ def test_draft_metadata(self): # refetch to check metadata problem = self.store.get_item(problem.location) - self.assertEqual(problem.graceperiod, course.graceperiod) - self.assertNotIn('graceperiod', own_metadata(problem)) + self.assertEqual(problem.graceperiod, course.graceperiod) # noqa: PT009 + self.assertNotIn('graceperiod', own_metadata(problem)) # noqa: PT009 # put back in draft and change metadata and see if it's now marked as 'own_metadata' self.store.convert_to_draft(problem.location, self.user.id) @@ -780,21 +776,21 @@ def test_draft_metadata(self): new_graceperiod = timedelta(hours=1) - self.assertNotIn('graceperiod', own_metadata(problem)) + self.assertNotIn('graceperiod', own_metadata(problem)) # noqa: PT009 problem.graceperiod = new_graceperiod # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. problem.save() - self.assertIn('graceperiod', own_metadata(problem)) - self.assertEqual(problem.graceperiod, new_graceperiod) + self.assertIn('graceperiod', own_metadata(problem)) # noqa: PT009 + self.assertEqual(problem.graceperiod, new_graceperiod) # noqa: PT009 self.store.update_item(problem, self.user.id) # read back to make sure it reads as 'own-metadata' problem = self.store.get_item(problem.location) - self.assertIn('graceperiod', own_metadata(problem)) - self.assertEqual(problem.graceperiod, new_graceperiod) + self.assertIn('graceperiod', own_metadata(problem)) # noqa: PT009 + self.assertEqual(problem.graceperiod, new_graceperiod) # noqa: PT009 # republish self.store.publish(problem.location, self.user.id) @@ -803,27 +799,27 @@ def test_draft_metadata(self): self.store.convert_to_draft(problem.location, self.user.id) problem = self.store.get_item(problem.location) - self.assertIn('graceperiod', own_metadata(problem)) - self.assertEqual(problem.graceperiod, new_graceperiod) + self.assertIn('graceperiod', own_metadata(problem)) # noqa: PT009 + self.assertEqual(problem.graceperiod, new_graceperiod) # noqa: PT009 def test_get_depth_with_drafts(self): # make sure no draft items have been returned num_drafts = self._get_draft_counts(self.course) - self.assertEqual(num_drafts, 0) + self.assertEqual(num_drafts, 0) # noqa: PT009 # put into draft self.problem = self.store.unpublish(self.problem.location, self.user.id) # make sure we can query that item and verify that it is a draft draft_problem = self.store.get_item(self.problem.location) - self.assertEqual(self.store.has_published_version(draft_problem), False) + self.assertEqual(self.store.has_published_version(draft_problem), False) # noqa: PT009 # now requery with depth course = self.store.get_course(self.course.id, depth=None) # make sure just one draft item have been returned num_drafts = self._get_draft_counts(course) - self.assertEqual(num_drafts, 1) + self.assertEqual(num_drafts, 1) # noqa: PT009 @mock.patch('xmodule.course_block.requests.get') def test_import_textbook_as_content_element(self, mock_get): @@ -834,13 +830,13 @@ def test_import_textbook_as_content_element(self, mock_get): """).strip() self.course.textbooks = [Textbook("Textbook", "https://s3.amazonaws.com/edx-textbooks/guttag_computation_v3/")] course = self.store.update_item(self.course, self.user.id) - self.assertGreater(len(course.textbooks), 0) + self.assertGreater(len(course.textbooks), 0) # noqa: PT009 def test_import_polls(self): items = self.store.get_items(self.course.id, qualifiers={'category': 'poll_question'}) - self.assertGreater(len(items), 0) + self.assertGreater(len(items), 0) # noqa: PT009 # check that there's actually content in the 'question' field - self.assertGreater(len(items[0].question), 0) + self.assertGreater(len(items[0].question), 0) # noqa: PT009 def test_module_preview_in_whitelist(self): """ @@ -851,7 +847,7 @@ def test_module_preview_in_whitelist(self): resp = self.client.get_json( get_url('xblock_view_handler', self.vert_loc, kwargs={'view_name': 'container_preview'}) ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 vertical = self.store.get_item(self.vert_loc) for child in vertical.children: @@ -861,17 +857,17 @@ def test_delete(self): # make sure the parent points to the child object which is to be deleted # need to refetch chapter b/c at the time it was assigned it had no children chapter = self.store.get_item(self.chapter_loc) - self.assertIn(self.seq_loc, chapter.children) + self.assertIn(self.seq_loc, chapter.children) # noqa: PT009 self.client.delete(get_url('xblock_handler', self.seq_loc)) - with self.assertRaises(ItemNotFoundError): + with self.assertRaises(ItemNotFoundError): # noqa: PT027 self.store.get_item(self.seq_loc) chapter = self.store.get_item(self.chapter_loc) # make sure the parent no longer points to the child object which was deleted - self.assertNotIn(self.seq_loc, chapter.children) + self.assertNotIn(self.seq_loc, chapter.children) # noqa: PT009 def test_asset_delete_and_restore(self): """ @@ -881,18 +877,18 @@ def test_asset_delete_and_restore(self): # now try to find it in store, but they should not be there any longer content = contentstore().find(asset_key, throw_on_not_found=False) - self.assertIsNone(content) + self.assertIsNone(content) # noqa: PT009 # now try to find it and the thumbnail in trashcan - should be in there content = contentstore('trashcan').find(asset_key, throw_on_not_found=False) - self.assertIsNotNone(content) + self.assertIsNotNone(content) # noqa: PT009 # let's restore the asset restore_asset_from_trashcan(str(asset_key)) # now try to find it in courseware store, and they should be back after restore content = contentstore('trashcan').find(asset_key, throw_on_not_found=False) - self.assertIsNotNone(content) + self.assertIsNotNone(content) # noqa: PT009 def _delete_asset_in_course(self): """ @@ -915,7 +911,7 @@ def _delete_asset_in_course(self): kwargs={'asset_key_string': str(asset_key)} ) resp = self.client.delete(url) - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 return asset_key @@ -927,22 +923,22 @@ def test_empty_trashcan(self): # make sure there's something in the trashcan all_assets, __ = contentstore('trashcan').get_all_content_for_course(self.course.id) - self.assertGreater(len(all_assets), 0) + self.assertGreater(len(all_assets), 0) # noqa: PT009 # empty the trashcan empty_asset_trashcan([self.course.id]) # make sure trashcan is empty all_assets, count = contentstore('trashcan').get_all_content_for_course(self.course.id) - self.assertEqual(len(all_assets), 0) - self.assertEqual(count, 0) + self.assertEqual(len(all_assets), 0) # noqa: PT009 + self.assertEqual(count, 0) # noqa: PT009 def test_illegal_draft_crud_ops(self): chapter = self.store.get_item(self.chapter_loc) chapter.data = 'chapter data' self.store.update_item(chapter, self.user.id) - with self.assertRaises(InvalidVersionError): + with self.assertRaises(InvalidVersionError): # noqa: PT027 self.store.unpublish(self.chapter_loc, self.user.id) def test_bad_contentstore_request(self): @@ -951,10 +947,10 @@ def test_bad_contentstore_request(self): asset/course key """ resp = self.client.get_html('asset-v1:CDX+123123+2012_Fall+type@asset+block@&invalid.png') - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 404) # noqa: PT009 resp = self.client.get_html('asset-v1:CDX+123123+2012_Fall+type@asset+block@invalid.png') - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 404) # noqa: PT009 @override_waffle_switch(waffle.ENABLE_ACCESSIBILITY_POLICY_PAGE, active=False) def test_disabled_accessibility_page(self): @@ -962,7 +958,7 @@ def test_disabled_accessibility_page(self): Test that accessibility page returns 404 when waffle switch is disabled """ resp = self.client.get_html('/accessibility') - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 404) # noqa: PT009 def test_delete_course(self): """ @@ -976,8 +972,8 @@ def test_delete_course(self): ) contentstore().save(content) assets, count = contentstore().get_all_content_for_course(self.course.id) - self.assertGreater(len(assets), 0) - self.assertGreater(count, 0) + self.assertGreater(len(assets), 0) # noqa: PT009 + self.assertGreater(count, 0) # noqa: PT009 self.store.unpublish(self.vert_loc, self.user.id) @@ -986,14 +982,14 @@ def test_delete_course(self): # assert that there's absolutely no non-draft blocks in the course # this should also include all draft items - with self.assertRaises(ItemNotFoundError): + with self.assertRaises(ItemNotFoundError): # noqa: PT027 self.store.get_items(self.course.id) # assert that all content in the asset library is keeped # in case the course is later restored. assets, count = contentstore().get_all_content_for_course(self.course.id) - self.assertGreater(len(assets), 0) - self.assertGreater(count, 0) + self.assertGreater(len(assets), 0) # noqa: PT009 + self.assertGreater(count, 0) # noqa: PT009 def test_course_handouts_rewrites(self): """ @@ -1009,7 +1005,7 @@ def test_course_handouts_rewrites(self): resp = self.client.get(get_url('xblock_handler', handouts.location)) # make sure we got a successful response - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # check that /static/ has been converted to the full path # note, we know the link it should be because that's what in the 'toy' course in the test data asset_key = self.course.id.make_asset_key('asset', 'handouts_sample_handout.txt') @@ -1028,10 +1024,10 @@ def test_prefetch_children(self): course = self.store.get_course(self.course.id, depth=2, lazy=False) # make sure we pre-fetched a known sequential which should be at depth=2 - self.assertIn(BlockKey.from_usage_key(self.seq_loc), course.runtime.module_data) + self.assertIn(BlockKey.from_usage_key(self.seq_loc), course.runtime.module_data) # noqa: PT009 # make sure we don't have a specific vertical which should be at depth=3 - self.assertNotIn(BlockKey.from_usage_key(self.vert_loc), course.runtime.module_data) + self.assertNotIn(BlockKey.from_usage_key(self.vert_loc), course.runtime.module_data) # noqa: PT009 # Now, test with the branch set to draft. No extra round trips b/c it doesn't go deep enough to get # beyond direct only categories @@ -1042,10 +1038,10 @@ def test_prefetch_children(self): def _check_verticals(self, locations): """ Test getting the editing HTML for each vertical. """ # Assert is here to make sure that the course being tested actually has verticals (units) to check. - self.assertGreater(len(locations), 0) + self.assertGreater(len(locations), 0) # noqa: PT009 for loc in locations: resp = self.client.get_html(get_url('container_handler', loc)) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 @ddt.ddt @@ -1077,7 +1073,7 @@ def assert_created_course(self, number_suffix=None): course_key = _get_course_id(self.store, test_course_data) _create_course(self, course_key, test_course_data) # Verify that the creator is now registered in the course. - self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_key)) # noqa: PT009 return test_course_data def assert_create_course_failed(self, error_message): @@ -1085,9 +1081,9 @@ def assert_create_course_failed(self, error_message): Checks that the course not created. """ resp = self.client.ajax_post('/course/', self.course_data) - self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.status_code, 400) # noqa: PT009 data = parse_json(resp) - self.assertEqual(data['error'], error_message) + self.assertEqual(data['error'], error_message) # noqa: PT009 def test_create_course(self): """Test new course creation - happy path""" @@ -1121,7 +1117,7 @@ def test_create_course__default_enable_flexible_peer_openassessments( new_course = self.store.get_course(new_course_key) # ... and our setting got toggled appropriately on the course - self.assertEqual(new_course.force_on_flexible_peer_openassessments, mock_toggle_state) + self.assertEqual(new_course.force_on_flexible_peer_openassessments, mock_toggle_state) # noqa: PT009 @override_settings(DEFAULT_COURSE_LANGUAGE='hr') def test_create_course_default_language(self): @@ -1129,7 +1125,7 @@ def test_create_course_default_language(self): test_course_data = self.assert_created_course() course_id = _get_course_id(self.store, test_course_data) course_block = self.store.get_course(course_id) - self.assertEqual(course_block.language, 'hr') + self.assertEqual(course_block.language, 'hr') # noqa: PT009 def test_create_course_with_dots(self): """Test new course creation with dots in the name""" @@ -1155,16 +1151,16 @@ def test_course_with_different_cases(self): def test_create_course_check_forum_seeding(self): """Test new course creation and verify forum seeding """ test_course_data = self.assert_created_course(number_suffix=uuid4().hex) - self.assertTrue(are_permissions_roles_seeded(_get_course_id(self.store, test_course_data))) + self.assertTrue(are_permissions_roles_seeded(_get_course_id(self.store, test_course_data))) # noqa: PT009 def test_forum_unseeding_on_delete(self): """Test new course creation and verify forum unseeding """ test_course_data = self.assert_created_course(number_suffix=uuid4().hex) course_id = _get_course_id(self.store, test_course_data) - self.assertTrue(are_permissions_roles_seeded(course_id)) + self.assertTrue(are_permissions_roles_seeded(course_id)) # noqa: PT009 delete_course(course_id, self.user.id) # should raise an exception for checking permissions on deleted course - with self.assertRaises(ItemNotFoundError): + with self.assertRaises(ItemNotFoundError): # noqa: PT027 are_permissions_roles_seeded(course_id) def test_forum_unseeding_with_multiple_courses(self): @@ -1176,12 +1172,12 @@ def test_forum_unseeding_with_multiple_courses(self): course_id = _get_course_id(self.store, test_course_data) delete_course(course_id, self.user.id) # should raise an exception for checking permissions on deleted course - with self.assertRaises(ItemNotFoundError): + with self.assertRaises(ItemNotFoundError): # noqa: PT027 are_permissions_roles_seeded(course_id) second_course_id = _get_course_id(self.store, second_course_data) # permissions should still be there for the other course - self.assertTrue(are_permissions_roles_seeded(second_course_id)) + self.assertTrue(are_permissions_roles_seeded(second_course_id)) # noqa: PT009 def test_course_enrollments_and_roles_on_delete(self): """ @@ -1191,14 +1187,14 @@ def test_course_enrollments_and_roles_on_delete(self): course_id = _get_course_id(self.store, test_course_data) # test that a user gets his enrollment and its 'student' role as default on creating a course - self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id)) - self.assertTrue(self.user.roles.filter(name="Student", course_id=course_id)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id)) # noqa: PT009 + self.assertTrue(self.user.roles.filter(name="Student", course_id=course_id)) # noqa: PT009 delete_course(course_id, self.user.id) # check that user's enrollment for this course is not deleted - self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, course_id)) # noqa: PT009 # check that user has form role "Student" for this course even after deleting it - self.assertTrue(self.user.roles.filter(name="Student", course_id=course_id)) + self.assertTrue(self.user.roles.filter(name="Student", course_id=course_id)) # noqa: PT009 def test_course_access_groups_on_delete(self): """ @@ -1213,7 +1209,7 @@ def test_course_access_groups_on_delete(self): auth.add_users(self.user, instructor_role, self.user) - self.assertGreater(len(instructor_role.users_with_role()), 0) + self.assertGreater(len(instructor_role.users_with_role()), 0) # noqa: PT009 # Now delete course and check that user not in instructor groups of this course delete_course(course_id, self.user.id) @@ -1221,8 +1217,8 @@ def test_course_access_groups_on_delete(self): # Update our cached user since its roles have changed self.user = User.objects.get_by_natural_key(self.user.natural_key()[0]) - self.assertFalse(instructor_role.has_user(self.user)) - self.assertEqual(len(instructor_role.users_with_role()), 0) + self.assertFalse(instructor_role.has_user(self.user)) # noqa: PT009 + self.assertEqual(len(instructor_role.users_with_role()), 0) # noqa: PT009 def test_delete_course_with_keep_instructors(self): """ @@ -1235,14 +1231,14 @@ def test_delete_course_with_keep_instructors(self): # Add and verify instructor role for the course instructor_role = CourseInstructorRole(course_id) instructor_role.add_users(self.user) - self.assertTrue(instructor_role.has_user(self.user)) + self.assertTrue(instructor_role.has_user(self.user)) # noqa: PT009 delete_course(course_id, self.user.id, keep_instructors=True) # Update our cached user so if any change in roles can be captured self.user = User.objects.get_by_natural_key(self.user.natural_key()[0]) - self.assertTrue(instructor_role.has_user(self.user)) + self.assertTrue(instructor_role.has_user(self.user)) # noqa: PT009 def test_create_course_after_delete(self): """ @@ -1273,13 +1269,14 @@ def assert_course_creation_failed(self, error_message): # b/c the intent of the test with bad chars isn't to test auth but to test the handler, ignore pass resp = self.client.ajax_post('/course/', self.course_data) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 data = parse_json(resp) - self.assertRegex(data['ErrMsg'], error_message) + assert 'ErrMsg' in data, "Expected the course creation to fail" + self.assertRegex(data['ErrMsg'], error_message) # noqa: PT009 if test_enrollment: # One test case involves trying to create the same course twice. Hence for that course, # the user will be enrolled. In the other cases, initially_enrolled will be False. - self.assertEqual(initially_enrolled, CourseEnrollment.is_enrolled(self.user, course_id)) + self.assertEqual(initially_enrolled, CourseEnrollment.is_enrolled(self.user, course_id)) # noqa: PT009 def test_create_course_duplicate_number(self): """Test new course creation - error path""" @@ -1325,11 +1322,11 @@ def test_course_substring(self): cache_current = self.course_data['number'] self.course_data['number'] = '{}a'.format(self.course_data['number']) resp = self.client.ajax_post('/course/', self.course_data) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 self.course_data['number'] = cache_current self.course_data['org'] = 'a{}'.format(self.course_data['org']) resp = self.client.ajax_post('/course/', self.course_data) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" @@ -1386,18 +1383,18 @@ def assert_course_permission_denied(self): Checks that the course did not get created due to a PermissionError. """ resp = self.client.ajax_post('/course/', self.course_data) - self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.status_code, 403) # noqa: PT009 def test_course_factory(self): """Test that the course factory works correctly.""" course = CourseFactory.create() - self.assertIsInstance(course, CourseBlock) + self.assertIsInstance(course, CourseBlock) # noqa: PT009 def test_item_factory(self): """Test that the item factory works correctly.""" course = CourseFactory.create() item = BlockFactory.create(parent_location=course.location) - self.assertIsInstance(item, SequenceBlock) + self.assertIsInstance(item, SequenceBlock) # noqa: PT009 def test_create_block(self): """Test creating a new xblock instance.""" @@ -1411,12 +1408,12 @@ def test_create_block(self): resp = self.client.ajax_post(reverse_url('xblock_handler'), section_data) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 data = parse_json(resp) retarget = re.escape( str(course.id.make_usage_key('chapter', 'REPLACE')) ).replace('REPLACE', r'([0-9]|[a-f]){3,}') - self.assertRegex(data['locator'], retarget) + self.assertRegex(data['locator'], retarget) # noqa: PT009 @ddt.data(True, False) def test_hide_xblock_from_toc_via_handler(self, hide_from_toc): @@ -1432,8 +1429,8 @@ def test_hide_xblock_from_toc_via_handler(self, hide_from_toc): response = self.client.ajax_post(get_url("xblock_handler", sequential.location), data) sequential = self.store.get_item(sequential.location) - self.assertEqual(response.status_code, 200) - self.assertEqual(hide_from_toc, sequential.hide_from_toc) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertEqual(hide_from_toc, sequential.hide_from_toc) # noqa: PT009 def test_capa_block(self): """Test that a problem treats markdown specially.""" @@ -1445,12 +1442,12 @@ def test_capa_block(self): } resp = self.client.ajax_post(reverse_url('xblock_handler'), problem_data) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 payload = parse_json(resp) problem_loc = UsageKey.from_string(payload['locator']) problem = self.store.get_item(problem_loc) - self.assertIsInstance(problem, ProblemBlock, "New problem is not a ProblemBlock") - self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") # lint-amnesty, pylint: disable=line-too-long + self.assertIsInstance(problem, ProblemBlock, "New problem is not a ProblemBlock") # noqa: PT009 + self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") # pylint: disable=line-too-long # noqa: PT009 def test_cms_imported_course_walkthrough(self): """ @@ -1463,7 +1460,7 @@ def test_get_html(handler): resp = self.client.get_html( get_url(handler, course_key, 'course_key_string') ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def test_get_json(handler): # Helper function for getting HTML for a page in Studio and @@ -1472,7 +1469,7 @@ def test_get_json(handler): get_url(handler, course_key, 'course_key_string'), HTTP_ACCEPT="application/json", ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 course_items = import_course_from_xml( self.store, self.user.id, TEST_DATA_DIR, ['simple'], create_if_not_present=True @@ -1483,25 +1480,29 @@ def test_get_json(handler): # course_handler raise 404 for old mongo course if course_key.deprecated: - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 404) # noqa: PT009 return - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 self.assertContains(resp, 'Chapter 2') # go to various pages - with override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True): - test_get_html('import_handler') - with override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True): - test_get_html('export_handler') - with override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True): - test_get_html('course_team_handler') - with override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True): - test_get_html('settings_handler') - with override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True): - test_get_html('grading_handler') - with override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True): - test_get_html('advanced_settings_handler') + with override_settings(COURSE_AUTHORING_MICROFRONTEND_URL='https://mfe.example'): + resp = self.client.get_html(get_url('import_handler', course_key, 'course_key_string')) + self.assertEqual(resp.status_code, 302) # noqa: PT009 + resp = self.client.get_html(get_url('export_handler', course_key, 'course_key_string')) + self.assertEqual(resp.status_code, 302) # noqa: PT009 + resp = self.client.get_html(get_url('course_team_handler', course_key, 'course_key_string')) + self.assertEqual(resp.status_code, 302) # noqa: PT009 + with override_settings(COURSE_AUTHORING_MICROFRONTEND_URL='https://mfe.example'): + resp = self.client.get_html(get_url('settings_handler', course_key, 'course_key_string')) + self.assertEqual(resp.status_code, 302) # noqa: PT009 + with override_settings(COURSE_AUTHORING_MICROFRONTEND_URL='https://mfe.example'): + resp = self.client.get_html(get_url('grading_handler', course_key, 'course_key_string')) + self.assertEqual(resp.status_code, 302) # noqa: PT009 + with override_settings(COURSE_AUTHORING_MICROFRONTEND_URL='https://mfe.example'): + resp = self.client.get_html(get_url('advanced_settings_handler', course_key, 'course_key_string')) + self.assertEqual(resp.status_code, 302) # noqa: PT009 test_get_json('textbooks_list_handler') # Test that studio updates load @@ -1517,19 +1518,19 @@ def test_get_json(handler): resp = self.client.get( get_url('cms.djangoapps.contentstore:v0:course_tab_list', course_key, 'course_id') ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # go look at the Edit page unit_key = course_key.make_usage_key('vertical', 'test_vertical') with override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True): resp = self.client.get_html(get_url('container_handler', unit_key)) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def delete_item(category, name): """ Helper method for testing the deletion of an xblock item. """ item_key = course_key.make_usage_key(category, name) resp = self.client.delete(get_url('xblock_handler', item_key)) - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 # delete a component delete_item(category='html', name='test_html') @@ -1553,7 +1554,7 @@ def test_import_into_new_course_id(self): # we should have a number of blocks in there # we can't specify an exact number since it'll always be changing - self.assertGreater(len(blocks), 10) + self.assertGreater(len(blocks), 10) # noqa: PT009 # # test various re-namespacing elements @@ -1562,10 +1563,10 @@ def test_import_into_new_course_id(self): # first check PDF textbooks, to make sure the url paths got updated course_block = self.store.get_course(target_id) - self.assertEqual(len(course_block.pdf_textbooks), 1) - self.assertEqual(len(course_block.pdf_textbooks[0]["chapters"]), 2) - self.assertEqual(course_block.pdf_textbooks[0]["chapters"][0]["url"], '/static/Chapter1.pdf') - self.assertEqual(course_block.pdf_textbooks[0]["chapters"][1]["url"], '/static/Chapter2.pdf') + self.assertEqual(len(course_block.pdf_textbooks), 1) # noqa: PT009 + self.assertEqual(len(course_block.pdf_textbooks[0]["chapters"]), 2) # noqa: PT009 + self.assertEqual(course_block.pdf_textbooks[0]["chapters"][0]["url"], '/static/Chapter1.pdf') # noqa: PT009 + self.assertEqual(course_block.pdf_textbooks[0]["chapters"][1]["url"], '/static/Chapter2.pdf') # noqa: PT009 def test_import_into_new_course_id_wiki_slug_renamespacing(self): # If reimporting into the same course change the wiki_slug. @@ -1584,7 +1585,7 @@ def test_import_into_new_course_id_wiki_slug_renamespacing(self): # Import a course with wiki_slug == location.course import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_id=target_id) course_block = self.store.get_course(target_id) - self.assertEqual(course_block.wiki_slug, 'edX.toy.2012_Fall') + self.assertEqual(course_block.wiki_slug, 'edX.toy.2012_Fall') # noqa: PT009 # But change the wiki_slug if it is a different course. target_id = self.store.make_course_key('MITx', '111', '2013_Spring') @@ -1599,12 +1600,12 @@ def test_import_into_new_course_id_wiki_slug_renamespacing(self): # Import a course with wiki_slug == location.course import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_id=target_id) course_block = self.store.get_course(target_id) - self.assertEqual(course_block.wiki_slug, 'MITx.111.2013_Spring') + self.assertEqual(course_block.wiki_slug, 'MITx.111.2013_Spring') # noqa: PT009 # Now try importing a course with wiki_slug == '{0}.{1}.{2}'.format(location.org, location.course, location.run) import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['two_toys'], target_id=target_id) course_block = self.store.get_course(target_id) - self.assertEqual(course_block.wiki_slug, 'MITx.111.2013_Spring') + self.assertEqual(course_block.wiki_slug, 'MITx.111.2013_Spring') # noqa: PT009 def test_import_metadata_with_attempts_empty_string(self): import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['simple'], create_if_not_present=True) @@ -1618,7 +1619,7 @@ def test_import_metadata_with_attempts_empty_string(self): pass # make sure we found the item (e.g. it didn't error while loading) - self.assertTrue(did_load_item) + self.assertTrue(did_load_item) # noqa: PT009 def test_forum_id_generation(self): """ @@ -1637,11 +1638,11 @@ def test_forum_id_generation(self): refetched = self.store.get_item(discussion_item.location) # and make sure the same discussion items have the same discussion ids - self.assertEqual(fetched.discussion_id, discussion_item.discussion_id) - self.assertEqual(fetched.discussion_id, refetched.discussion_id) + self.assertEqual(fetched.discussion_id, discussion_item.discussion_id) # noqa: PT009 + self.assertEqual(fetched.discussion_id, refetched.discussion_id) # noqa: PT009 # and make sure that the id isn't the old "$$GUID$$" - self.assertNotEqual(discussion_item.discussion_id, '$$GUID$$') + self.assertNotEqual(discussion_item.discussion_id, '$$GUID$$') # noqa: PT009 def test_metadata_inheritance(self): course_items = import_course_from_xml( @@ -1653,10 +1654,10 @@ def test_metadata_inheritance(self): # let's assert on the metadata_inheritance on an existing vertical for vertical in verticals: - self.assertEqual(course.xqa_key, vertical.xqa_key) - self.assertEqual(course.start, vertical.start) + self.assertEqual(course.xqa_key, vertical.xqa_key) # noqa: PT009 + self.assertEqual(course.start, vertical.start) # noqa: PT009 - self.assertGreater(len(verticals), 0) + self.assertGreater(len(verticals), 0) # noqa: PT009 # crate a new block and add it as a child to a vertical parent = verticals[0] @@ -1668,11 +1669,11 @@ def test_metadata_inheritance(self): new_block = self.store.get_item(new_block.location) # check for grace period definition which should be defined at the course level - self.assertEqual(parent.graceperiod, new_block.graceperiod) - self.assertEqual(parent.start, new_block.start) - self.assertEqual(course.start, new_block.start) + self.assertEqual(parent.graceperiod, new_block.graceperiod) # noqa: PT009 + self.assertEqual(parent.start, new_block.start) # noqa: PT009 + self.assertEqual(course.start, new_block.start) # noqa: PT009 - self.assertEqual(course.xqa_key, new_block.xqa_key) + self.assertEqual(course.xqa_key, new_block.xqa_key) # noqa: PT009 # # now let's define an override at the leaf node level @@ -1683,26 +1684,26 @@ def test_metadata_inheritance(self): # flush the cache and refetch new_block = self.store.get_item(new_block.location) - self.assertEqual(timedelta(1), new_block.graceperiod) + self.assertEqual(timedelta(1), new_block.graceperiod) # noqa: PT009 def test_default_metadata_inheritance(self): course = CourseFactory.create() vertical = BlockFactory.create(parent_location=course.location) course.children.append(vertical) # in memory - self.assertIsNotNone(course.start) - self.assertEqual(course.start, vertical.start) - self.assertEqual(course.textbooks, []) - self.assertIn('GRADER', course.grading_policy) - self.assertIn('GRADE_CUTOFFS', course.grading_policy) + self.assertIsNotNone(course.start) # noqa: PT009 + self.assertEqual(course.start, vertical.start) # noqa: PT009 + self.assertEqual(course.textbooks, []) # noqa: PT009 + self.assertIn('GRADER', course.grading_policy) # noqa: PT009 + self.assertIn('GRADE_CUTOFFS', course.grading_policy) # noqa: PT009 # by fetching fetched_course = self.store.get_item(course.location) fetched_item = self.store.get_item(vertical.location) - self.assertIsNotNone(fetched_course.start) - self.assertEqual(course.start, fetched_course.start) - self.assertEqual(fetched_course.start, fetched_item.start) - self.assertEqual(course.textbooks, fetched_course.textbooks) + self.assertIsNotNone(fetched_course.start) # noqa: PT009 + self.assertEqual(course.start, fetched_course.start) # noqa: PT009 + self.assertEqual(fetched_course.start, fetched_item.start) # noqa: PT009 + self.assertEqual(course.textbooks, fetched_course.textbooks) # noqa: PT009 def test_image_import(self): """Test backwards compatibilty of course image.""" @@ -1721,7 +1722,7 @@ def test_image_import(self): course = courses[0] # Make sure the course image is set to the right place - self.assertEqual(course.course_image, 'images_course_image.jpg') + self.assertEqual(course.course_image, 'images_course_image.jpg') # noqa: PT009 # Ensure that the imported course image is present -- this shouldn't raise an exception asset_key = course.id.make_asset_key('asset', course.course_image) @@ -1741,13 +1742,13 @@ def test_wiki_slug(self): course_key = _get_course_id(self.store, self.course_data) _create_course(self, course_key, self.course_data) course_block = self.store.get_course(course_key) - self.assertEqual(course_block.wiki_slug, 'MITx.111.2013_Spring') + self.assertEqual(course_block.wiki_slug, 'MITx.111.2013_Spring') # noqa: PT009 def test_course_handler_with_invalid_course_key_string(self): """Test viewing the course overview page with invalid course id""" response = self.client.get_html('/course/edX/test') - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 404) # noqa: PT009 class MetadataSaveTestCase(ContentStoreTestCase): @@ -1780,7 +1781,7 @@ def test_metadata_not_persistence(self): Test that blocks which set metadata fields in their constructor are correctly deleted. """ - self.assertIn('html5_sources', own_metadata(self.video_block)) + self.assertIn('html5_sources', own_metadata(self.video_block)) # noqa: PT009 attrs_to_strip = { 'show_captions', 'youtube_id_1_0', @@ -1798,11 +1799,11 @@ def test_metadata_not_persistence(self): for field_name in attrs_to_strip: delattr(self.video_block, field_name) - self.assertNotIn('html5_sources', own_metadata(self.video_block)) + self.assertNotIn('html5_sources', own_metadata(self.video_block)) # noqa: PT009 self.store.update_item(self.video_block, self.user.id) block = self.store.get_item(location) - self.assertNotIn('html5_sources', own_metadata(block)) + self.assertNotIn('html5_sources', own_metadata(block)) # noqa: PT009 def test_metadata_persistence(self): # TODO: create the same test as `test_metadata_not_persistence`, @@ -1841,10 +1842,10 @@ def post_rerun_request( response = self.client.ajax_post(course_url, rerun_course_data) # verify response - self.assertEqual(response.status_code, response_code) + self.assertEqual(response.status_code, response_code) # noqa: PT009 if not expect_error: json_resp = parse_json(response) - self.assertNotIn('ErrMsg', json_resp) + self.assertNotIn('ErrMsg', json_resp) # noqa: PT009 destination_course_key = CourseKey.from_string(json_resp['destination_course_key']) return destination_course_key @@ -1886,10 +1887,10 @@ def verify_rerun_course(self, source_course_key, destination_course_key, destina 'should_display': True, } for field_name, expected_value in expected_states.items(): - self.assertEqual(getattr(rerun_state, field_name), expected_value) + self.assertEqual(getattr(rerun_state, field_name), expected_value) # noqa: PT009 # Verify that the creator is now enrolled in the course. - self.assertTrue(CourseEnrollment.is_enrolled(self.user, destination_course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, destination_course_key)) # noqa: PT009 # Verify both courses are in the course listing section self.assertInCourseListing(source_course_key) @@ -1904,7 +1905,7 @@ def test_rerun_course_no_videos_in_val(self): self.verify_rerun_course(source_course.id, destination_course_key, self.destination_course_data['display_name']) videos, __ = get_videos_for_course(str(destination_course_key)) videos = list(videos) - self.assertEqual(0, len(videos)) + self.assertEqual(0, len(videos)) # noqa: PT009 self.assertInCourseListing(destination_course_key) def test_rerun_course_video_upload_token(self): @@ -1924,8 +1925,8 @@ def test_rerun_course_video_upload_token(self): # Verify video upload pipeline is empty. source_course = self.store.get_course(source_course.id) new_course = self.store.get_course(destination_course_key) - self.assertDictEqual(source_course.video_upload_pipeline, {"course_video_upload_token": 'test-token'}) - self.assertEqual(new_course.video_upload_pipeline, {}) + self.assertDictEqual(source_course.video_upload_pipeline, {"course_video_upload_token": 'test-token'}) # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(new_course.video_upload_pipeline, {}) # noqa: PT009 def test_rerun_course_success(self): source_course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) @@ -1946,12 +1947,12 @@ def test_rerun_course_success(self): source_videos = list(videos) videos, __ = get_videos_for_course(str(destination_course_key)) target_videos = list(videos) - self.assertEqual(1, len(source_videos)) - self.assertEqual(source_videos, target_videos) + self.assertEqual(1, len(source_videos)) # noqa: PT009 + self.assertEqual(source_videos, target_videos) # noqa: PT009 # Verify that video upload token is empty for rerun. new_course = self.store.get_course(destination_course_key) - self.assertEqual(new_course.video_upload_pipeline, {}) + self.assertEqual(new_course.video_upload_pipeline, {}) # noqa: PT009 def test_rerun_course_resets_advertised_date(self): source_course = CourseFactory.create( @@ -1961,7 +1962,7 @@ def test_rerun_course_resets_advertised_date(self): destination_course_key = self.post_rerun_request(source_course.id) destination_course = self.store.get_course(destination_course_key) - self.assertEqual(None, destination_course.advertised_start) + self.assertEqual(None, destination_course.advertised_start) # noqa: PT009 def test_rerun_of_rerun(self): source_course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) @@ -1982,11 +1983,11 @@ def test_rerun_course_fail_no_source_course(self): # Verify that the course rerun action is marked failed rerun_state = CourseRerunState.objects.find_first(course_key=destination_course_key) - self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.FAILED) - self.assertIn("Cannot find a course at", rerun_state.message) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.FAILED) # noqa: PT009 + self.assertIn("Cannot find a course at", rerun_state.message) # noqa: PT009 # Verify that the creator is not enrolled in the course. - self.assertFalse(CourseEnrollment.is_enrolled(self.user, non_existent_course_key)) + self.assertFalse(CourseEnrollment.is_enrolled(self.user, non_existent_course_key)) # noqa: PT009 # Verify that the existing course continues to be in the course listings self.assertInCourseListing(existent_course_key) @@ -2007,7 +2008,7 @@ def test_rerun_course_fail_duplicate_course(self): ) # Verify that the course rerun action doesn't exist - with self.assertRaises(CourseActionStateItemNotFoundError): + with self.assertRaises(CourseActionStateItemNotFoundError): # noqa: PT027 CourseRerunState.objects.find_first(course_key=destination_course_key) # Verify that the existing course continues to be in the course listing @@ -2030,8 +2031,8 @@ def test_rerun_error(self): source_course = CourseFactory.create() destination_course_key = self.post_rerun_request(source_course.id) rerun_state = CourseRerunState.objects.find_first(course_key=destination_course_key) - self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.FAILED) - self.assertIn(error_message, rerun_state.message) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.FAILED) # noqa: PT009 + self.assertIn(error_message, rerun_state.message) # noqa: PT009 def test_rerun_error_trunc_message(self): """ @@ -2049,9 +2050,9 @@ def test_rerun_error_trunc_message(self): with mock.patch('traceback.format_exc', return_value=message_too_long): destination_course_key = self.post_rerun_request(source_course.id) rerun_state = CourseRerunState.objects.find_first(course_key=destination_course_key) - self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.FAILED) - self.assertTrue(rerun_state.message.endswith("traceback")) - self.assertEqual(len(rerun_state.message), CourseRerunState.MAX_MESSAGE_LENGTH) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.FAILED) # noqa: PT009 + self.assertTrue(rerun_state.message.endswith("traceback")) # noqa: PT009 + self.assertEqual(len(rerun_state.message), CourseRerunState.MAX_MESSAGE_LENGTH) # noqa: PT009 def test_rerun_course_wiki_slug(self): """ @@ -2072,7 +2073,7 @@ def test_rerun_course_wiki_slug(self): source_course = self.store.get_course(source_course_key) # Verify created course's wiki_slug. - self.assertEqual(source_course.wiki_slug, source_wiki_slug) + self.assertEqual(source_course.wiki_slug, source_wiki_slug) # noqa: PT009 destination_course_data = course_data destination_course_data['run'] = '2013_Rerun' @@ -2083,12 +2084,12 @@ def test_rerun_course_wiki_slug(self): self.verify_rerun_course(source_course.id, destination_course_key, destination_course_data['display_name']) destination_course = self.store.get_course(destination_course_key) - destination_wiki_slug = '{}.{}.{}'.format( + destination_wiki_slug = '{}.{}.{}'.format( # noqa: UP032 destination_course.id.org, destination_course.id.course, destination_course.id.run ) # Verify rerun course's wiki_slug. - self.assertEqual(destination_course.wiki_slug, destination_wiki_slug) + self.assertEqual(destination_course.wiki_slug, destination_wiki_slug) # noqa: PT009 class ContentLicenseTest(ContentStoreTestCase): @@ -2106,7 +2107,7 @@ def test_course_license_export(self): run_file_path = root_dir / "test_license" / "course" / fname with run_file_path.open() as f: run_xml = etree.parse(f) - self.assertEqual(run_xml.getroot().get("license"), "creative-commons: BY SA") + self.assertEqual(run_xml.getroot().get("license"), "creative-commons: BY SA") # noqa: PT009 def test_video_license_export(self): content_store = contentstore() @@ -2120,16 +2121,16 @@ def test_video_license_export(self): video_file_path = root_dir / "test_license" / "video" / fname with video_file_path.open() as f: video_xml = etree.parse(f) - self.assertEqual(video_xml.getroot().get("license"), "all-rights-reserved") + self.assertEqual(video_xml.getroot().get("license"), "all-rights-reserved") # noqa: PT009 def test_license_import(self): course_items = import_course_from_xml( self.store, self.user.id, TEST_DATA_DIR, ['toy'], create_if_not_present=True ) course = course_items[0] - self.assertEqual(course.license, "creative-commons: BY") + self.assertEqual(course.license, "creative-commons: BY") # noqa: PT009 videos = self.store.get_items(course.id, qualifiers={'category': 'video'}) - self.assertEqual(videos[0].license, "all-rights-reserved") + self.assertEqual(videos[0].license, "all-rights-reserved") # noqa: PT009 class EntryPageTestCase(TestCase): @@ -2143,14 +2144,15 @@ def setUp(self): def _test_page(self, page, status_code=200): resp = self.client.get_html(page) - self.assertEqual(resp.status_code, status_code) + self.assertEqual(resp.status_code, status_code) # noqa: PT009 - @override_waffle_flag(toggles.LEGACY_STUDIO_LOGGED_OUT_HOME, True) - def test_how_it_works_legacy(self): - self._test_page("/howitworks") + def test_homepage_redirects_to_home(self): + # Root URL permanently redirects to studio home (sign-in page). + self._test_page("/", 301) - def test_how_it_works_redirect_to_signin(self): - self._test_page("/howitworks", 302) + def test_howitworks_redirects_to_home(self): + # Legacy landing page permanently redirects; preserves bookmarks. + self._test_page("/howitworks", 301) def test_signup(self): # deprecated signup url redirects to LMS register. @@ -2177,10 +2179,10 @@ def _create_course(test, course_key, course_data): """ course_url = get_url('course_handler', course_key, 'course_key_string') response = test.client.ajax_post(course_url, course_data) - test.assertEqual(response.status_code, 200) + test.assertEqual(response.status_code, 200) # noqa: PT009 data = parse_json(response) - test.assertNotIn('ErrMsg', data) - test.assertEqual(data['url'], course_url) + test.assertNotIn('ErrMsg', data) # noqa: PT009 + test.assertEqual(data['url'], course_url) # noqa: PT009 return data diff --git a/cms/djangoapps/contentstore/tests/test_core_caching.py b/cms/djangoapps/contentstore/tests/test_core_caching.py index e6b23c2deeed..956397214a64 100644 --- a/cms/djangoapps/contentstore/tests/test_core_caching.py +++ b/cms/djangoapps/contentstore/tests/test_core_caching.py @@ -32,15 +32,15 @@ class CachingTestCase(TestCase): def test_put_and_get(self): set_cached_content(self.mockAsset) - self.assertEqual(self.mockAsset.content, get_cached_content(self.unicodeLocation).content, + self.assertEqual(self.mockAsset.content, get_cached_content(self.unicodeLocation).content, # noqa: PT009 'should be stored in cache with unicodeLocation') - self.assertEqual(self.mockAsset.content, get_cached_content(self.nonUnicodeLocation).content, + self.assertEqual(self.mockAsset.content, get_cached_content(self.nonUnicodeLocation).content, # noqa: PT009 'should be stored in cache with nonUnicodeLocation') def test_delete(self): set_cached_content(self.mockAsset) del_cached_content(self.nonUnicodeLocation) - self.assertEqual(None, get_cached_content(self.unicodeLocation), + self.assertEqual(None, get_cached_content(self.unicodeLocation), # noqa: PT009 'should not be stored in cache with unicodeLocation') - self.assertEqual(None, get_cached_content(self.nonUnicodeLocation), + self.assertEqual(None, get_cached_content(self.nonUnicodeLocation), # noqa: PT009 'should not be stored in cache with nonUnicodeLocation') diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index a03e8e9cd2f5..fbcf56067ac1 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -14,20 +14,22 @@ from django.test.client import RequestFactory from django.urls import reverse from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.roles import COURSE_EDITOR from organizations.api import add_organization, get_course_organizations, get_organization_by_short_name from organizations.exceptions import InvalidOrganizationException from organizations.models import Organization -from xmodule.course_block import CourseFields -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json +from cms.djangoapps.contentstore.views.course import get_allowed_organizations, user_can_create_organizations from cms.djangoapps.course_creators.admin import CourseCreatorAdmin from cms.djangoapps.course_creators.models import CourseCreator -from cms.djangoapps.contentstore.views.course import get_allowed_organizations, user_can_create_organizations from common.djangoapps.student.auth import update_org_role from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, OrgContentCreatorRole from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from xmodule.course_block import CourseFields +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory def mock_render_to_string(template_name, context): @@ -99,19 +101,19 @@ def test_rerun(self): 'org': self.source_course_key.org, 'course': self.source_course_key.course, 'run': 'copy', 'display_name': 'not the same old name', }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 data = parse_json(response) dest_course_key = CourseKey.from_string(data['destination_course_key']) - self.assertEqual(dest_course_key.run, 'copy') + self.assertEqual(dest_course_key.run, 'copy') # noqa: PT009 dest_course = self.store.get_course(dest_course_key) - self.assertEqual(dest_course.start, CourseFields.start.default) - self.assertEqual(dest_course.end, None) - self.assertEqual(dest_course.enrollment_start, None) - self.assertEqual(dest_course.enrollment_end, None) + self.assertEqual(dest_course.start, CourseFields.start.default) # noqa: PT009 + self.assertEqual(dest_course.end, None) # noqa: PT009 + self.assertEqual(dest_course.enrollment_start, None) # noqa: PT009 + self.assertEqual(dest_course.enrollment_end, None) # noqa: PT009 course_orgs = get_course_organizations(dest_course_key) - self.assertEqual(len(course_orgs), 1) - self.assertEqual(course_orgs[0]['short_name'], self.source_course_key.org) + self.assertEqual(len(course_orgs), 1) # noqa: PT009 + self.assertEqual(course_orgs[0]['short_name'], self.source_course_key.org) # noqa: PT009 def test_newly_created_course_has_web_certs_enabled(self): """ @@ -123,11 +125,11 @@ def test_newly_created_course_has_web_certs_enabled(self): 'display_name': 'Course with web certs enabled', 'run': '2015_T2' }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 data = parse_json(response) new_course_key = CourseKey.from_string(data['course_key']) course = self.store.get_course(new_course_key) - self.assertTrue(course.cert_html_view_enabled) + self.assertTrue(course.cert_html_view_enabled) # noqa: PT009 def test_course_creation_for_unknown_organization_relaxed(self): """ @@ -135,7 +137,7 @@ def test_course_creation_for_unknown_organization_relaxed(self): creating a course-run with an unknown org slug will create an organization and organization-course linkage in the system. """ - with self.assertRaises(InvalidOrganizationException): + with self.assertRaises(InvalidOrganizationException): # noqa: PT027 get_organization_by_short_name("orgX") response = self.client.ajax_post(self.course_create_rerun_url, { 'org': 'orgX', @@ -143,13 +145,13 @@ def test_course_creation_for_unknown_organization_relaxed(self): 'display_name': 'Course with web certs enabled', 'run': '2015_T2' }) - self.assertEqual(response.status_code, 200) - self.assertIsNotNone(get_organization_by_short_name("orgX")) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertIsNotNone(get_organization_by_short_name("orgX")) # noqa: PT009 data = parse_json(response) new_course_key = CourseKey.from_string(data['course_key']) course_orgs = get_course_organizations(new_course_key) - self.assertEqual(len(course_orgs), 1) - self.assertEqual(course_orgs[0]['short_name'], 'orgX') + self.assertEqual(len(course_orgs), 1) # noqa: PT009 + self.assertEqual(course_orgs[0]['short_name'], 'orgX') # noqa: PT009 @override_settings(ORGANIZATIONS_AUTOCREATE=False) def test_course_creation_for_unknown_organization_strict(self): @@ -163,11 +165,11 @@ def test_course_creation_for_unknown_organization_strict(self): 'display_name': 'Course with web certs enabled', 'run': '2015_T2' }) - self.assertEqual(response.status_code, 400) - with self.assertRaises(InvalidOrganizationException): + self.assertEqual(response.status_code, 400) # noqa: PT009 + with self.assertRaises(InvalidOrganizationException): # noqa: PT027 get_organization_by_short_name("orgX") data = parse_json(response) - self.assertIn('Organization you selected does not exist in the system', data['error']) + self.assertIn('Organization you selected does not exist in the system', data['error']) # noqa: PT009 @ddt.data(True, False) def test_course_creation_for_known_organization(self, organizations_autocreate): @@ -186,12 +188,12 @@ def test_course_creation_for_known_organization(self, organizations_autocreate): 'display_name': 'Course with web certs enabled', 'run': '2015_T2' }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 data = parse_json(response) new_course_key = CourseKey.from_string(data['course_key']) course_orgs = get_course_organizations(new_course_key) - self.assertEqual(len(course_orgs), 1) - self.assertEqual(course_orgs[0]['short_name'], 'orgX') + self.assertEqual(len(course_orgs), 1) # noqa: PT009 + self.assertEqual(course_orgs[0]['short_name'], 'orgX') # noqa: PT009 @override_settings(FEATURES={'ENABLE_CREATOR_GROUP': True}) def test_course_creation_when_user_not_in_org(self): @@ -204,7 +206,7 @@ def test_course_creation_when_user_not_in_org(self): 'display_name': 'Course with web certs enabled', 'run': '2021_T1' }) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 @override_settings(FEATURES={'ENABLE_CREATOR_GROUP': True}) @mock.patch( @@ -224,14 +226,14 @@ def test_course_creation_when_user_in_org_with_creator_role(self): self.course_creator_entry.all_organizations = True self.course_creator_entry.state = CourseCreator.GRANTED self.creator_admin.save_model(self.request, self.course_creator_entry, None, True) - self.assertIn(self.source_course_key.org, get_allowed_organizations(self.user)) + self.assertIn(self.source_course_key.org, get_allowed_organizations(self.user)) # noqa: PT009 response = self.client.ajax_post(self.course_create_rerun_url, { 'org': self.source_course_key.org, 'number': 'CS101', 'display_name': 'Course with web certs enabled', 'run': '2021_T1' }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 @override_settings(FEATURES={'ENABLE_CREATOR_GROUP': True}) @mock.patch( @@ -250,15 +252,15 @@ def test_course_creation_with_all_org_checked(self): self.course_creator_entry.all_organizations = True self.course_creator_entry.state = CourseCreator.GRANTED self.creator_admin.save_model(self.request, self.course_creator_entry, None, True) - self.assertIn(self.source_course_key.org, get_allowed_organizations(self.user)) - self.assertFalse(user_can_create_organizations(self.user)) + self.assertIn(self.source_course_key.org, get_allowed_organizations(self.user)) # noqa: PT009 + self.assertFalse(user_can_create_organizations(self.user)) # noqa: PT009 response = self.client.ajax_post(self.course_create_rerun_url, { 'org': self.source_course_key.org, 'number': 'CS101', 'display_name': 'Course with web certs enabled', 'run': '2021_T1' }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 @override_settings(FEATURES={'ENABLE_CREATOR_GROUP': True}) @mock.patch( @@ -279,15 +281,15 @@ def test_course_creation_with_permission_for_specific_organization(self): self.creator_admin.save_model(self.request, self.course_creator_entry, None, True) dc_org_object = Organization.objects.get(name='Test Organization') self.course_creator_entry.organizations.add(dc_org_object) - self.assertIn(self.source_course_key.org, get_allowed_organizations(self.user)) - self.assertFalse(user_can_create_organizations(self.user)) + self.assertIn(self.source_course_key.org, get_allowed_organizations(self.user)) # noqa: PT009 + self.assertFalse(user_can_create_organizations(self.user)) # noqa: PT009 response = self.client.ajax_post(self.course_create_rerun_url, { 'org': self.source_course_key.org, 'number': 'CS101', 'display_name': 'Course with web certs enabled', 'run': '2021_T1' }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 @override_settings(FEATURES={'ENABLE_CREATOR_GROUP': True}) @mock.patch( @@ -315,15 +317,15 @@ def test_course_creation_without_permission_for_specific_organization(self): # When the user tries to create course under `Test Organization` it throws a 403. dc_org_object = Organization.objects.get(name='DC') self.course_creator_entry.organizations.add(dc_org_object) - self.assertNotIn(self.source_course_key.org, get_allowed_organizations(self.user)) - self.assertFalse(user_can_create_organizations(self.user)) + self.assertNotIn(self.source_course_key.org, get_allowed_organizations(self.user)) # noqa: PT009 + self.assertFalse(user_can_create_organizations(self.user)) # noqa: PT009 response = self.client.ajax_post(self.course_create_rerun_url, { 'org': self.source_course_key.org, 'number': 'CS101', 'display_name': 'Course with web certs enabled', 'run': '2021_T1' }) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 @ddt.data(*product([True, False], [True, False])) @ddt.unpack @@ -360,7 +362,7 @@ def test_default_enable_flexible_peer_openassessments_on_rerun( }) # Then the process completes successfully - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 data = parse_json(response) dest_course_key = CourseKey.from_string(data['destination_course_key']) @@ -368,10 +370,124 @@ def test_default_enable_flexible_peer_openassessments_on_rerun( # ... and our setting got enabled appropriately on our new course if mock_toggle_state: - self.assertTrue(dest_course.force_on_flexible_peer_openassessments) + self.assertTrue(dest_course.force_on_flexible_peer_openassessments) # noqa: PT009 # ... or preserved if the default enable setting is not on else: - self.assertEqual( + self.assertEqual( # noqa: PT009 source_course.force_on_flexible_peer_openassessments, dest_course.force_on_flexible_peer_openassessments ) + + +class TestCourseHandlerAuthz( + CourseAuthoringAuthzTestMixin, + ModuleStoreTestCase, +): + """ + AuthZ integration tests for course_handler using real RBAC (no mocks). + """ + + def setUp(self): + super().setUp() + + self.url = reverse("course_handler") + + # Create a base course to extract org + self.course = CourseFactory.create() + self.course_key = self.course.id + self.org = self.course_key.org + + # If your policy expects this format, keep it + self.org_key = f"course-v1:{self.org}+*" + + self.authorized_client = AjaxEnabledTestClient() + self.authorized_client.login( + username=self.authorized_user.username, + password=self.password, + ) + + self.unauthorized_client = AjaxEnabledTestClient() + self.unauthorized_client.login( + username=self.unauthorized_user.username, + password=self.password, + ) + self.authorized_staff_client = AjaxEnabledTestClient() + self.authorized_staff_client.login( + username=self.staff_user.username, + password=self.password, + ) + + # ------------------------------------------------------------ + # CREATE COURSE -- Non-staff users and existing Organization + # ------------------------------------------------------------ + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_unauthorized(self): + """ + User without role cannot create course. + """ + + response = self.unauthorized_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + assert response.status_code == 403 + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_unauthorized_with_role(self): + """ + User with role but without required permission cannot create course. + """ + + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + "course-v1:someotherorg+*", + ) + + response = self.unauthorized_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + assert response.status_code == 403 + + # ------------------------------------------------------------ + # CREATE COURSE -- Staff users + # Only staff users can create course, and they can do it + # without an org role. + # ------------------------------------------------------------ + def test_create_course_staff(self): + """ + Staff user can create course. + """ + response = self.authorized_staff_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + assert response.status_code == 200 + + # ------------------------------------------------------------ + # FEATURE FLAG + # ------------------------------------------------------------ + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": True}) + def test_create_course_disabled_by_flag(self): + """ + Even authorized users cannot create course if feature flag is off. + """ + + response = self.authorized_staff_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + assert response.status_code == 403 diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 990eff83c922..9a56b8ec7c76 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -10,6 +10,9 @@ from ccx_keys.locator import CCXLocator from django.test import RequestFactory from opaque_keys.edx.locations import CourseLocator +from openedx_authz.api.data import OrgCourseOverviewGlobData +from openedx_authz.api.users import assign_role_to_user_in_scope +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_EDITOR, COURSE_STAFF from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.utils import delete_course @@ -18,7 +21,7 @@ _accessible_courses_iter_for_tests, _accessible_courses_list_from_groups, _accessible_courses_summary_iter, - get_courses_accessible_to_user + get_courses_accessible_to_user, ) from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.models.user import CourseAccessRole @@ -29,19 +32,26 @@ GlobalStaff, OrgInstructorRole, OrgStaffRole, - UserBasedRole + UserBasedRole, ) from common.djangoapps.student.tests.factories import UserFactory +from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangolib.testing.utils import AUTHZ_TABLES +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order TOTAL_COURSES_COUNT = 10 USER_COURSES_COUNT = 1 +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + AUTHZ_TABLES + @ddt.ddt class TestCourseListing(ModuleStoreTestCase): @@ -98,20 +108,20 @@ def test_get_course_list(self): # get courses through iterating all courses courses_iter, __ = _accessible_courses_iter_for_tests(self.request) courses_list = list(courses_iter) - self.assertEqual(len(courses_list), 1) + self.assertEqual(len(courses_list), 1) # noqa: PT009 courses_summary_list, __ = _accessible_courses_summary_iter(self.request) - self.assertEqual(len(list(courses_summary_list)), 1) + self.assertEqual(len(list(courses_summary_list)), 1) # noqa: PT009 # get courses by reversing group name formats courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(len(courses_list_by_groups), 1) + self.assertEqual(len(courses_list_by_groups), 1) # noqa: PT009 # check both course lists have same courses course_keys_in_course_list = [course.id for course in courses_list] course_keys_in_courses_list_by_groups = [course.id for course in courses_list_by_groups] - self.assertEqual(course_keys_in_course_list, course_keys_in_courses_list_by_groups) + self.assertEqual(course_keys_in_course_list, course_keys_in_courses_list_by_groups) # noqa: PT009 def test_courses_list_with_ccx_courses(self): """ @@ -127,8 +137,8 @@ def test_courses_list_with_ccx_courses(self): # Test that CCX courses are filtered out. courses_list, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(len(courses_list), 1) - self.assertNotIn( + self.assertEqual(len(courses_list), 1) # noqa: PT009 + self.assertNotIn( # noqa: PT009 ccx_course_key, [course.id for course in courses_list] ) @@ -139,7 +149,7 @@ def test_courses_list_with_ccx_courses(self): all_courses = (instructor_courses | staff_courses) # Verify that CCX course exists in access but filtered by `_accessible_courses_list_from_groups`. - self.assertIn( + self.assertIn( # noqa: PT009 ccx_course_key, [access.course_id for access in all_courses] ) @@ -151,7 +161,7 @@ def test_courses_list_with_ccx_courses(self): return_value=[mocked_ccx_course], ): courses_iter, __ = _accessible_courses_iter_for_tests(self.request) - self.assertEqual(len(list(courses_iter)), 0) + self.assertEqual(len(list(courses_iter)), 0) # noqa: PT009 def test_staff_course_listing(self): """ @@ -161,7 +171,7 @@ def test_staff_course_listing(self): # Assign & verify staff role to the user GlobalStaff().add_users(self.user) - self.assertTrue(GlobalStaff().has_user(self.user)) + self.assertTrue(GlobalStaff().has_user(self.user)) # noqa: PT009 # Create few courses for num in range(TOTAL_COURSES_COUNT): @@ -171,8 +181,8 @@ def test_staff_course_listing(self): # Fetch accessible courses list & verify their count courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) - self.assertEqual(len(list(courses_list_by_staff)), TOTAL_COURSES_COUNT) - self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_list_by_staff)) + self.assertEqual(len(list(courses_list_by_staff)), TOTAL_COURSES_COUNT) # noqa: PT009 + self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_list_by_staff)) # noqa: PT009 # Now count the db queries for staff with self.assertNumQueries(2): @@ -190,7 +200,7 @@ def test_course_limited_staff_course_listing(self): # Add the user as a course_limited_staff on the course CourseLimitedStaffRole(course.id).add_users(self.user) - self.assertTrue(CourseLimitedStaffRole(course.id).has_user(self.user)) + self.assertTrue(CourseLimitedStaffRole(course.id).has_user(self.user)) # noqa: PT009 # Fetch accessible courses list & verify their count courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) @@ -207,7 +217,7 @@ def test_org_limited_staff_course_listing(self): number=course_location.course, run=course_location.run ) - course = CourseOverviewFactory.create(id=course_location, org=course_location.org) + course = CourseOverviewFactory.create(id=course_location, org=course_location.org) # noqa: F841 # Add a user as course_limited_staff on the org # This is not possible using the course roles classes but is possible via Django admin so we @@ -231,21 +241,21 @@ def test_get_course_list_with_invalid_course_location(self): # get courses through iterating all courses courses_iter, __ = _accessible_courses_iter_for_tests(self.request) courses_list = list(courses_iter) - self.assertEqual(len(courses_list), 1) + self.assertEqual(len(courses_list), 1) # noqa: PT009 courses_summary_iter, __ = _accessible_courses_summary_iter(self.request) courses_summary_list = list(courses_summary_iter) - self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_summary_list)) - self.assertEqual(len(courses_summary_list), 1) + self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_summary_list)) # noqa: PT009 + self.assertEqual(len(courses_summary_list), 1) # noqa: PT009 # get courses by reversing group name formats courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(len(courses_list_by_groups), 1) + self.assertEqual(len(courses_list_by_groups), 1) # noqa: PT009 course_keys_in_course_list = [course.id for course in courses_list] course_keys_in_courses_list_by_groups = [course.id for course in courses_list_by_groups] # check course lists have same courses - self.assertEqual(course_keys_in_course_list, course_keys_in_courses_list_by_groups) + self.assertEqual(course_keys_in_course_list, course_keys_in_courses_list_by_groups) # noqa: PT009 # now delete this course and re-add user to instructor group of this course delete_course(course_key, self.user.id) course.delete() @@ -262,7 +272,7 @@ def test_get_course_list_with_invalid_course_location(self): courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) # Test that course list returns no course - self.assertEqual( + self.assertEqual( # noqa: PT009 [len(list(courses_iter)), len(courses_list_by_groups), len(list(courses_summary_iter))], [0, 0, 0] ) @@ -289,24 +299,24 @@ def test_course_listing_performance(self): # get courses by iterating through all courses courses_iter, __ = _accessible_courses_iter_for_tests(self.request) - self.assertEqual(len(list(courses_iter)), USER_COURSES_COUNT) + self.assertEqual(len(list(courses_iter)), USER_COURSES_COUNT) # noqa: PT009 # again get courses by iterating through all courses courses_iter, __ = _accessible_courses_iter_for_tests(self.request) - self.assertEqual(len(list(courses_iter)), USER_COURSES_COUNT) + self.assertEqual(len(list(courses_iter)), USER_COURSES_COUNT) # noqa: PT009 # get courses by reversing django groups courses_list, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(len(courses_list), USER_COURSES_COUNT) + self.assertEqual(len(courses_list), USER_COURSES_COUNT) # noqa: PT009 # again get courses by reversing django groups courses_list, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(len(courses_list), USER_COURSES_COUNT) + self.assertEqual(len(courses_list), USER_COURSES_COUNT) # noqa: PT009 - with self.assertNumQueries(1, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(2, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): _accessible_courses_list_from_groups(self.request) - with self.assertNumQueries(2, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(2, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): _accessible_courses_iter_for_tests(self.request) def test_course_listing_errored_deleted_courses(self): @@ -322,7 +332,7 @@ def test_course_listing_errored_deleted_courses(self): course.delete() courses_list, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(len(courses_list), 1, courses_list) + self.assertEqual(len(courses_list), 1, courses_list) # noqa: PT009 @ddt.data(OrgStaffRole('AwesomeOrg'), OrgInstructorRole('AwesomeOrg')) def test_course_listing_org_permissions(self, role): @@ -353,8 +363,8 @@ def test_course_listing_org_permissions(self, role): # Verify fetched accessible courses list is a list of CourseSummery instances and test expacted # course count is returned - self.assertEqual(len(list(courses_list)), 2) - self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_list)) + self.assertEqual(len(list(courses_list)), 2) # noqa: PT009 + self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_list)) # noqa: PT009 @ddt.data(OrgStaffRole(), OrgInstructorRole()) def test_course_listing_org_permissions_exception(self, role): @@ -364,7 +374,7 @@ def test_course_listing_org_permissions_exception(self, role): """ role.add_users(self.user) - with self.assertRaises(AccessListFallback): + with self.assertRaises(AccessListFallback): # noqa: PT027 _accessible_courses_list_from_groups(self.request) def test_course_listing_with_actions_in_progress(self): @@ -398,7 +408,427 @@ def _set_of_course_keys(course_list, key_attribute_name='id'): return {getattr(c, key_attribute_name) for c in course_list} found_courses, unsucceeded_course_actions = _accessible_courses_iter_for_tests(self.request) - self.assertSetEqual(_set_of_course_keys(courses + courses_in_progress), _set_of_course_keys(found_courses)) - self.assertSetEqual( + self.assertSetEqual(_set_of_course_keys(courses + courses_in_progress), _set_of_course_keys(found_courses)) # noqa: PT009 # pylint: disable=line-too-long + self.assertSetEqual( # noqa: PT009 _set_of_course_keys(courses_in_progress), _set_of_course_keys(unsucceeded_course_actions, 'course_key') ) + + +class TestCourseListingAuthz(CourseAuthoringAuthzTestMixin, ModuleStoreTestCase): + """ + Tests course listing using the new AuthZ authorization framework. + """ + + def setUp(self): + super().setUp() + + self.factory = RequestFactory() + + def _create_course(self, course_key): + """Helper method to create a course and its overview.""" + course = CourseFactory.create( + org=course_key.org, + number=course_key.course, + run=course_key.run, + ) + + return CourseOverviewFactory.create(id=course.id, org=course_key.org) + + def _mock_authz_toggle(self, enabled_keys): + def _is_enabled(course_key=None, **_): + return str(course_key) in enabled_keys + return _is_enabled + + def _make_request(self, user): + request = self.factory.get("/course") + request.user = user + return request + + def _create_courses(self): + """Helper method to create multiple courses for testing.""" + authz_keys = [ + CourseLocator("Org1", "Course1", "AuthzRun"), + CourseLocator("Org1", "Course2", "AuthzRun"), + CourseLocator("Org1", "Course3", "AuthzRun"), + ] + + legacy_keys = [ + CourseLocator("Org1", "Course1", "LegacyRun"), + CourseLocator("Org1", "Course2", "LegacyRun"), + CourseLocator("Org1", "Course3", "LegacyRun"), + ] + + authz_courses = [self._create_course(k) for k in authz_keys] + legacy_courses = [self._create_course(k) for k in legacy_keys] + + return authz_keys, legacy_keys, authz_courses, legacy_courses + + def test_course_listing_with_course_staff_authz_permission(self): + """ + Create courses and assign access to only some of them to the user. + Verify that only those courses are returned in the course listing. + Using COURSE_STAFF role here. + """ + course_key_1 = CourseLocator("Org1", "Course1", "Run1") + course1 = self._create_course(course_key_1) + + course_key_2 = CourseLocator("Org1", "Course2", "Run1") + course2 = self._create_course(course_key_2) # noqa: F841 + + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + str(course_key_1), + ) + + request = self.factory.get("/course") + request.user = self.authorized_user + + courses_list, _ = get_courses_accessible_to_user(request) + + courses = list(courses_list) + + self.assertEqual(len(courses), 1) # noqa: PT009 + self.assertEqual(courses[0].id, course1.id) # noqa: PT009 + self.assertNotIn(course_key_2, {c.id for c in courses}) # noqa: PT009 + + def test_course_listing_with_course_editor_authz_permission(self): + """ + Create courses and assign access to only some of them to the user. + Verify that only those courses are returned in the course listing. + Using COURSE_EDITOR role here. + """ + course_key_1 = CourseLocator("Org1", "Course1", "Run1") + course1 = self._create_course(course_key_1) + + course_key_2 = CourseLocator("Org1", "Course2", "Run1") + course2 = self._create_course(course_key_2) # noqa: F841 + + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_EDITOR.external_key, + str(course_key_1), + ) + + request = self.factory.get("/course") + request.user = self.authorized_user + + courses_list, _ = get_courses_accessible_to_user(request) + + courses = list(courses_list) + + self.assertEqual(len(courses), 1) # noqa: PT009 + self.assertEqual(courses[0].id, course1.id) # noqa: PT009 + self.assertNotIn(course_key_2, {c.id for c in courses}) # noqa: PT009 + + def test_course_listing_without_permissions(self): + """ + Create a course but do not assign access to the user. + Verify that no courses are returned in the course listing. + """ + course_key = CourseLocator("Org1", "Course1", "Run1") + + self._create_course(course_key) + + request = self.factory.get("/course") + request.user = self.unauthorized_user + + courses_list, _ = get_courses_accessible_to_user(request) + + self.assertEqual(len(list(courses_list)), 0) # noqa: PT009 + + def test_non_staff_user_cannot_access(self): + """ + Create a course and assign a non-staff role to the user. + Verify that the course is not returned in the course listing. + """ + non_staff_user = UserFactory() + course_key = CourseLocator("Org1", "Course1", "Run1") + self._create_course(course_key) + self.add_user_to_role_in_course(non_staff_user, COURSE_DATA_RESEARCHER.external_key, course_key) + + request = self.factory.get("/course") + request.user = non_staff_user + + courses_list, _ = get_courses_accessible_to_user(request) + + self.assertEqual(len(list(courses_list)), 0) # noqa: PT009 + + def test_authz_and_legacy_basic(self): + """ + AuthZ roles should only apply when toggle is enabled. + Legacy roles should still grant access. + """ + authz_keys, legacy_keys, authz_courses, legacy_courses = self._create_courses() + + enabled_keys = {str(authz_keys[0]), str(authz_keys[2])} + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + side_effect=self._mock_authz_toggle(enabled_keys), + ): + user = UserFactory() + + # AuthZ roles + assign_role_to_user_in_scope( + user.username, + COURSE_STAFF.external_key, + str(authz_keys[0]), # toggle ON → valid + ) + assign_role_to_user_in_scope( + user.username, + COURSE_EDITOR.external_key, + str(authz_keys[1]), # toggle OFF → ignored + ) + + # Legacy role + CourseInstructorRole(legacy_keys[0]).add_users(user) + + courses, _ = get_courses_accessible_to_user(self._make_request(user)) + + result_ids = {c.id for c in courses} + + expected_ids = { + authz_courses[0].id, + legacy_courses[0].id, + } + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_authz_role_ignored_when_toggle_off(self): + """ + AuthZ role should not grant access if toggle is disabled for that course. + """ + authz_keys, _, authz_courses, _ = self._create_courses() + + enabled_keys = {str(authz_keys[2])} # only Course3 enabled + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + side_effect=self._mock_authz_toggle(enabled_keys), + ): + user = UserFactory() + + assign_role_to_user_in_scope( + user.username, + COURSE_EDITOR.external_key, + str(authz_keys[1]), # toggle OFF → ignored + ) + + courses, _ = get_courses_accessible_to_user(self._make_request(user)) + + result_ids = {c.id for c in courses} + expected_ids = set() # no access since toggle is off + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_multiple_roles_mixed_authz_and_legacy(self): + """ + User should receive: + - AuthZ courses when toggle is enabled + - Legacy courses independently + """ + authz_keys, legacy_keys, authz_courses, legacy_courses = self._create_courses() + + enabled_keys = {str(k) for k in authz_keys} # all enabled + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + side_effect=self._mock_authz_toggle(enabled_keys), + ): + user = UserFactory() + + # AuthZ roles + assign_role_to_user_in_scope( + user.username, + COURSE_STAFF.external_key, + str(authz_keys[0]), + ) + assign_role_to_user_in_scope( + user.username, + COURSE_EDITOR.external_key, + str(authz_keys[1]), + ) + + # Legacy role + CourseInstructorRole(legacy_keys[2]).add_users(user) + + courses, _ = get_courses_accessible_to_user(self._make_request(user)) + + result_ids = {c.id for c in courses} + + expected_ids = { + authz_courses[0].id, + authz_courses[1].id, + legacy_courses[2].id, + } + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_staff_gets_all_courses(self): + """ + Global staff should bypass AuthZ/legacy restrictions and get all courses. + """ + authz_keys, legacy_keys, authz_courses, legacy_courses = self._create_courses() + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + return_value=False, # irrelevant for staff + ): + user = UserFactory() + GlobalStaff().add_users(user) + + courses, _ = get_courses_accessible_to_user(self._make_request(user)) + + result_ids = {c.id for c in courses} + + expected_ids = { + *(c.id for c in authz_courses), + *(c.id for c in legacy_courses), + } + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_superuser_gets_all_courses(self): + """ + Superuser should bypass all permission checks and get all courses. + """ + _, _, authz_courses, legacy_courses = self._create_courses() + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + return_value=False, # irrelevant for superuser + ): + user = UserFactory(is_superuser=True) + + courses, _ = get_courses_accessible_to_user(self._make_request(user)) + + result_ids = {c.id for c in courses} + + expected_ids = { + *(c.id for c in authz_courses), + *(c.id for c in legacy_courses), + } + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_course_listing_with_org_scope(self): + """ + Verify that assigning a course role like course_staff with an org-wide scope + (`course-v1:Org1+*`) grants access to all courses in that org when + the AuthZ course authoring toggle is enabled. + """ + _, _, authz_courses, legacy_courses = self._create_courses() + org_scope = OrgCourseOverviewGlobData(external_key='course-v1:Org1+*') + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + org_scope.external_key, + ) + + request = self._make_request(self.authorized_user) + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + return_value=True, + ): + courses, _ = get_courses_accessible_to_user(request) + + result_ids = {c.id for c in courses} + + expected_ids = { + *(c.id for c in authz_courses), + *(c.id for c in legacy_courses), + } + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_course_listing_with_org_scope_with_toggle(self): + """ + If the authz toggle is enabled only for a subset of org courses, only + those course keys should appear in the resulting course list. + """ + authz_keys, _, _, _ = self._create_courses() + # enable only the first and third course keys + enabled_keys = {str(authz_keys[0]), str(authz_keys[2])} + org_scope = OrgCourseOverviewGlobData(external_key='course-v1:Org1+*') + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + org_scope.external_key, + ) + + request = self._make_request(self.authorized_user) + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + side_effect=self._mock_authz_toggle(enabled_keys), + ): + courses, _ = get_courses_accessible_to_user(request) + + result_ids = {c.id for c in courses} + + expected = {authz_keys[0], authz_keys[2]} + self.assertEqual(result_ids, expected) # noqa: PT009 + + def test_course_listing_with_org_scope_without_courses(self): + """ + When the scope is an OrgCourseOverviewGlobData for an org that has no + courses, `get_courses_accessible_to_user` should return an empty + list. + """ + org_scope = OrgCourseOverviewGlobData(external_key='course-v1:Org2+*') + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + org_scope.external_key, + ) + + request = self._make_request(self.authorized_user) + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + return_value=True, + ): + courses, _ = get_courses_accessible_to_user(request) + + self.assertEqual(courses, []) # noqa: PT009 + + def test_course_listing_with_org_scope_fetched_once(self): + """ + Verify that course overviews are fetched once with all authorized orgs. + """ + org_scope1 = OrgCourseOverviewGlobData(external_key='course-v1:Org1+*') + org_scope2 = OrgCourseOverviewGlobData(external_key='course-v1:Org2+*') + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + org_scope1.external_key, + ) + assign_role_to_user_in_scope( + self.authorized_user.username, + COURSE_STAFF.external_key, + org_scope2.external_key, + ) + + request = self._make_request(self.authorized_user) + + with patch.object( + core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, + "is_enabled", + return_value=True, + ), patch.object( + CourseOverview, + "get_all_courses", + ) as mock_get_all_courses: + courses, _ = get_courses_accessible_to_user(request) + + mock_get_all_courses.assert_called_once_with(orgs={"Org1", "Org2"}) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 876bb37ee783..720a021ecb3f 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -29,29 +29,27 @@ from pytz import UTC from xblock.fields import Date -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url from cms.djangoapps.models.settings.course_grading import ( GRADING_POLICY_CHANGED_EVENT_TYPE, CourseGradingModel, - hash_grading_policy + hash_grading_policy, ) from cms.djangoapps.models.settings.course_metadata import CourseMetadata from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder from cms.djangoapps.models.settings.waffle import MATERIAL_RECOMPUTE_ONLY_FLAG -from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util import milestones_helpers from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag from openedx.core.djangoapps.discussions.config.waffle import ( ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND, - OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG + OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG, ) from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.lib.teams_config import TeamsConfig -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order from .utils import AjaxEnabledTestClient, CourseTestCase @@ -69,14 +67,14 @@ def test_encoder(self): details = CourseDetails.fetch(self.course.id) jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) - self.assertEqual(jsondetails['course_image_name'], self.course.course_image) - self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") - self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") - self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") - self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized") - self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") - self.assertIsNone(jsondetails['effort'], "effort somehow initialized") - self.assertIsNone(jsondetails['language'], "language somehow initialized") + self.assertEqual(jsondetails['course_image_name'], self.course.course_image) # noqa: PT009 + self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") # noqa: PT009 + self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") # noqa: PT009 + self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") # noqa: PT009 + self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized") # noqa: PT009 + self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") # noqa: PT009 + self.assertIsNone(jsondetails['effort'], "effort somehow initialized") # noqa: PT009 + self.assertIsNone(jsondetails['language'], "language somehow initialized") # noqa: PT009 def test_pre_1900_date(self): """ @@ -88,7 +86,7 @@ def test_pre_1900_date(self): details.enrollment_start = pre_1900 dumped_jsondetails = json.dumps(details, cls=CourseSettingsEncoder) loaded_jsondetails = json.loads(dumped_jsondetails) - self.assertEqual(loaded_jsondetails['enrollment_start'], pre_1900.isoformat()) + self.assertEqual(loaded_jsondetails['enrollment_start'], pre_1900.isoformat()) # noqa: PT009 def test_ooc_encoder(self): """ @@ -102,8 +100,8 @@ def test_ooc_encoder(self): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) - self.assertEqual(1, jsondetails['number']) - self.assertEqual(jsondetails['string'], 'string') + self.assertEqual(1, jsondetails['number']) # noqa: PT009 + self.assertEqual(jsondetails['string'], 'string') # noqa: PT009 @ddt.ddt @@ -124,7 +122,6 @@ def setUp(self): CourseStaffRole(self.course.id).add_users(self.nonstaff) @override_settings(FEATURES={'DISABLE_MOBILE_COURSE_AVAILABLE': True}) - @override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) def test_mobile_field_available(self): """ @@ -132,13 +129,12 @@ def test_mobile_field_available(self): when DISABLE_MOBILE_COURSE_AVAILABLE is true. """ - response = self.client.get_html(self.course_setting_url) - start = response.content.decode('utf-8').find("mobile_available") - end = response.content.decode('utf-8').find("}", start) - settings_fields = json.loads(response.content.decode('utf-8')[start + len("mobile_available: "):end + 1]) + response = self.client.get(self.course_setting_url, HTTP_ACCEPT='application/json') + data = json.loads(response.content.decode('utf-8')) + settings_fields = data.get('mobile_available') - self.assertEqual(settings_fields["display_name"], "Mobile Course Available") - self.assertEqual(settings_fields["deprecated"], True) + self.assertEqual(settings_fields["display_name"], "Mobile Course Available") # noqa: PT009 + self.assertEqual(settings_fields["deprecated"], True) # noqa: PT009 @ddt.data( (False, False, True), @@ -147,7 +143,6 @@ def test_mobile_field_available(self): (False, True, True) ) @ddt.unpack - @override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) def test_discussion_fields_available(self, is_pages_and_resources_enabled, is_legacy_discussion_setting_enabled, fields_visible): """ @@ -156,60 +151,52 @@ def test_discussion_fields_available(self, is_pages_and_resources_enabled, with override_waffle_flag(ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND, is_pages_and_resources_enabled): with override_waffle_flag(OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG, is_legacy_discussion_setting_enabled): - response = self.client.get_html(self.course_setting_url).content.decode('utf-8') - self.assertEqual('allow_anonymous' in response, fields_visible) - self.assertEqual('allow_anonymous_to_peers' in response, fields_visible) - self.assertEqual('discussion_blackouts' in response, fields_visible) - self.assertEqual('discussion_topics' in response, fields_visible) + response = self.client.get(self.course_setting_url, HTTP_ACCEPT='application/json') + data = json.loads(response.content.decode('utf-8')) + self.assertEqual('allow_anonymous' in data, fields_visible) # noqa: PT009 + self.assertEqual('allow_anonymous_to_peers' in data, fields_visible) # noqa: PT009 + self.assertEqual('discussion_blackouts' in data, fields_visible) # noqa: PT009 + self.assertEqual('discussion_topics' in data, fields_visible) # noqa: PT009 @ddt.data(False, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True) def test_disable_advanced_settings_feature(self, disable_advanced_settings): """ - If this feature is enabled, only Django Staff/Superuser should be able to access the "Advanced Settings" page. - For non-staff users the "Advanced Settings" tab link should not be visible. + When DISABLE_ADVANCED_SETTINGS is enabled, non-staff users should receive + a 403 on the advanced settings URL; staff users should always be redirected. """ - advanced_settings_link_html = f"Advanced Settings".encode('utf-8') - with override_settings(FEATURES={ 'DISABLE_ADVANCED_SETTINGS': disable_advanced_settings, - }): - for handler in ( - 'import_handler', - 'export_handler', - 'course_team_handler', - 'settings_handler', - 'grading_handler', - ): - # Test that non-staff users don't see the "Advanced Settings" tab link. - response = self.non_staff_client.get_html( - get_url(self.course.id, handler) - ) - self.assertEqual(response.status_code, 200) - if disable_advanced_settings: - self.assertNotIn(advanced_settings_link_html, response.content) - else: - self.assertIn(advanced_settings_link_html, response.content) - - # Test that staff users see the "Advanced Settings" tab link. - response = self.client.get_html( - get_url(self.course.id, handler) - ) - self.assertEqual(response.status_code, 200) - self.assertIn(advanced_settings_link_html, response.content) - - # Test that non-staff users can't access the "Advanced Settings" page. + }, COURSE_AUTHORING_MICROFRONTEND_URL='https://mfe.example'): response = self.non_staff_client.get_html(self.course_setting_url) - self.assertEqual(response.status_code, 403 if disable_advanced_settings else 200) + self.assertEqual(response.status_code, 403 if disable_advanced_settings else 302) # noqa: PT009 - # Test that staff users can access the "Advanced Settings" page. response = self.client.get_html(self.course_setting_url) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 302) # noqa: PT009 + + def test_grading_handler_redirects_to_mfe(self): + """grading_handler redirects to the authoring MFE.""" + response = self.client.get_html(get_url(self.course.id, 'grading_handler')) + self.assertEqual(response.status_code, 302) # noqa: PT009 + + def test_settings_handler_redirects_to_mfe(self): + """settings_handler (schedule & details) redirects to the authoring MFE.""" + response = self.client.get_html(get_url(self.course.id, 'settings_handler')) + self.assertEqual(response.status_code, 302) # noqa: PT009 + + def test_import_handler_redirects_to_mfe(self): + """import_handler redirects to the authoring MFE.""" + response = self.client.get_html(get_url(self.course.id, 'import_handler')) + self.assertEqual(response.status_code, 302) # noqa: PT009 + + def test_export_handler_redirects_to_mfe(self): + """export_handler redirects to the authoring MFE.""" + response = self.client.get_html(get_url(self.course.id, 'export_handler')) + self.assertEqual(response.status_code, 302) # noqa: PT009 + + def test_course_team_handler_redirects_to_mfe(self): + """course_team_handler redirects to the authoring MFE.""" + response = self.client.get_html(get_url(self.course.id, 'course_team_handler')) + self.assertEqual(response.status_code, 302) # noqa: PT009 @ddt.ddt @@ -273,17 +260,17 @@ def compare_details_with_encoding(self, encoded, details, context): self.compare_date_fields(details, encoded, context, 'end_date') self.compare_date_fields(details, encoded, context, 'enrollment_start') self.compare_date_fields(details, encoded, context, 'enrollment_end') - self.assertEqual( + self.assertEqual( # noqa: PT009 details['short_description'], encoded['short_description'], context + " short_description not ==" ) - self.assertEqual( + self.assertEqual( # noqa: PT009 details['about_sidebar_html'], encoded['about_sidebar_html'], context + " about_sidebar_html not ==" ) - self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") - self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") - self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") - self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==") - self.assertEqual(details['language'], encoded['language'], context + " languages not ==") + self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") # noqa: PT009 + self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") # noqa: PT009 + self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==") # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(details['language'], encoded['language'], context + " languages not ==") # noqa: PT009 def compare_date_fields(self, details, encoded, context, field): """ @@ -295,53 +282,22 @@ def compare_date_fields(self, details, encoded, context, field): dt1 = date.from_json(encoded[field]) dt2 = details[field] - self.assertEqual(dt1, dt2, msg=f"{dt1} != {dt2} at {context}") + self.assertEqual(dt1, dt2, msg=f"{dt1} != {dt2} at {context}") # noqa: PT009 else: self.fail(field + " missing from encoded but in details at " + context) elif field in encoded and encoded[field] is not None: self.fail(field + " included in encoding but missing from details at " + context) - @ddt.data( - (False, False), - (True, False), - (True, True), - ) - @ddt.unpack - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_upgrade_deadline(self, has_verified_mode, has_expiration_date): - if has_verified_mode: - deadline = None - if has_expiration_date: - deadline = self.course.start + datetime.timedelta(days=2) - CourseMode.objects.get_or_create( - course_id=self.course.id, - mode_display_name="Verified", - mode_slug="verified", - min_price=1, - _expiration_datetime=deadline, - ) - - settings_details_url = get_url(self.course.id) - response = self.client.get_html(settings_details_url) - self.assertEqual(b"Upgrade Deadline Date" in response.content, has_expiration_date and has_verified_mode) - - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_pre_requisite_course_list_present(self): - settings_details_url = get_url(self.course.id) - response = self.client.get_html(settings_details_url) - self.assertContains(response, "Prerequisite Course") - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) def test_pre_requisite_course_update_and_fetch(self): - self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), + self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), # noqa: PT009 msg='The initial empty state should be: no prerequisite courses') url = get_url(self.course.id) resp = self.client.get_json(url) course_detail_json = json.loads(resp.content.decode('utf-8')) # assert pre_requisite_courses is initialized - self.assertEqual([], course_detail_json['pre_requisite_courses']) + self.assertEqual([], course_detail_json['pre_requisite_courses']) # noqa: PT009 # update pre requisite courses with a new course keys pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run') @@ -353,9 +309,9 @@ def test_pre_requisite_course_update_and_fetch(self): # fetch updated course to assert pre_requisite_courses has new values resp = self.client.get_json(url) course_detail_json = json.loads(resp.content.decode('utf-8')) - self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses']) + self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses']) # noqa: PT009 - self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), + self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), # noqa: PT009 msg='Should have prerequisite courses') # remove pre requisite course @@ -363,9 +319,9 @@ def test_pre_requisite_course_update_and_fetch(self): self.client.ajax_post(url, course_detail_json) resp = self.client.get_json(url) course_detail_json = json.loads(resp.content.decode('utf-8')) - self.assertEqual([], course_detail_json['pre_requisite_courses']) + self.assertEqual([], course_detail_json['pre_requisite_courses']) # noqa: PT009 - self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), + self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), # noqa: PT009 msg='Should not have prerequisite courses anymore') @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) @@ -379,63 +335,7 @@ def test_invalid_pre_requisite_course(self): pre_requisite_course_keys = [str(pre_requisite_course.id), 'invalid_key'] course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys response = self.client.ajax_post(url, course_detail_json) - self.assertEqual(400, response.status_code) - - @ddt.data( - (False, False, False), - (True, False, True), - (False, True, False), - (True, True, True), - ) - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - @override_settings(MILESTONES_APP=False) - def test_visibility_of_entrance_exam_section(self, feature_flags): - """ - Tests entrance exam section is available if ENTRANCE_EXAMS feature is enabled no matter any other - feature is enabled or disabled i.e ENABLE_PUBLISHER. - """ - with patch.dict("django.conf.settings.FEATURES", { - 'ENABLE_PUBLISHER': feature_flags[1] - }), override_settings(ENTRANCE_EXAMS=feature_flags[0]): - course_details_url = get_url(self.course.id) - resp = self.client.get_html(course_details_url) - self.assertEqual( - feature_flags[2], - b'

' in resp.content - ) - - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - @override_settings(MILESTONES_APP=False) - @override_settings(ENTRANCE_EXAMS=False) - def test_marketing_site_fetch(self): - settings_details_url = get_url(self.course.id) - - with mock.patch.dict('django.conf.settings.FEATURES', { - 'ENABLE_PUBLISHER': True, - 'ENABLE_MKTG_SITE': True, - 'ENABLE_PREREQUISITE_COURSES': False, - }): - response = self.client.get_html(settings_details_url) - self.assertNotContains(response, "Course Summary Page") - self.assertNotContains(response, "Send a note to students via email") - self.assertContains(response, "course summary page will not be viewable") - - self.assertContains(response, "Course Start Date") - self.assertContains(response, "Course End Date") - self.assertContains(response, "Enrollment Start Date") - self.assertContains(response, "Enrollment End Date") - - self.assertContains(response, "Course Short Description") - self.assertNotContains(response, "Course About Sidebar HTML") - self.assertNotContains(response, "Course Title") - self.assertNotContains(response, "Course Subtitle") - self.assertNotContains(response, "Course Duration") - self.assertNotContains(response, "Course Description") - self.assertNotContains(response, "Course Overview") - self.assertNotContains(response, "Course Introduction Video") - self.assertNotContains(response, "Requirements") - self.assertNotContains(response, "Course Banner Image") - self.assertNotContains(response, "Course Video Thumbnail Image") + self.assertEqual(400, response.status_code) # noqa: PT009 @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) def test_entrance_exam_created_updated_and_deleted_successfully(self): @@ -444,27 +344,20 @@ def test_entrance_exam_created_updated_and_deleted_successfully(self): Splitting the test requires significant refactoring `settings_handler()` view. """ - self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), + self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), # noqa: PT009 msg='The initial empty state should be: no entrance exam') settings_details_url = get_url(self.course.id) data = { 'entrance_exam_enabled': 'true', 'entrance_exam_minimum_score_pct': '60', - 'syllabus': 'none', - 'short_description': 'empty', - 'overview': '', - 'effort': '', - 'intro_video': '', - 'start_date': '2012-01-01', - 'end_date': '2012-12-31', } response = self.client.post(settings_details_url, data=json.dumps(data), content_type='application/json', HTTP_ACCEPT='application/json') - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 course = modulestore().get_course(self.course.id) - self.assertTrue(course.entrance_exam_enabled) - self.assertEqual(course.entrance_exam_minimum_score_pct, .60) + self.assertTrue(course.entrance_exam_enabled) # noqa: PT009 + self.assertEqual(course.entrance_exam_minimum_score_pct, .60) # noqa: PT009 # Update the entrance exam data['entrance_exam_enabled'] = "true" @@ -475,12 +368,12 @@ def test_entrance_exam_created_updated_and_deleted_successfully(self): content_type='application/json', HTTP_ACCEPT='application/json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 course = modulestore().get_course(self.course.id) - self.assertTrue(course.entrance_exam_enabled) - self.assertEqual(course.entrance_exam_minimum_score_pct, .80) + self.assertTrue(course.entrance_exam_enabled) # noqa: PT009 + self.assertEqual(course.entrance_exam_minimum_score_pct, .80) # noqa: PT009 - self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), + self.assertTrue(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), # noqa: PT009 msg='The entrance exam should be required.') # Delete the entrance exam @@ -492,15 +385,15 @@ def test_entrance_exam_created_updated_and_deleted_successfully(self): HTTP_ACCEPT='application/json' ) course = modulestore().get_course(self.course.id) - self.assertEqual(response.status_code, 200) - self.assertFalse(course.entrance_exam_enabled) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertFalse(course.entrance_exam_enabled) # noqa: PT009 entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) if entrance_exam_minimum_score_pct.is_integer(): entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 - self.assertEqual(course.entrance_exam_minimum_score_pct, entrance_exam_minimum_score_pct) + self.assertEqual(course.entrance_exam_minimum_score_pct, entrance_exam_minimum_score_pct) # noqa: PT009 - self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), + self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), # noqa: PT009 msg='The entrance exam should not be required anymore') @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) @@ -512,13 +405,6 @@ def test_entrance_exam_store_default_min_score(self): settings_details_url = get_url(self.course.id) test_data_1 = { 'entrance_exam_enabled': 'true', - 'syllabus': 'none', - 'short_description': 'empty', - 'overview': '', - 'effort': '', - 'intro_video': '', - 'start_date': '2012-01-01', - 'end_date': '2012-12-31', } response = self.client.post( settings_details_url, @@ -526,36 +412,28 @@ def test_entrance_exam_store_default_min_score(self): content_type='application/json', HTTP_ACCEPT='application/json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 course = modulestore().get_course(self.course.id) - self.assertTrue(course.entrance_exam_enabled) + self.assertTrue(course.entrance_exam_enabled) # noqa: PT009 # entrance_exam_minimum_score_pct is not present in the request so default value should be saved. - self.assertEqual(course.entrance_exam_minimum_score_pct, .5) + self.assertEqual(course.entrance_exam_minimum_score_pct, .5) # noqa: PT009 - #add entrance_exam_minimum_score_pct with empty value in json request. + # add entrance_exam_minimum_score_pct with empty value in json request. test_data_2 = { 'entrance_exam_enabled': 'true', 'entrance_exam_minimum_score_pct': '', - 'syllabus': 'none', - 'short_description': 'empty', - 'overview': '', - 'effort': '', - 'intro_video': '', - 'start_date': '2012-01-01', - 'end_date': '2012-12-31', } - response = self.client.post( settings_details_url, data=json.dumps(test_data_2), content_type='application/json', HTTP_ACCEPT='application/json' ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 course = modulestore().get_course(self.course.id) - self.assertTrue(course.entrance_exam_enabled) - self.assertEqual(course.entrance_exam_minimum_score_pct, .5) + self.assertTrue(course.entrance_exam_enabled) # noqa: PT009 + self.assertEqual(course.entrance_exam_minimum_score_pct, .5) # noqa: PT009 @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) @@ -605,7 +483,7 @@ def test_entrance_after_changing_other_setting(self): # Call the settings handler again then ensure it didn't delete the settings of the entrance exam data.update({ 'start_date': '2018-01-01', - 'end_date': '{year}-12-31'.format(year=datetime.datetime.now().year + 4), + 'end_date': '{year}-12-31'.format(year=datetime.datetime.now().year + 4), # noqa: UP032 }) response = self.client.post( settings_details_url, @@ -617,14 +495,6 @@ def test_entrance_after_changing_other_setting(self): assert milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \ 'The entrance exam should be required.' - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_editable_short_description_fetch(self): - settings_details_url = get_url(self.course.id) - - with mock.patch.dict('django.conf.settings.FEATURES', {'EDITABLE_SHORT_DESCRIPTION': False}): - response = self.client.get_html(settings_details_url) - self.assertNotContains(response, "Course Short Description") - def test_empty_course_overview_keep_default_value(self): """ Test saving the course with an empty course overview. @@ -653,38 +523,9 @@ def test_empty_course_overview_keep_default_value(self): ) course_details = CourseDetails.fetch(self.course.id) - self.assertEqual(response.status_code, 200) - self.assertEqual(course_details.overview, '

 

') - - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_regular_site_fetch(self): - settings_details_url = get_url(self.course.id) + self.assertEqual(response.status_code, 200) # noqa: PT009 + self.assertEqual(course_details.overview, '

 

') # noqa: PT009 - with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_PUBLISHER': False, - 'ENABLE_EXTENDED_COURSE_DETAILS': True}): - response = self.client.get_html(settings_details_url) - self.assertContains(response, "Course Summary Page") - self.assertContains(response, "Send a note to students via email") - self.assertNotContains(response, "course summary page will not be viewable") - - self.assertContains(response, "Course Start Date") - self.assertContains(response, "Course End Date") - self.assertContains(response, "Enrollment Start Date") - self.assertContains(response, "Enrollment End Date") - - self.assertContains(response, "Introducing Your Course") - self.assertContains(response, "Course Card Image") - self.assertContains(response, "Course Title") - self.assertContains(response, "Course Subtitle") - self.assertContains(response, "Course Duration") - self.assertContains(response, "Course Description") - self.assertContains(response, "Course Short Description") - self.assertNotContains(response, "Course About Sidebar HTML") - self.assertContains(response, "Course Overview") - self.assertContains(response, "Course Introduction Video") - self.assertContains(response, "Requirements") - self.assertContains(response, "Course Banner Image") - self.assertContains(response, "Course Video Thumbnail Image") @ddt.ddt @@ -695,17 +536,17 @@ class CourseGradingTest(CourseTestCase): def test_initial_grader(self): test_grader = CourseGradingModel(self.course) - self.assertIsNotNone(test_grader.graders) - self.assertIsNotNone(test_grader.grade_cutoffs) + self.assertIsNotNone(test_grader.graders) # noqa: PT009 + self.assertIsNotNone(test_grader.grade_cutoffs) # noqa: PT009 def test_fetch_grader(self): test_grader = CourseGradingModel.fetch(self.course.id) - self.assertIsNotNone(test_grader.graders, "No graders") - self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") + self.assertIsNotNone(test_grader.graders, "No graders") # noqa: PT009 + self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") # noqa: PT009 for i, grader in enumerate(test_grader.graders): subgrader = CourseGradingModel.fetch_grader(self.course.id, i) - self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal") + self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal") # noqa: PT009 @mock.patch('common.djangoapps.track.event_transaction_utils.uuid4') @mock.patch('cms.djangoapps.models.settings.course_grading.tracker') @@ -716,10 +557,10 @@ def test_update_from_json(self, send_signal, tracker, uuid): test_grader = CourseGradingModel.fetch(self.course.id) # there should be no event raised after this call, since nothing got modified altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user) - self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update") + self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update") # noqa: PT009 test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2 altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user) - self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2") + self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2") # noqa: PT009 grading_policy_2 = self._grading_policy_hash_for_course() # test for bug LMS-11485 with modulestore().bulk_operations(self.course.id): @@ -731,15 +572,15 @@ def test_update_from_json(self, send_signal, tracker, uuid): # don't use altered cached def, get a fresh one CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user) altered_grader = CourseGradingModel.fetch(self.course.id) - self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__) + self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__) # noqa: PT009 grading_policy_3 = self._grading_policy_hash_for_course() test_grader.grade_cutoffs['D'] = 0.3 altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user) - self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") + self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") # noqa: PT009 grading_policy_4 = self._grading_policy_hash_for_course() test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0} altered_grader = CourseGradingModel.update_from_json(self.course.id, test_grader.__dict__, self.user) - self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") + self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") # noqa: PT009 # one for each of the calls to update_from_json() send_signal.assert_has_calls([ @@ -789,7 +630,7 @@ def test_must_fire_grading_event_and_signal_multiple_type(self): modulestore().get_course(self.course.id), course_grading_model.__dict__ ) - self.assertTrue(result) + self.assertTrue(result) # noqa: PT009 @override_waffle_flag(MATERIAL_RECOMPUTE_ONLY_FLAG, True) def test_must_fire_grading_event_and_signal_multiple_type_waffle_on(self): @@ -814,7 +655,7 @@ def test_must_fire_grading_event_and_signal_multiple_type_waffle_on(self): modulestore().get_course(self.course.id), course_grading_model.__dict__ ) - self.assertFalse(result) + self.assertFalse(result) # noqa: PT009 def test_must_fire_grading_event_and_signal_return_true(self): """ @@ -838,7 +679,7 @@ def test_must_fire_grading_event_and_signal_return_true(self): modulestore().get_course(self.course.id), course_grading_model.__dict__ ) - self.assertTrue(result) + self.assertTrue(result) # noqa: PT009 @mock.patch('common.djangoapps.track.event_transaction_utils.uuid4') @mock.patch('cms.djangoapps.models.settings.course_grading.tracker') @@ -849,18 +690,18 @@ def test_update_grader_from_json(self, send_signal, tracker, uuid): altered_grader = CourseGradingModel.update_grader_from_json( self.course.id, test_grader.graders[1], self.user ) - self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update") + self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update") # noqa: PT009 test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2 altered_grader = CourseGradingModel.update_grader_from_json( self.course.id, test_grader.graders[1], self.user) - self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2") + self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2") # noqa: PT009 grading_policy_2 = self._grading_policy_hash_for_course() test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1 altered_grader = CourseGradingModel.update_grader_from_json( self.course.id, test_grader.graders[1], self.user) - self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") + self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") # noqa: PT009 grading_policy_3 = self._grading_policy_hash_for_course() # one for each of the calls to update_grader_from_json() @@ -894,19 +735,19 @@ def test_update_cutoffs_from_json(self, tracker, uuid): # Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json # simply returns the cutoffs you send into it, rather than returning the db contents. altered_grader = CourseGradingModel.fetch(self.course.id) - self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update") + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update") # noqa: PT009 grading_policy_1 = self._grading_policy_hash_for_course() test_grader.grade_cutoffs['D'] = 0.3 CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user) altered_grader = CourseGradingModel.fetch(self.course.id) - self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D") + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D") # noqa: PT009 grading_policy_2 = self._grading_policy_hash_for_course() test_grader.grade_cutoffs['Pass'] = 0.75 CourseGradingModel.update_cutoffs_from_json(self.course.id, test_grader.grade_cutoffs, self.user) altered_grader = CourseGradingModel.fetch(self.course.id) - self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'") + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'") # noqa: PT009 # pylint: disable=line-too-long grading_policy_3 = self._grading_policy_hash_for_course() # one for each of the calls to update_cutoffs_from_json() @@ -930,13 +771,13 @@ def test_delete_grace_period(self): ) # update_grace_period_from_json doesn't return anything, so query the db for its contents. altered_grader = CourseGradingModel.fetch(self.course.id) - self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update") + self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update") # noqa: PT009 test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30} CourseGradingModel.update_grace_period_from_json( self.course.id, test_grader.grace_period, self.user) altered_grader = CourseGradingModel.fetch(self.course.id) - self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period") + self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period") # noqa: PT009 # pylint: disable=line-too-long test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0} # Now delete the grace period @@ -944,7 +785,7 @@ def test_delete_grace_period(self): # update_grace_period_from_json doesn't return anything, so query the db for its contents. altered_grader = CourseGradingModel.fetch(self.course.id) # Once deleted, the grace period should simply be None - self.assertEqual(None, altered_grader.grace_period, "Delete grace period") + self.assertEqual(None, altered_grader.grace_period, "Delete grace period") # noqa: PT009 @mock.patch('common.djangoapps.track.event_transaction_utils.uuid4') @mock.patch('cms.djangoapps.models.settings.course_grading.tracker') @@ -955,9 +796,9 @@ def test_update_section_grader_type(self, send_signal, tracker, uuid): block = modulestore().get_item(self.course.location) section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) - self.assertEqual('notgraded', section_grader_type['graderType']) - self.assertEqual(None, block.format) - self.assertEqual(False, block.graded) + self.assertEqual('notgraded', section_grader_type['graderType']) # noqa: PT009 + self.assertEqual(None, block.format) # noqa: PT009 + self.assertEqual(False, block.graded) # noqa: PT009 # Change the default grader type to Homework, which should also mark the section as graded CourseGradingModel.update_section_grader_type(self.course, 'Homework', self.user) @@ -965,9 +806,9 @@ def test_update_section_grader_type(self, send_signal, tracker, uuid): section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) grading_policy_1 = self._grading_policy_hash_for_course() - self.assertEqual('Homework', section_grader_type['graderType']) - self.assertEqual('Homework', block.format) - self.assertEqual(True, block.graded) + self.assertEqual('Homework', section_grader_type['graderType']) # noqa: PT009 + self.assertEqual('Homework', block.format) # noqa: PT009 + self.assertEqual(True, block.graded) # noqa: PT009 # Change the grader type back to notgraded, which should also unmark the section as graded CourseGradingModel.update_section_grader_type(self.course, 'notgraded', self.user) @@ -975,9 +816,9 @@ def test_update_section_grader_type(self, send_signal, tracker, uuid): section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) grading_policy_2 = self._grading_policy_hash_for_course() - self.assertEqual('notgraded', section_grader_type['graderType']) - self.assertEqual(None, block.format) - self.assertEqual(False, block.graded) + self.assertEqual('notgraded', section_grader_type['graderType']) # noqa: PT009 + self.assertEqual(None, block.format) # noqa: PT009 + self.assertEqual(False, block.graded) # noqa: PT009 # one for each call to update_section_grader_type() send_signal.assert_has_calls([ @@ -1011,21 +852,21 @@ def test_get_set_grader_types_ajax(self): grader_type_url_base = get_url(self.course.id, 'grading_handler') whole_model = self._model_from_url(grader_type_url_base) - self.assertIn('graders', whole_model) - self.assertIn('grade_cutoffs', whole_model) - self.assertIn('grace_period', whole_model) + self.assertIn('graders', whole_model) # noqa: PT009 + self.assertIn('grade_cutoffs', whole_model) # noqa: PT009 + self.assertIn('grace_period', whole_model) # noqa: PT009 # test post/update whole whole_model['grace_period'] = {'hours': 1, 'minutes': 30, 'seconds': 0} response = self.client.ajax_post(grader_type_url_base, whole_model) - self.assertEqual(200, response.status_code) + self.assertEqual(200, response.status_code) # noqa: PT009 whole_model = self._model_from_url(grader_type_url_base) - self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0}) + self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0}) # noqa: PT009 # test get one grader - self.assertGreater(len(whole_model['graders']), 1) # ensure test will make sense + self.assertGreater(len(whole_model['graders']), 1) # ensure test will make sense # noqa: PT009 grader_sample = self._model_from_url(grader_type_url_base + '/1') - self.assertEqual(grader_sample, whole_model['graders'][1]) + self.assertEqual(grader_sample, whole_model['graders'][1]) # noqa: PT009 @mock.patch('cms.djangoapps.contentstore.signals.signals.GRADING_POLICY_CHANGED.send') def test_add_delete_grader(self, send_signal): @@ -1046,19 +887,19 @@ def test_add_delete_grader(self, send_signal): new_grader ) grading_policy_hash1 = self._grading_policy_hash_for_course() - self.assertEqual(200, response.status_code) + self.assertEqual(200, response.status_code) # noqa: PT009 grader_sample = json.loads(response.content.decode('utf-8')) new_grader['id'] = len(original_model['graders']) - self.assertEqual(new_grader, grader_sample) + self.assertEqual(new_grader, grader_sample) # noqa: PT009 # test deleting the original grader response = self.client.delete(grader_type_url_base + '/1', HTTP_ACCEPT="application/json") grading_policy_hash2 = self._grading_policy_hash_for_course() - self.assertEqual(204, response.status_code) + self.assertEqual(204, response.status_code) # noqa: PT009 updated_model = self._model_from_url(grader_type_url_base) new_grader['id'] -= 1 # one fewer and the id mutates - self.assertIn(new_grader, updated_model['graders']) - self.assertNotIn(original_model['graders'][1], updated_model['graders']) + self.assertIn(new_grader, updated_model['graders']) # noqa: PT009 + self.assertNotIn(original_model['graders'][1], updated_model['graders']) # noqa: PT009 send_signal.assert_has_calls([ # once for the POST # pylint: disable=line-too-long @@ -1075,7 +916,7 @@ def setup_test_set_get_section_grader_ajax(self): self.populate_course() sections = modulestore().get_items(self.course.id, qualifiers={'category': "sequential"}) # see if test makes sense - self.assertGreater(len(sections), 0, "No sections found") + self.assertGreater(len(sections), 0, "No sections found") # noqa: PT009 section = sections[0] # just take the first one return reverse_usage_url('xblock_handler', section.location) @@ -1085,14 +926,14 @@ def test_set_get_section_grader_ajax(self): """ grade_type_url = self.setup_test_set_get_section_grader_ajax() response = self.client.ajax_post(grade_type_url, {'graderType': 'Homework'}) - self.assertEqual(200, response.status_code) + self.assertEqual(200, response.status_code) # noqa: PT009 response = self.client.get_json(grade_type_url + '?fields=graderType') - self.assertEqual(json.loads(response.content.decode('utf-8')).get('graderType'), 'Homework') + self.assertEqual(json.loads(response.content.decode('utf-8')).get('graderType'), 'Homework') # noqa: PT009 # and unset response = self.client.ajax_post(grade_type_url, {'graderType': 'notgraded'}) - self.assertEqual(200, response.status_code) + self.assertEqual(200, response.status_code) # noqa: PT009 response = self.client.get_json(grade_type_url + '?fields=graderType') - self.assertEqual(json.loads(response.content.decode('utf-8')).get('graderType'), 'notgraded') + self.assertEqual(json.loads(response.content.decode('utf-8')).get('graderType'), 'notgraded') # noqa: PT009 def _grading_policy_hash_for_course(self): return hash_grading_policy(modulestore().get_course(self.course.id).grading_policy) @@ -1118,16 +959,16 @@ def setUp(self): def test_fetch_initial_fields(self): test_model = CourseMetadata.fetch(self.course) - self.assertIn('display_name', test_model, 'Missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], self.course.display_name) + self.assertIn('display_name', test_model, 'Missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], self.course.display_name) # noqa: PT009 test_model = CourseMetadata.fetch(self.fullcourse) - self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') - self.assertIn('display_name', test_model, 'full missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name) - self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') - self.assertIn('showanswer', test_model, 'showanswer field ') - self.assertIn('xqa_key', test_model, 'xqa_key field ') + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') # noqa: PT009 + self.assertIn('display_name', test_model, 'full missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name) # noqa: PT009 + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') # noqa: PT009 + self.assertIn('showanswer', test_model, 'showanswer field ') # noqa: PT009 + self.assertIn('xqa_key', test_model, 'xqa_key field ') # noqa: PT009 @override_settings(ENABLE_EXPORT_GIT=True) def test_fetch_giturl_present(self): @@ -1135,7 +976,7 @@ def test_fetch_giturl_present(self): If feature flag ENABLE_EXPORT_GIT is on, show the setting as a non-deprecated Advanced Setting. """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertIn('giturl', test_model) + self.assertIn('giturl', test_model) # noqa: PT009 @override_settings(ENABLE_EXPORT_GIT=False) def test_fetch_giturl_not_present(self): @@ -1143,7 +984,7 @@ def test_fetch_giturl_not_present(self): If feature flag ENABLE_EXPORT_GIT is off, don't show the setting at all on the Advanced Settings page. """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertNotIn('giturl', test_model) + self.assertNotIn('giturl', test_model) # noqa: PT009 @override_settings( PROCTORING_BACKENDS={ @@ -1157,7 +998,7 @@ def test_fetch_proctoring_escalation_email_present(self): show the escalation email setting """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertIn('proctoring_escalation_email', test_model) + self.assertIn('proctoring_escalation_email', test_model) # noqa: PT009 @override_settings( PROCTORING_BACKENDS={ @@ -1171,7 +1012,7 @@ def test_fetch_proctoring_escalation_email_not_present(self): don't show the escalation email setting """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertNotIn('proctoring_escalation_email', test_model) + self.assertNotIn('proctoring_escalation_email', test_model) # noqa: PT009 @override_settings(ENABLE_EXPORT_GIT=False) def test_validate_update_filtered_off(self): @@ -1186,7 +1027,7 @@ def test_validate_update_filtered_off(self): }, user=self.user ) - self.assertNotIn('giturl', test_model) + self.assertNotIn('giturl', test_model) # noqa: PT009 @override_settings(ENABLE_EXPORT_GIT=True) def test_validate_update_filtered_on(self): @@ -1201,7 +1042,7 @@ def test_validate_update_filtered_on(self): }, user=self.user ) - self.assertIn('giturl', test_model) + self.assertIn('giturl', test_model) # noqa: PT009 @override_settings(ENABLE_EXPORT_GIT=True) def test_update_from_json_filtered_on(self): @@ -1215,7 +1056,7 @@ def test_update_from_json_filtered_on(self): }, user=self.user ) - self.assertIn('giturl', test_model) + self.assertIn('giturl', test_model) # noqa: PT009 @override_settings(ENABLE_EXPORT_GIT=False) def test_update_from_json_filtered_off(self): @@ -1229,7 +1070,7 @@ def test_update_from_json_filtered_off(self): }, user=self.user ) - self.assertNotIn('giturl', test_model) + self.assertNotIn('giturl', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True}) def test_edxnotes_present(self): @@ -1237,7 +1078,7 @@ def test_edxnotes_present(self): If feature flag ENABLE_EDXNOTES is on, show the setting as a non-deprecated Advanced Setting. """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertIn('edxnotes', test_model) + self.assertIn('edxnotes', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False}) def test_edxnotes_not_present(self): @@ -1245,7 +1086,7 @@ def test_edxnotes_not_present(self): If feature flag ENABLE_EDXNOTES is off, don't show the setting at all on the Advanced Settings page. """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertNotIn('edxnotes', test_model) + self.assertNotIn('edxnotes', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False}) def test_validate_update_filtered_edxnotes_off(self): @@ -1260,7 +1101,7 @@ def test_validate_update_filtered_edxnotes_off(self): }, user=self.user ) - self.assertNotIn('edxnotes', test_model) + self.assertNotIn('edxnotes', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True}) def test_validate_update_filtered_edxnotes_on(self): @@ -1275,7 +1116,7 @@ def test_validate_update_filtered_edxnotes_on(self): }, user=self.user ) - self.assertIn('edxnotes', test_model) + self.assertIn('edxnotes', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True}) def test_update_from_json_filtered_edxnotes_on(self): @@ -1289,7 +1130,7 @@ def test_update_from_json_filtered_edxnotes_on(self): }, user=self.user ) - self.assertIn('edxnotes', test_model) + self.assertIn('edxnotes', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False}) def test_update_from_json_filtered_edxnotes_off(self): @@ -1303,7 +1144,7 @@ def test_update_from_json_filtered_edxnotes_off(self): }, user=self.user ) - self.assertNotIn('edxnotes', test_model) + self.assertNotIn('edxnotes', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_OTHER_COURSE_SETTINGS': True}) def test_othercoursesettings_present(self): @@ -1311,7 +1152,7 @@ def test_othercoursesettings_present(self): If feature flag ENABLE_OTHER_COURSE_SETTINGS is on, show the setting in Advanced Settings. """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertIn('other_course_settings', test_model) + self.assertIn('other_course_settings', test_model) # noqa: PT009 @patch.dict(settings.FEATURES, {'ENABLE_OTHER_COURSE_SETTINGS': False}) def test_othercoursesettings_not_present(self): @@ -1319,16 +1160,16 @@ def test_othercoursesettings_not_present(self): If feature flag ENABLE_OTHER_COURSE_SETTINGS is off, don't show the setting at all in Advanced Settings. """ test_model = CourseMetadata.fetch(self.fullcourse) - self.assertNotIn('other_course_settings', test_model) + self.assertNotIn('other_course_settings', test_model) # noqa: PT009 def test_allow_unsupported_xblocks(self): """ allow_unsupported_xblocks is only shown in Advanced Settings if XBlockStudioConfigurationFlag is enabled. """ - self.assertNotIn('allow_unsupported_xblocks', CourseMetadata.fetch(self.fullcourse)) + self.assertNotIn('allow_unsupported_xblocks', CourseMetadata.fetch(self.fullcourse)) # noqa: PT009 XBlockStudioConfigurationFlag(enabled=True).save() - self.assertIn('allow_unsupported_xblocks', CourseMetadata.fetch(self.fullcourse)) + self.assertIn('allow_unsupported_xblocks', CourseMetadata.fetch(self.fullcourse)) # noqa: PT009 def test_validate_from_json_correct_inputs(self): is_valid, errors, test_model = CourseMetadata.validate_and_update_from_json( @@ -1340,13 +1181,13 @@ def test_validate_from_json_correct_inputs(self): }, user=self.user ) - self.assertTrue(is_valid) - self.assertEqual(len(errors), 0) + self.assertTrue(is_valid) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 self.update_check(test_model) # Tab gets tested in test_advanced_settings_munge_tabs - self.assertIn('advanced_modules', test_model, 'Missing advanced_modules') - self.assertEqual(test_model['advanced_modules']['value'], ['notes'], 'advanced_module is not updated') + self.assertIn('advanced_modules', test_model, 'Missing advanced_modules') # noqa: PT009 + self.assertEqual(test_model['advanced_modules']['value'], ['notes'], 'advanced_module is not updated') # noqa: PT009 # pylint: disable=line-too-long def test_validate_from_json_wrong_inputs(self): # input incorrectly formatted data @@ -1362,21 +1203,21 @@ def test_validate_from_json_wrong_inputs(self): ) # Check valid results from validate_and_update_from_json - self.assertFalse(is_valid) - self.assertEqual(len(errors), 3) - self.assertFalse(test_model) + self.assertFalse(is_valid) # noqa: PT009 + self.assertEqual(len(errors), 3) # noqa: PT009 + self.assertFalse(test_model) # noqa: PT009 error_keys = {error_obj['model']['display_name'] for error_obj in errors} test_keys = {'Advanced Module List', 'Course Advertised Start Date', 'Days Early for Beta Users'} - self.assertEqual(error_keys, test_keys) + self.assertEqual(error_keys, test_keys) # noqa: PT009 # try fresh fetch to ensure no update happened fresh = modulestore().get_course(self.course.id) test_model = CourseMetadata.fetch(fresh) - self.assertNotEqual(test_model['advertised_start']['value'], 1, + self.assertNotEqual(test_model['advertised_start']['value'], 1, # noqa: PT009 'advertised_start should not be updated to a wrong value') - self.assertNotEqual(test_model['days_early_for_beta']['value'], "supposed to be an integer", + self.assertNotEqual(test_model['days_early_for_beta']['value'], "supposed to be an integer", # noqa: PT009 'days_early_for beta should not be updated to a wrong value') def test_correct_http_status(self): @@ -1389,7 +1230,7 @@ def test_correct_http_status(self): "advanced_modules": {"value": 1, "display_name": "Advanced Module List", }, }) response = self.client.ajax_post(self.course_setting_url, json_data) - self.assertEqual(400, response.status_code) + self.assertEqual(400, response.status_code) # noqa: PT009 def test_update_from_json(self): test_model = CourseMetadata.update_from_json( @@ -1414,36 +1255,36 @@ def test_update_from_json(self): }, user=self.user ) - self.assertIn('display_name', test_model, 'Missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value") - self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') - self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value") + self.assertIn('display_name', test_model, 'Missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value") # noqa: PT009 + self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') # noqa: PT009 + self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value") # noqa: PT009 # pylint: disable=line-too-long def update_check(self, test_model): """ checks that updates were made """ - self.assertIn('display_name', test_model, 'Missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], self.course.display_name) - self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') - self.assertEqual(test_model['advertised_start']['value'], 'start A', "advertised_start not expected value") - self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') - self.assertEqual(test_model['days_early_for_beta']['value'], 2, "days_early_for_beta not expected value") + self.assertIn('display_name', test_model, 'Missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], self.course.display_name) # noqa: PT009 + self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') # noqa: PT009 + self.assertEqual(test_model['advertised_start']['value'], 'start A', "advertised_start not expected value") # noqa: PT009 # pylint: disable=line-too-long + self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') # noqa: PT009 + self.assertEqual(test_model['days_early_for_beta']['value'], 2, "days_early_for_beta not expected value") # noqa: PT009 # pylint: disable=line-too-long def test_http_fetch_initial_fields(self): response = self.client.get_json(self.course_setting_url) test_model = json.loads(response.content.decode('utf-8')) - self.assertIn('display_name', test_model, 'Missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], self.course.display_name) + self.assertIn('display_name', test_model, 'Missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], self.course.display_name) # noqa: PT009 response = self.client.get_json(self.fullcourse_setting_url) test_model = json.loads(response.content.decode('utf-8')) - self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') - self.assertIn('display_name', test_model, 'full missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name) - self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') - self.assertIn('showanswer', test_model, 'showanswer field ') - self.assertIn('xqa_key', test_model, 'xqa_key field ') + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') # noqa: PT009 + self.assertIn('display_name', test_model, 'full missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], self.fullcourse.display_name) # noqa: PT009 + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') # noqa: PT009 + self.assertIn('showanswer', test_model, 'showanswer field ') # noqa: PT009 + self.assertIn('xqa_key', test_model, 'xqa_key field ') # noqa: PT009 def test_http_update_from_json(self): response = self.client.ajax_post(self.course_setting_url, { @@ -1462,10 +1303,10 @@ def test_http_update_from_json(self): "display_name": {"value": "jolly roger"} }) test_model = json.loads(response.content.decode('utf-8')) - self.assertIn('display_name', test_model, 'Missing editable metadata field') - self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value") - self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') - self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value") + self.assertIn('display_name', test_model, 'Missing editable metadata field') # noqa: PT009 + self.assertEqual(test_model['display_name']['value'], 'jolly roger', "not expected value") # noqa: PT009 + self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') # noqa: PT009 + self.assertEqual(test_model['advertised_start']['value'], 'start B', "advertised_start not expected value") # noqa: PT009 # pylint: disable=line-too-long @patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': True}) @patch('xmodule.util.xmodule_django.get_current_request') @@ -1482,7 +1323,7 @@ def test_post_settings_with_staff_not_enrolled(self, mock_request): response = self.client.ajax_post(self.course_setting_url, { 'advanced_modules': {"value": [""]} }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 @ddt.data(True, False) @override_settings( @@ -1510,20 +1351,20 @@ def test_validate_update_does_not_allow_proctoring_provider_changes_after_course ) if staff_user: - self.assertTrue(did_validate) - self.assertEqual(len(errors), 0) - self.assertIn(field_name, test_model) + self.assertTrue(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 + self.assertIn(field_name, test_model) # noqa: PT009 else: - self.assertFalse(did_validate) - self.assertEqual(len(errors), 1) - self.assertEqual( + self.assertFalse(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 1) # noqa: PT009 + self.assertEqual( # noqa: PT009 errors[0].get('message'), ( 'The proctoring provider cannot be modified after a course has started.' ' Contact support@foobar.com for assistance' ) ) - self.assertIsNone(test_model) + self.assertIsNone(test_model) # noqa: PT009 @ddt.data(True, False) @override_settings( @@ -1546,10 +1387,10 @@ def test_validate_update_requires_escalation_email_if_relevant_flag_is_set(self, json_data, user=self.user ) - self.assertFalse(did_validate) - self.assertEqual(len(errors), 1) - self.assertIsNone(test_model) - self.assertEqual( + self.assertFalse(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 1) # noqa: PT009 + self.assertIsNone(test_model) # noqa: PT009 + self.assertEqual( # noqa: PT009 errors[0].get('message'), 'Provider \'test_proctoring_provider\' requires an exam escalation contact.' ) @@ -1568,9 +1409,9 @@ def test_validate_update_does_not_require_escalation_email_by_default(self): }, user=self.user ) - self.assertTrue(did_validate) - self.assertEqual(len(errors), 0) - self.assertIn('proctoring_provider', test_model) + self.assertTrue(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 + self.assertIn('proctoring_provider', test_model) # noqa: PT009 @override_settings( PROCTORING_BACKENDS={ @@ -1592,10 +1433,10 @@ def test_validate_update_cannot_unset_escalation_email_when_requires_escalation_ }, user=self.user ) - self.assertFalse(did_validate) - self.assertEqual(len(errors), 1) - self.assertIsNone(test_model) - self.assertEqual( + self.assertFalse(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 1) # noqa: PT009 + self.assertIsNone(test_model) # noqa: PT009 + self.assertEqual( # noqa: PT009 errors[0].get('message'), 'Provider \'test_proctoring_provider\' requires an exam escalation contact.' ) @@ -1615,10 +1456,10 @@ def test_validate_update_set_proctoring_provider_with_valid_escalation_email(sel }, user=self.user ) - self.assertTrue(did_validate) - self.assertEqual(len(errors), 0) - self.assertIn('proctoring_provider', test_model) - self.assertIn('proctoring_escalation_email', test_model) + self.assertTrue(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 + self.assertIn('proctoring_provider', test_model) # noqa: PT009 + self.assertIn('proctoring_escalation_email', test_model) # noqa: PT009 @override_settings( PROCTORING_BACKENDS={ @@ -1644,9 +1485,9 @@ def test_validate_update_disable_proctoring_with_no_escalation_email(self): }, user=self.user ) - self.assertTrue(did_validate) - self.assertEqual(len(errors), 0) - self.assertIn('enable_proctored_exams', test_model) + self.assertTrue(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 + self.assertIn('enable_proctored_exams', test_model) # noqa: PT009 @override_settings( PROCTORING_BACKENDS={ @@ -1664,11 +1505,11 @@ def test_validate_update_disable_proctoring_and_change_escalation_email(self): }, user=self.user ) - self.assertTrue(did_validate) - self.assertEqual(len(errors), 0) - self.assertIn('proctoring_provider', test_model) - self.assertIn('proctoring_escalation_email', test_model) - self.assertIn('enable_proctored_exams', test_model) + self.assertTrue(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 + self.assertIn('proctoring_provider', test_model) # noqa: PT009 + self.assertIn('proctoring_escalation_email', test_model) # noqa: PT009 + self.assertIn('enable_proctored_exams', test_model) # noqa: PT009 @override_settings( PROCTORING_BACKENDS={ @@ -1690,11 +1531,11 @@ def test_validate_update_disabled_proctoring_and_unset_escalation_email(self): }, user=self.user ) - self.assertTrue(did_validate) - self.assertEqual(len(errors), 0) - self.assertIn('proctoring_provider', test_model) - self.assertIn('proctoring_escalation_email', test_model) - self.assertIn('enable_proctored_exams', test_model) + self.assertTrue(did_validate) # noqa: PT009 + self.assertEqual(len(errors), 0) # noqa: PT009 + self.assertIn('proctoring_provider', test_model) # noqa: PT009 + self.assertIn('proctoring_escalation_email', test_model) # noqa: PT009 + self.assertIn('enable_proctored_exams', test_model) # noqa: PT009 def test_create_zendesk_tickets_present_for_edx_staff(self): """ @@ -1703,7 +1544,7 @@ def test_create_zendesk_tickets_present_for_edx_staff(self): self._set_request_user_to_staff() test_model = CourseMetadata.fetch(self.fullcourse) - self.assertIn('create_zendesk_tickets', test_model) + self.assertIn('create_zendesk_tickets', test_model) # noqa: PT009 def test_validate_update_does_not_filter_out_create_zendesk_tickets_for_edx_staff(self): """ @@ -1721,7 +1562,7 @@ def test_validate_update_does_not_filter_out_create_zendesk_tickets_for_edx_staf }, user=self.user ) - self.assertIn(field_name, test_model) + self.assertIn(field_name, test_model) # noqa: PT009 def test_update_from_json_does_not_filter_out_create_zendesk_tickets_for_edx_staff(self): """ @@ -1739,7 +1580,7 @@ def test_update_from_json_does_not_filter_out_create_zendesk_tickets_for_edx_sta }, user=self.user ) - self.assertIn(field_name, test_model) + self.assertIn(field_name, test_model) # noqa: PT009 def test_validate_update_does_not_filter_out_create_zendesk_tickets_for_course_staff(self): """ @@ -1755,7 +1596,7 @@ def test_validate_update_does_not_filter_out_create_zendesk_tickets_for_course_s }, user=self.user ) - self.assertIn(field_name, test_model) + self.assertIn(field_name, test_model) # noqa: PT009 def test_update_from_json_does_not_filter_out_create_zendesk_tickets_for_course_staff(self): """ @@ -1771,7 +1612,7 @@ def test_update_from_json_does_not_filter_out_create_zendesk_tickets_for_course_ }, user=self.user ) - self.assertIn(field_name, test_model) + self.assertIn(field_name, test_model) # noqa: PT009 def _set_request_user_to_staff(self): """ @@ -1816,9 +1657,9 @@ def test_team_content_groups_off(self): user=self.user ) - self.assertEqual(len(errors), 0) + self.assertEqual(len(errors), 0) # noqa: PT009 for team_set in updated_data["teams_configuration"]["value"]["team_sets"]: - self.assertNotIn("user_partition_id", team_set) + self.assertNotIn("user_partition_id", team_set) # noqa: PT009 @patch("cms.djangoapps.models.settings.course_metadata.CONTENT_GROUPS_FOR_TEAMS.is_enabled", lambda _: True) def test_team_content_groups_on(self): @@ -1856,9 +1697,9 @@ def test_team_content_groups_on(self): user=self.user ) - self.assertEqual(len(errors), 0) + self.assertEqual(len(errors), 0) # noqa: PT009 for team_set in updated_data["teams_configuration"]["value"]["team_sets"]: - self.assertIn("user_partition_id", team_set) + self.assertIn("user_partition_id", team_set) # noqa: PT009 class CourseGraderUpdatesTest(CourseTestCase): @@ -1875,17 +1716,17 @@ def setUp(self): def test_get(self): """Test getting a specific grading type record.""" resp = self.client.get_json(self.url + '/0') - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 obj = json.loads(resp.content.decode('utf-8')) - self.assertEqual(self.starting_graders[0], obj) + self.assertEqual(self.starting_graders[0], obj) # noqa: PT009 def test_delete(self): """Test deleting a specific grading type record.""" resp = self.client.delete(self.url + '/0', HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 current_graders = CourseGradingModel.fetch(self.course.id).graders - self.assertNotIn(self.starting_graders[0], current_graders) - self.assertEqual(len(self.starting_graders) - 1, len(current_graders)) + self.assertNotIn(self.starting_graders[0], current_graders) # noqa: PT009 + self.assertEqual(len(self.starting_graders) - 1, len(current_graders)) # noqa: PT009 def test_update(self): """Test updating a specific grading type record.""" @@ -1898,11 +1739,11 @@ def test_update(self): "weight": 17.3, } resp = self.client.ajax_post(self.url + '/0', grader) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 obj = json.loads(resp.content.decode('utf-8')) - self.assertEqual(obj, grader) + self.assertEqual(obj, grader) # noqa: PT009 current_graders = CourseGradingModel.fetch(self.course.id).graders - self.assertEqual(len(self.starting_graders), len(current_graders)) + self.assertEqual(len(self.starting_graders), len(current_graders)) # noqa: PT009 def test_add(self): """Test adding a grading type record.""" @@ -1917,139 +1758,10 @@ def test_add(self): "weight": 17.3, } resp = self.client.ajax_post(f'{self.url}/{len(self.starting_graders) + 1}', grader) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 obj = json.loads(resp.content.decode('utf-8')) - self.assertEqual(obj['id'], len(self.starting_graders)) + self.assertEqual(obj['id'], len(self.starting_graders)) # noqa: PT009 del obj['id'] - self.assertEqual(obj, grader) + self.assertEqual(obj, grader) # noqa: PT009 current_graders = CourseGradingModel.fetch(self.course.id).graders - self.assertEqual(len(self.starting_graders) + 1, len(current_graders)) - - -class CourseEnrollmentEndFieldTest(CourseTestCase): - """ - Base class to test the enrollment end fields in the course settings details view in Studio - when using marketing site flag and global vs non-global staff to access the page. - """ - - NOT_EDITABLE_HELPER_MESSAGE = "Contact your edX partner manager to update these settings." - NOT_EDITABLE_DATE_WRAPPER = "
" - NOT_EDITABLE_TIME_WRAPPER = "
" - NOT_EDITABLE_DATE_FIELD = "" - NOT_EDITABLE_TIME_FIELD = "" - - EDITABLE_DATE_WRAPPER = "
" - EDITABLE_TIME_WRAPPER = "
" - EDITABLE_DATE_FIELD = "" - EDITABLE_TIME_FIELD = "" - - EDITABLE_ELEMENTS = [ - EDITABLE_DATE_WRAPPER, - EDITABLE_TIME_WRAPPER, - EDITABLE_DATE_FIELD, - EDITABLE_TIME_FIELD, - ] - - NOT_EDITABLE_ELEMENTS = [ - NOT_EDITABLE_HELPER_MESSAGE, - NOT_EDITABLE_DATE_WRAPPER, - NOT_EDITABLE_TIME_WRAPPER, - NOT_EDITABLE_DATE_FIELD, - NOT_EDITABLE_TIME_FIELD, - ] - - def setUp(self): - """ - Initialize course used to test enrollment fields. - """ - super().setUp() - self.course = CourseFactory.create(org='edX', number='dummy', display_name='Marketing Site Course') - self.course_details_url = reverse_course_url('settings_handler', str(self.course.id)) - - def _get_course_details_response(self, global_staff): - """ - Return the course details page as either global or non-global staff - """ - user = UserFactory(is_staff=global_staff, password=self.TEST_PASSWORD) - CourseInstructorRole(self.course.id).add_users(user) - - self.client.login(username=user.username, password=self.TEST_PASSWORD) - - return self.client.get_html(self.course_details_url) - - def _verify_editable(self, response): - """ - Verify that the response has expected editable fields. - - Assert that all editable field content exists and no - uneditable field content exists for enrollment end fields. - """ - self.assertEqual(response.status_code, 200) - for element in self.NOT_EDITABLE_ELEMENTS: - self.assertNotContains(response, element) - - for element in self.EDITABLE_ELEMENTS: - self.assertContains(response, element) - - def _verify_not_editable(self, response): - """ - Verify that the response has expected non-editable fields. - - Assert that all uneditable field content exists and no - editable field content exists for enrollment end fields. - """ - self.assertEqual(response.status_code, 200) - for element in self.NOT_EDITABLE_ELEMENTS: - self.assertContains(response, element) - - for element in self.EDITABLE_ELEMENTS: - self.assertNotContains(response, element) - - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': False}) - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_course_details_with_disabled_setting_global_staff(self): - """ - Test that user enrollment end date is editable in response. - - Feature flag 'ENABLE_PUBLISHER' is not enabled. - User is global staff. - """ - self._verify_editable(self._get_course_details_response(True)) - - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': False}) - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_course_details_with_disabled_setting_non_global_staff(self): - """ - Test that user enrollment end date is editable in response. - - Feature flag 'ENABLE_PUBLISHER' is not enabled. - User is non-global staff. - """ - self._verify_editable(self._get_course_details_response(False)) - - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': True}) - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_course_details_with_enabled_setting_global_staff(self): - """ - Test that user enrollment end date is editable in response. - - Feature flag 'ENABLE_PUBLISHER' is enabled. - User is global staff. - """ - self._verify_editable(self._get_course_details_response(True)) - - @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': True}) - @override_settings(PLATFORM_NAME='edX') - @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) - def test_course_details_with_enabled_setting_non_global_staff(self): - """ - Test that user enrollment end date is not editable in response. - - Feature flag 'ENABLE_PUBLISHER' is enabled. - User is non-global staff. - """ - self._verify_not_editable(self._get_course_details_response(False)) + self.assertEqual(len(self.starting_graders) + 1, len(current_graders)) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_courseware_index.py b/cms/djangoapps/contentstore/tests/test_courseware_index.py index 3ab3fa373f81..7c59773bd871 100644 --- a/cms/djangoapps/contentstore/tests/test_courseware_index.py +++ b/cms/djangoapps/contentstore/tests/test_courseware_index.py @@ -5,7 +5,7 @@ import time from datetime import datetime from unittest import skip -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import ddt import pytest @@ -18,7 +18,7 @@ CourseAboutSearchIndexer, CoursewareSearchIndexer, LibrarySearchIndexer, - SearchIndexingError + SearchIndexingError, ) from cms.djangoapps.contentstore.signals.handlers import listen_for_course_publish, listen_for_library_update from cms.djangoapps.contentstore.tasks import update_search_index @@ -27,16 +27,20 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from openedx.core.djangoapps.models.course_details import CourseDetails -from xmodule.library_tools import normalize_key_for_search # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order - ModuleStoreTestCase, +from xmodule.library_tools import normalize_key_for_search # pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import SignalHandler, modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( # pylint: disable=wrong-import-order TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, SharedModuleStoreTestCase, ) -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions import UserPartition # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, + LibraryFactory, +) +from xmodule.partitions.partitions import UserPartition # pylint: disable=wrong-import-order COURSE_CHILD_STRUCTURE = { "course": "chapter", @@ -53,7 +57,7 @@ def create_children(store, parent, category, load_factor): child_object = BlockFactory.create( parent_location=parent.location, category=category, - display_name=f"{category} {child_index} {time.clock()}", # lint-amnesty, pylint: disable=no-member + display_name=f"{category} {child_index} {time.clock()}", # pylint: disable=no-member modulestore=store, publish_item=True, start=datetime(2015, 3, 1, tzinfo=UTC), @@ -85,7 +89,7 @@ class MixedWithOptionsTestCase(ModuleStoreTestCase): def setup_course_base(self, store): """ base version of setup_course_base is a no-op """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass @lazy def searcher(self): @@ -208,15 +212,15 @@ def _test_indexing_course(self, store): """ indexing course tests """ # Only published blocks should be in the index added_to_index = self.reindex_course(store) # This reindex may not be necessary (it may already be indexed) - self.assertEqual(added_to_index, 3) + self.assertEqual(added_to_index, 3) # noqa: PT009 response = self.search() - self.assertEqual(response["total"], 3) + self.assertEqual(response["total"], 3) # noqa: PT009 # Publish the vertical as is, and any unpublished children should now be available self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 4) + self.assertEqual(response["total"], 4) # noqa: PT009 def _test_not_indexing_unpublished_content(self, store): """ add a new one, only appers in index once added """ @@ -224,7 +228,7 @@ def _test_not_indexing_unpublished_content(self, store): self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 4) + self.assertEqual(response["total"], 4) # noqa: PT009 # Now add a new unit to the existing vertical BlockFactory.create( @@ -236,14 +240,14 @@ def _test_not_indexing_unpublished_content(self, store): ) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 4) + self.assertEqual(response["total"], 4) # noqa: PT009 # Now publish it and we should find it # Publish the vertical as is, and everything should be available self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 5) + self.assertEqual(response["total"], 5) # noqa: PT009 def _test_delete_course_from_search_index_after_course_deletion(self, store): # pylint: disable=invalid-name """ @@ -253,14 +257,14 @@ def _test_delete_course_from_search_index_after_course_deletion(self, store): # # index the course in search_index (it may already be indexed) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 1) + self.assertEqual(response["total"], 1) # noqa: PT009 # delete the course and look course in search_index store.delete_course(self.course.id, ModuleStoreEnum.UserID.test) - self.assertIsNone(store.get_course(self.course.id)) + self.assertIsNone(store.get_course(self.course.id)) # noqa: PT009 # Now, because of contentstore.signals.handlers.listen_for_course_delete, the index should already be updated: response = self.search() - self.assertEqual(response["total"], 0) + self.assertEqual(response["total"], 0) # noqa: PT009 def _test_deleting_item(self, store): """ test deleting an item """ @@ -268,19 +272,19 @@ def _test_deleting_item(self, store): self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 4) + self.assertEqual(response["total"], 4) # noqa: PT009 # just a delete should not change anything self.delete_item(store, self.html_unit.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 4) + self.assertEqual(response["total"], 4) # noqa: PT009 # but after publishing, we should no longer find the html_unit self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 3) + self.assertEqual(response["total"], 3) # noqa: PT009 def _test_start_date_propagation(self, store): """ make sure that the start date is applied at the right level """ @@ -291,7 +295,7 @@ def _test_start_date_propagation(self, store): self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search() - self.assertEqual(response["total"], 4) + self.assertEqual(response["total"], 4) # noqa: PT009 results = response["results"] date_map = { @@ -301,19 +305,19 @@ def _test_start_date_propagation(self, store): str(self.html_unit.location): later_date, } for result in results: - self.assertEqual(result["data"]["start_date"], date_map[result["data"]["id"]]) + self.assertEqual(result["data"]["start_date"], date_map[result["data"]["id"]]) # noqa: PT009 @patch('django.conf.settings.SEARCH_ENGINE', None) def _test_search_disabled(self, store): """ if search setting has it as off, confirm that nothing is indexed """ indexed_count = self.reindex_course(store) - self.assertFalse(indexed_count) + self.assertFalse(indexed_count) # noqa: PT009 def _test_time_based_index(self, store): """ Make sure that a time based request to index does not index anything too old """ self.publish_item(store, self.vertical.location) indexed_count = self.reindex_course(store) - self.assertEqual(indexed_count, 4) + self.assertEqual(indexed_count, 4) # noqa: PT009 # Add a new sequential sequential2 = BlockFactory.create( @@ -347,11 +351,11 @@ def _test_time_based_index(self, store): # because it is in a common subtree but not of the original vertical # because the original sequential's subtree is too old new_indexed_count = self.index_recent_changes(store, before_time) - self.assertEqual(new_indexed_count, 5) + self.assertEqual(new_indexed_count, 5) # noqa: PT009 # full index again indexed_count = self.reindex_course(store) - self.assertEqual(indexed_count, 7) + self.assertEqual(indexed_count, 7) # noqa: PT009 def _test_course_about_property_index(self, store): """ @@ -365,8 +369,8 @@ def _test_course_about_property_index(self, store): response = self.searcher.search( field_dictionary={"course": str(self.course.id)} ) - self.assertEqual(response["total"], 1) - self.assertEqual(response["results"][0]["data"]["content"]["display_name"], display_name) + self.assertEqual(response["total"], 1) # noqa: PT009 + self.assertEqual(response["results"][0]["data"]["content"]["display_name"], display_name) # noqa: PT009 def _test_course_about_store_index(self, store): """ @@ -382,8 +386,8 @@ def _test_course_about_store_index(self, store): response = self.searcher.search( field_dictionary={"course": str(self.course.id)} ) - self.assertEqual(response["total"], 1) - self.assertEqual(response["results"][0]["data"]["content"]["short_description"], short_description) + self.assertEqual(response["total"], 1) # noqa: PT009 + self.assertEqual(response["results"][0]["data"]["content"]["short_description"], short_description) # noqa: PT009 # pylint: disable=line-too-long def _test_course_about_mode_index(self, store): """ @@ -409,20 +413,20 @@ def _test_course_about_mode_index(self, store): response = self.searcher.search( field_dictionary={"course": str(self.course.id)} ) - self.assertEqual(response["total"], 1) - self.assertIn(CourseMode.HONOR, response["results"][0]["data"]["modes"]) - self.assertIn(CourseMode.VERIFIED, response["results"][0]["data"]["modes"]) + self.assertEqual(response["total"], 1) # noqa: PT009 + self.assertIn(CourseMode.HONOR, response["results"][0]["data"]["modes"]) # noqa: PT009 + self.assertIn(CourseMode.VERIFIED, response["results"][0]["data"]["modes"]) # noqa: PT009 def _test_course_location_info(self, store): """ Test that course location information is added to index """ self.publish_item(store, self.vertical.location) self.reindex_course(store) response = self.search(query_string="Html Content") - self.assertEqual(response["total"], 1) + self.assertEqual(response["total"], 1) # noqa: PT009 result = response["results"][0]["data"] - self.assertEqual(result["course_name"], "Search Index Test Course") - self.assertEqual(result["location"], ["Week 1", "Lesson 1", "Subsection 1"]) + self.assertEqual(result["course_name"], "Search Index Test Course") # noqa: PT009 + self.assertEqual(result["location"], ["Week 1", "Lesson 1", "Subsection 1"]) # noqa: PT009 def _test_course_location_null(self, store): """ Test that course location information is added to index """ @@ -451,17 +455,17 @@ def _test_course_location_null(self, store): ) self.reindex_course(store) response = self.search(query_string="Find Me") - self.assertEqual(response["total"], 1) + self.assertEqual(response["total"], 1) # noqa: PT009 result = response["results"][0]["data"] - self.assertEqual(result["course_name"], "Search Index Test Course") - self.assertEqual(result["location"], ["Week 1", CoursewareSearchIndexer.UNNAMED_MODULE_NAME, "Subsection 2"]) + self.assertEqual(result["course_name"], "Search Index Test Course") # noqa: PT009 + self.assertEqual(result["location"], ["Week 1", CoursewareSearchIndexer.UNNAMED_MODULE_NAME, "Subsection 2"]) # noqa: PT009 # pylint: disable=line-too-long @patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ErroringIndexEngine') def _test_exception(self, store): """ Test that exception within indexing yields a SearchIndexingError """ self.publish_item(store, self.vertical.location) - with self.assertRaises(SearchIndexingError): + with self.assertRaises(SearchIndexingError): # noqa: PT027 self.reindex_course(store) def test_indexing_course(self): @@ -541,7 +545,7 @@ def assert_search_count(self, expected_count): """ Check that the search within this course will yield the expected number of results """ response = self.searcher.search(field_dictionary={"course": self.course_id}) - self.assertEqual(response["total"], expected_count) + self.assertEqual(response["total"], expected_count) # noqa: PT009 def _do_test_large_course_deletion(self, store, load_factor): """ Test that deleting items from a course works even when present within a very large course """ @@ -667,7 +671,7 @@ def test_task_indexing_course(self): response = searcher.search( field_dictionary={"course": str(self.course.id)} ) - self.assertEqual(response["total"], 0) + self.assertEqual(response["total"], 0) # noqa: PT009 listen_for_course_publish(self, self.course.id) @@ -675,20 +679,20 @@ def test_task_indexing_course(self): response = searcher.search( field_dictionary={"course": str(self.course.id)} ) - self.assertEqual(response["total"], 3) + self.assertEqual(response["total"], 3) # noqa: PT009 def test_task_library_update(self): """ Making sure that the receiver correctly fires off the task when invoked by signal """ searcher = SearchEngine.get_search_engine(LibrarySearchIndexer.INDEX_NAME) library_search_key = str(normalize_key_for_search(self.library.location.library_key)) response = searcher.search(field_dictionary={"library": library_search_key}) - self.assertEqual(response["total"], 0) + self.assertEqual(response["total"], 0) # noqa: PT009 listen_for_library_update(self, self.library.location.library_key) # Note that this test will only succeed if celery is working in inline mode response = searcher.search(field_dictionary={"library": library_search_key}) - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 def test_ignore_ccx(self): """Test that we ignore CCX courses (it's too slow now).""" @@ -697,12 +701,12 @@ def test_ignore_ccx(self): # fall through to the normal indexing and raise an exception because # there is no data or backing course behind the course key. with patch('cms.djangoapps.contentstore.courseware_index.CoursewareSearchIndexer.index') as mock_index: - self.assertIsNone( + self.assertIsNone( # noqa: PT009 update_search_index( "ccx-v1:OpenEdX+FAKECOURSE+FAKERUN+ccx@1", "2020-09-28T16:41:57.150796" ) ) - self.assertFalse(mock_index.called) + self.assertFalse(mock_index.called) # noqa: PT009 @pytest.mark.django_db @@ -760,18 +764,18 @@ def _test_indexing_library(self, store): """ indexing course tests """ self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 added_to_index = self.reindex_library(store) - self.assertEqual(added_to_index, 2) + self.assertEqual(added_to_index, 2) # noqa: PT009 response = self.search() - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 def _test_creating_item(self, store): """ test updating an item """ self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 # updating a library item causes immediate reindexing data = "Some data" @@ -786,15 +790,15 @@ def _test_creating_item(self, store): self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 3) + self.assertEqual(response["total"], 3) # noqa: PT009 html_contents = [cont['html_content'] for cont in self._get_contents(response)] - self.assertIn(data, html_contents) + self.assertIn(data, html_contents) # noqa: PT009 def _test_updating_item(self, store): """ test updating an item """ self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 # updating a library item causes immediate reindexing new_data = "I'm new data" @@ -802,32 +806,32 @@ def _test_updating_item(self, store): self.update_item(store, self.html_unit1) self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 html_contents = [cont['html_content'] for cont in self._get_contents(response)] - self.assertIn(new_data, html_contents) + self.assertIn(new_data, html_contents) # noqa: PT009 def _test_deleting_item(self, store): """ test deleting an item """ self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 2) + self.assertEqual(response["total"], 2) # noqa: PT009 # deleting a library item causes immediate reindexing self.delete_item(store, self.html_unit1.location) self.reindex_library(store) response = self.search() - self.assertEqual(response["total"], 1) + self.assertEqual(response["total"], 1) # noqa: PT009 @patch('django.conf.settings.SEARCH_ENGINE', None) def _test_search_disabled(self, store): """ if search setting has it as off, confirm that nothing is indexed """ indexed_count = self.reindex_library(store) - self.assertFalse(indexed_count) + self.assertFalse(indexed_count) # noqa: PT009 @patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ErroringIndexEngine') def _test_exception(self, store): """ Test that exception within indexing yields a SearchIndexingError """ - with self.assertRaises(SearchIndexingError): + with self.assertRaises(SearchIndexingError): # noqa: PT027 self.reindex_library(store) @ddt.data(*WORKS_WITH_STORES) @@ -1173,9 +1177,9 @@ def test_content_group_gets_indexed(self): # Only published blocks should be in the index added_to_index = self.reindex_course(self.store) - self.assertEqual(added_to_index, 16) + self.assertEqual(added_to_index, 16) # noqa: PT009 response = self.searcher.search(field_dictionary={"course": str(self.course.id)}) - self.assertEqual(response["total"], 16) + self.assertEqual(response["total"], 16) # noqa: PT009 group_access_content = {'group_access': {666: [1]}} @@ -1189,46 +1193,46 @@ def test_content_group_gets_indexed(self): with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) - self.assertIn(self._html_experiment_group_result(self.html_unit4, [str(2)]), indexed_content) - self.assertIn(self._html_experiment_group_result(self.html_unit5, [str(3)]), indexed_content) - self.assertIn(self._html_experiment_group_result(self.html_unit6, [str(4)]), indexed_content) - self.assertNotIn(self._html_experiment_group_result(self.html_unit6, [str(5)]), indexed_content) - self.assertIn( + self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) # noqa: PT009 + self.assertIn(self._html_experiment_group_result(self.html_unit4, [str(2)]), indexed_content) # noqa: PT009 + self.assertIn(self._html_experiment_group_result(self.html_unit5, [str(3)]), indexed_content) # noqa: PT009 + self.assertIn(self._html_experiment_group_result(self.html_unit6, [str(4)]), indexed_content) # noqa: PT009 + self.assertNotIn(self._html_experiment_group_result(self.html_unit6, [str(5)]), indexed_content) # noqa: PT009 # pylint: disable=line-too-long + self.assertIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_0_vertical, [str(2)]), indexed_content ) - self.assertNotIn( + self.assertNotIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_1_vertical, [str(2)]), indexed_content ) - self.assertNotIn( + self.assertNotIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_2_vertical, [str(2)]), indexed_content ) - self.assertNotIn( + self.assertNotIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_0_vertical, [str(3)]), indexed_content ) - self.assertIn( + self.assertIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_1_vertical, [str(3)]), indexed_content ) - self.assertNotIn( + self.assertNotIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_2_vertical, [str(3)]), indexed_content ) - self.assertNotIn( + self.assertNotIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_0_vertical, [str(4)]), indexed_content ) - self.assertNotIn( + self.assertNotIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_1_vertical, [str(4)]), indexed_content ) - self.assertIn( + self.assertIn( # noqa: PT009 self._vertical_experiment_group_result(self.condition_2_vertical, [str(4)]), indexed_content ) @@ -1239,9 +1243,9 @@ def test_content_group_not_assigned(self): with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_nogroup_result(self.html_unit1), indexed_content) + self.assertIn(self._html_nogroup_result(self.html_unit1), indexed_content) # noqa: PT009 mock_index.reset_mock() def test_content_group_not_indexed_on_delete(self): @@ -1259,9 +1263,9 @@ def test_content_group_not_indexed_on_delete(self): # Checking group indexed correctly with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) + self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) # noqa: PT009 mock_index.reset_mock() empty_group_access = {'group_access': {}} @@ -1276,9 +1280,9 @@ def test_content_group_not_indexed_on_delete(self): # Checking group removed and not indexed any more with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_nogroup_result(self.html_unit1), indexed_content) + self.assertIn(self._html_nogroup_result(self.html_unit1), indexed_content) # noqa: PT009 mock_index.reset_mock() def test_group_indexed_only_on_assigned_html_block(self): @@ -1293,10 +1297,10 @@ def test_group_indexed_only_on_assigned_html_block(self): with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) - self.assertIn(self._html_nogroup_result(self.html_unit2), indexed_content) + self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) # noqa: PT009 + self.assertIn(self._html_nogroup_result(self.html_unit2), indexed_content) # noqa: PT009 mock_index.reset_mock() def test_different_groups_indexed_on_assigned_html_blocks(self): @@ -1318,10 +1322,10 @@ def test_different_groups_indexed_on_assigned_html_blocks(self): with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) - self.assertIn(self._html_group_result(self.html_unit2, [0]), indexed_content) + self.assertIn(self._html_group_result(self.html_unit1, [1]), indexed_content) # noqa: PT009 + self.assertIn(self._html_group_result(self.html_unit2, [0]), indexed_content) # noqa: PT009 mock_index.reset_mock() def test_different_groups_indexed_on_same_vertical_html_blocks(self): @@ -1347,8 +1351,8 @@ def test_different_groups_indexed_on_same_vertical_html_blocks(self): with patch(settings.SEARCH_ENGINE + '.index') as mock_index: self.reindex_course(self.store) - self.assertTrue(mock_index.called) + self.assertTrue(mock_index.called) # noqa: PT009 indexed_content = self._get_index_values_from_call_args(mock_index) - self.assertIn(self._html_group_result(self.html_unit2, [1]), indexed_content) - self.assertIn(self._html_group_result(self.html_unit3, [0]), indexed_content) + self.assertIn(self._html_group_result(self.html_unit2, [1]), indexed_content) # noqa: PT009 + self.assertIn(self._html_group_result(self.html_unit3, [0]), indexed_content) # noqa: PT009 mock_index.reset_mock() diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py index 792d0386ac48..dc151254dc6a 100644 --- a/cms/djangoapps/contentstore/tests/test_crud.py +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -17,22 +17,22 @@ class TemplateTests(ModuleStoreTestCase): """ def test_get_templates(self): found = templates.all_templates() - self.assertIsNotNone(found.get('course')) - self.assertIsNotNone(found.get('about')) - self.assertIsNotNone(found.get('html')) - self.assertEqual(len(found.get('course')), 0) - self.assertEqual(len(found.get('about')), 1) - self.assertGreaterEqual(len(found.get('html')), 2) + self.assertIsNotNone(found.get('course')) # noqa: PT009 + self.assertIsNotNone(found.get('about')) # noqa: PT009 + self.assertIsNotNone(found.get('html')) # noqa: PT009 + self.assertEqual(len(found.get('course')), 0) # noqa: PT009 + self.assertEqual(len(found.get('about')), 1) # noqa: PT009 + self.assertGreaterEqual(len(found.get('html')), 2) # noqa: PT009 def test_get_some_templates(self): course = CourseFactory.create() htmlblock = BlockFactory.create(category="html", parent_location=course.location) - self.assertEqual(len(SequenceBlock.templates()), 0) - self.assertGreater(len(htmlblock.templates()), 0) - self.assertIsNone(SequenceBlock.get_template('doesntexist.yaml')) - self.assertIsNone(htmlblock.get_template('doesntexist.yaml')) - self.assertIsNotNone(htmlblock.get_template('announcement.yaml')) + self.assertEqual(len(SequenceBlock.templates()), 0) # noqa: PT009 + self.assertGreater(len(htmlblock.templates()), 0) # noqa: PT009 + self.assertIsNone(SequenceBlock.get_template('doesntexist.yaml')) # noqa: PT009 + self.assertIsNone(htmlblock.get_template('doesntexist.yaml')) # noqa: PT009 + self.assertIsNotNone(htmlblock.get_template('announcement.yaml')) # noqa: PT009 def test_factories(self): test_course = CourseFactory.create( @@ -42,24 +42,24 @@ def test_factories(self): display_name='fun test course', user_id=ModuleStoreEnum.UserID.test, ) - self.assertIsInstance(test_course, CourseBlock) - self.assertEqual(test_course.display_name, 'fun test course') + self.assertIsInstance(test_course, CourseBlock) # noqa: PT009 + self.assertEqual(test_course.display_name, 'fun test course') # noqa: PT009 course_from_store = self.store.get_course(test_course.id) - self.assertEqual(course_from_store.id.org, 'testx') - self.assertEqual(course_from_store.id.course, 'course') - self.assertEqual(course_from_store.id.run, '2014') + self.assertEqual(course_from_store.id.org, 'testx') # noqa: PT009 + self.assertEqual(course_from_store.id.course, 'course') # noqa: PT009 + self.assertEqual(course_from_store.id.run, '2014') # noqa: PT009 test_chapter = BlockFactory.create( parent_location=test_course.location, category='chapter', display_name='chapter 1' ) - self.assertIsInstance(test_chapter, SequenceBlock) + self.assertIsInstance(test_chapter, SequenceBlock) # noqa: PT009 # refetch parent which should now point to child test_course = self.store.get_course(test_course.id.version_agnostic()) - self.assertIn(test_chapter.location, test_course.children) + self.assertIn(test_chapter.location, test_course.children) # noqa: PT009 - with self.assertRaises(DuplicateCourseError): + with self.assertRaises(DuplicateCourseError): # noqa: PT027 CourseFactory.create( org='testx', course='course', @@ -81,9 +81,9 @@ def test_temporary_xblocks(self): test_course.runtime, test_course.id, 'chapter', fields={'display_name': 'chapter n'}, parent_xblock=test_course ) - self.assertIsInstance(test_chapter, SequenceBlock) - self.assertEqual(test_chapter.display_name, 'chapter n') - self.assertIn(test_chapter, test_course.get_children()) + self.assertIsInstance(test_chapter, SequenceBlock) # noqa: PT009 + self.assertEqual(test_chapter.display_name, 'chapter n') # noqa: PT009 + self.assertIn(test_chapter, test_course.get_children()) # noqa: PT009 # test w/ a definition (e.g., a problem) test_def_content = 'boo' @@ -91,11 +91,11 @@ def test_temporary_xblocks(self): test_course.runtime, test_course.id, 'problem', fields={'data': test_def_content}, parent_xblock=test_chapter ) - self.assertIsInstance(test_problem, ProblemBlock) - self.assertEqual(test_problem.data, test_def_content) - self.assertIn(test_problem, test_chapter.get_children()) + self.assertIsInstance(test_problem, ProblemBlock) # noqa: PT009 + self.assertEqual(test_problem.data, test_def_content) # noqa: PT009 + self.assertIn(test_problem, test_chapter.get_children()) # noqa: PT009 test_problem.display_name = 'test problem' - self.assertEqual(test_problem.display_name, 'test problem') + self.assertEqual(test_problem.display_name, 'test problem') # noqa: PT009 def test_delete_course(self): test_course = CourseFactory.create( @@ -113,13 +113,13 @@ def test_delete_course(self): id_locator = test_course.id.for_branch(ModuleStoreEnum.BranchName.draft) # verify it can be retrieved by id - self.assertIsInstance(self.store.get_course(id_locator), CourseBlock) + self.assertIsInstance(self.store.get_course(id_locator), CourseBlock) # noqa: PT009 # TODO reenable when split_draft supports getting specific versions # guid_locator = test_course.location.course_agnostic() # Verify it can be retrieved by guid # self.assertIsInstance(self.store.get_item(guid_locator), CourseBlock) self.store.delete_course(id_locator, ModuleStoreEnum.UserID.test) # Test can no longer retrieve by id. - self.assertIsNone(self.store.get_course(id_locator)) + self.assertIsNone(self.store.get_course(id_locator)) # noqa: PT009 # But can retrieve by guid -- same TODO as above # self.assertIsInstance(self.store.get_item(guid_locator), CourseBlock) diff --git a/cms/djangoapps/contentstore/tests/test_exams.py b/cms/djangoapps/contentstore/tests/test_exams.py index 823038957714..6974c234c019 100644 --- a/cms/djangoapps/contentstore/tests/test_exams.py +++ b/cms/djangoapps/contentstore/tests/test_exams.py @@ -3,7 +3,7 @@ """ import itertools from datetime import datetime, timedelta, timezone -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import ddt from django.conf import settings @@ -14,7 +14,7 @@ from cms.djangoapps.contentstore.signals.handlers import listen_for_course_publish from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @ddt.ddt @@ -182,7 +182,7 @@ def test_feature_flag_off(self, mock_patch_course_exams): @ddt.data( *itertools.product( (True, False), - (datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc), None), + (datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc), None), # noqa: UP017 ('null', 'lti_external'), ) ) @@ -236,7 +236,7 @@ def test_subsection_due_date_prioritized_instructor_paced( Test that exam due date is computed correctly. """ self.course.self_paced = is_self_paced - self.course.end = datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc) + self.course.end = datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc) # noqa: UP017 self.course.proctoring_provider = proctoring_provider self.course = self.update_course(self.course, 1) diff --git a/cms/djangoapps/contentstore/tests/test_export_git.py b/cms/djangoapps/contentstore/tests/test_export_git.py index e65c441d0e90..a986b342041d 100644 --- a/cms/djangoapps/contentstore/tests/test_export_git.py +++ b/cms/djangoapps/contentstore/tests/test_export_git.py @@ -14,12 +14,12 @@ import cms.djangoapps.contentstore.git_export_utils as git_export_utils from cms.djangoapps.contentstore.utils import reverse_course_url -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from .utils import CourseTestCase TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex # noqa: UP031 @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) @@ -46,7 +46,7 @@ def make_bare_repo_with_course(self, repo_name): os.mkdir(repo_dir) self.addCleanup(shutil.rmtree, repo_dir) - bare_repo_dir = '{}/{}.git'.format( + bare_repo_dir = '{}/{}.git'.format( # noqa: UP032 os.path.abspath(git_export_utils.GIT_REPO_EXPORT_DIR), repo_name ) @@ -136,7 +136,7 @@ def test_dirty_repo(self): ) test_file = os.path.join(repo_dir, 'test.txt') open(test_file, 'a').close() - self.assertTrue(os.path.isfile(test_file)) + self.assertTrue(os.path.isfile(test_file)) # noqa: PT009 git_export_utils.export_to_git(self.course.id, self.course_block.giturl, self.user) - self.assertFalse(os.path.isfile(test_file)) + self.assertFalse(os.path.isfile(test_file)) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_filters.py b/cms/djangoapps/contentstore/tests/test_filters.py index 4011ae728b34..2f9dfce50813 100644 --- a/cms/djangoapps/contentstore/tests/test_filters.py +++ b/cms/djangoapps/contentstore/tests/test_filters.py @@ -4,12 +4,12 @@ from datetime import datetime from urllib.parse import urljoin -from pytz import UTC - from django.test import override_settings -from cms.djangoapps.contentstore import asset_storage_handlers from opaque_keys.edx.locator import CourseLocator from openedx_filters import PipelineStep +from pytz import UTC + +from cms.djangoapps.contentstore import asset_storage_handlers from xmodule.contentstore.content import StaticContent from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -74,7 +74,7 @@ def test_lms_url_requested_filter_executed(self): self.course_key ) - self.assertEqual(output.get('external_url'), urljoin('https://lms-url-creation', self.asset_url)) + self.assertEqual(output.get('external_url'), urljoin('https://lms-url-creation', self.asset_url)) # noqa: PT009 @override_settings(OPEN_EDX_FILTERS_CONFIG={}, LMS_ROOT_URL="https://lms-base") def test_lms_url_requested_without_filter_configuration(self): @@ -95,4 +95,4 @@ def test_lms_url_requested_without_filter_configuration(self): self.course_key ) - self.assertEqual(output.get('external_url'), urljoin('https://lms-base', self.asset_url)) + self.assertEqual(output.get('external_url'), urljoin('https://lms-base', self.asset_url)) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_gating.py b/cms/djangoapps/contentstore/tests/test_gating.py index 91d2395bfde4..fa173d394911 100644 --- a/cms/djangoapps/contentstore/tests/test_gating.py +++ b/cms/djangoapps/contentstore/tests/test_gating.py @@ -9,8 +9,13 @@ from cms.djangoapps.contentstore.signals.handlers import handle_item_deleted from openedx.core.lib.gating import api as gating_api -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) class TestHandleItemDeleted(ModuleStoreTestCase, MilestonesTestCaseMixin): diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index 3ee991493196..1aaab8b52e36 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -5,16 +5,15 @@ from unittest import mock from django.utils import translation - from django.utils.translation import get_language from xblock.core import XBlock + +from cms.djangoapps.contentstore.views.preview import _prepare_runtime_for_preview from xmodule.modulestore.django import XBlockI18nService from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from xmodule.tests.test_export import PureXBlock -from cms.djangoapps.contentstore.views.preview import _prepare_runtime_for_preview - class FakeTranslations(XBlockI18nService): """A test GNUTranslations class that takes a map of msg -> translations.""" @@ -73,8 +72,8 @@ def get_block_i18n_service(self, block): return the block i18n service. """ i18n_service = self.block.runtime.service(block, 'i18n') - self.assertIsNotNone(i18n_service) - self.assertIsInstance(i18n_service, XBlockI18nService) + self.assertIsNotNone(i18n_service) # noqa: PT009 + self.assertIsInstance(i18n_service, XBlockI18nService) # noqa: PT009 return i18n_service def test_django_service_translation_works(self): @@ -112,10 +111,10 @@ def __exit__(self, _type, _value, _traceback): # wrap the ugettext functions so that 'XYZ ' will prefix each translation with wrap_ugettext_with_xyz(french_translation): - self.assertEqual(i18n_service.ugettext(self.test_language), 'XYZ dummy language') + self.assertEqual(i18n_service.ugettext(self.test_language), 'XYZ dummy language') # noqa: PT009 # Check that the old ugettext has been put back into place - self.assertEqual(i18n_service.ugettext(self.test_language), 'dummy language') + self.assertEqual(i18n_service.ugettext(self.test_language), 'dummy language') # noqa: PT009 @mock.patch('django.utils.translation.gettext', mock.Mock(return_value='XYZ-TEST-LANGUAGE')) def test_django_translator_in_use_with_empty_block(self): @@ -123,7 +122,7 @@ def test_django_translator_in_use_with_empty_block(self): Test: Django default translator should in use if we have an empty block """ i18n_service = XBlockI18nService(None) - self.assertEqual(i18n_service.ugettext(self.test_language), 'XYZ-TEST-LANGUAGE') + self.assertEqual(i18n_service.ugettext(self.test_language), 'XYZ-TEST-LANGUAGE') # noqa: PT009 @mock.patch('django.utils.translation.gettext', mock.Mock(return_value='XYZ-TEST-LANGUAGE')) def test_message_catalog_translations(self): @@ -141,24 +140,24 @@ def test_message_catalog_translations(self): with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir, languages=[get_language()])): i18n_service = self.get_block_i18n_service(self.block) - self.assertEqual(i18n_service.ugettext('Hello'), 'es-hello-world') + self.assertEqual(i18n_service.ugettext('Hello'), 'es-hello-world') # noqa: PT009 translation.activate("ar") with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir, languages=[get_language()])): i18n_service = self.get_block_i18n_service(self.block) - self.assertEqual(i18n_service.gettext('Hello'), 'Hello') - self.assertNotEqual(i18n_service.gettext('Hello'), 'fr-hello-world') - self.assertNotEqual(i18n_service.gettext('Hello'), 'es-hello-world') + self.assertEqual(i18n_service.gettext('Hello'), 'Hello') # noqa: PT009 + self.assertNotEqual(i18n_service.gettext('Hello'), 'fr-hello-world') # noqa: PT009 + self.assertNotEqual(i18n_service.gettext('Hello'), 'es-hello-world') # noqa: PT009 translation.activate("fr") with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir, languages=[get_language()])): i18n_service = self.get_block_i18n_service(self.block) - self.assertEqual(i18n_service.ugettext('Hello'), 'fr-hello-world') + self.assertEqual(i18n_service.ugettext('Hello'), 'fr-hello-world') # noqa: PT009 def test_i18n_service_callable(self): """ Test: i18n service should be callable in studio. """ - self.assertTrue(callable(self.block.runtime._services.get('i18n'))) # pylint: disable=protected-access + self.assertTrue(callable(self.block.runtime._services.get('i18n'))) # pylint: disable=protected-access # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index dbcef8e79ba7..af703184f776 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -11,10 +11,12 @@ import ddt from django.conf import settings +from django.core.files.storage import storages from django.test.client import Client from django.test.utils import override_settings -from django.core.files.storage import storages +from storages.backends.s3boto3 import S3Boto3Storage +from common.djangoapps.util.storage import resolve_storage_backend from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError from xmodule.modulestore import ModuleStoreEnum @@ -22,11 +24,8 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.xml_importer import import_course_from_xml -from common.djangoapps.util.storage import resolve_storage_backend -from storages.backends.s3boto3 import S3Boto3Storage - TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex # noqa: UP031 TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -73,7 +72,7 @@ def load_test_import_course(self, target_id=None, create_if_not_present=True, mo ) course_id = module_store.make_course_key('edX', 'test_import_course', '2012_Fall') course = module_store.get_course(course_id) - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 return module_store, content_store, course @@ -90,7 +89,7 @@ def test_import_course_into_similar_namespace(self): target_id=course.id, verbose=True, ) - self.assertEqual(len(course_items), 1) + self.assertEqual(len(course_items), 1) # noqa: PT009 def test_unicode_chars_in_course_name_import(self): """ @@ -110,10 +109,10 @@ def test_unicode_chars_in_course_name_import(self): ) course = module_store.get_course(course_id) - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 # test that course 'display_name' same as imported course 'display_name' - self.assertEqual(course.display_name, "Φυσικά το όνομα Unicode") + self.assertEqual(course.display_name, "Φυσικά το όνομα Unicode") # noqa: PT009 def test_static_import(self): ''' @@ -123,9 +122,9 @@ def test_static_import(self): # make sure we have ONE asset in our contentstore ("should_be_imported.html") all_assets, count = content_store.get_all_content_for_course(course.id) - print("len(all_assets)=%d" % len(all_assets)) - self.assertEqual(len(all_assets), 1) - self.assertEqual(count, 1) + print("len(all_assets)=%d" % len(all_assets)) # noqa: UP031 + self.assertEqual(len(all_assets), 1) # noqa: PT009 + self.assertEqual(count, 1) # noqa: PT009 content = None try: @@ -134,11 +133,11 @@ def test_static_import(self): except NotFoundError: pass - self.assertIsNotNone(content) + self.assertIsNotNone(content) # noqa: PT009 # make sure course.static_asset_path is correct print(f"static_asset_path = {course.static_asset_path}") - self.assertEqual(course.static_asset_path, 'test_import_course') + self.assertEqual(course.static_asset_path, 'test_import_course') # noqa: PT009 def test_asset_import_nostatic(self): ''' @@ -158,8 +157,8 @@ def test_asset_import_nostatic(self): # make sure we have NO assets in our contentstore all_assets, count = content_store.get_all_content_for_course(course.id) - self.assertEqual(all_assets, []) - self.assertEqual(count, 0) + self.assertEqual(all_assets, []) # noqa: PT009 + self.assertEqual(count, 0) # noqa: PT009 def test_no_static_link_rewrites_on_import(self): module_store = modulestore() @@ -170,15 +169,15 @@ def test_no_static_link_rewrites_on_import(self): course_key = courses[0].id handouts = module_store.get_item(course_key.make_usage_key('course_info', 'handouts')) - self.assertIn('/static/', handouts.data) + self.assertIn('/static/', handouts.data) # noqa: PT009 handouts = module_store.get_item(course_key.make_usage_key('html', 'toyhtml')) - self.assertIn('/static/', handouts.data) + self.assertIn('/static/', handouts.data) # noqa: PT009 def test_tab_name_imports_correctly(self): _module_store, _content_store, course = self.load_test_import_course() print(f"course tabs = {course.tabs}") - self.assertEqual(course.tabs[1]['name'], 'Syllabus') + self.assertEqual(course.tabs[1]['name'], 'Syllabus') # noqa: PT009 def test_reimport(self): __, __, course = self.load_test_import_course(create_if_not_present=True) @@ -201,16 +200,16 @@ def test_rewrite_reference_list(self): conditional_block = module_store.get_item( target_id.make_usage_key('conditional', 'condone') ) - self.assertIsNotNone(conditional_block) + self.assertIsNotNone(conditional_block) # noqa: PT009 different_course_id = module_store.make_course_key('edX', 'different_course', 'course_run') - self.assertListEqual( + self.assertListEqual( # noqa: PT009 [ target_id.make_usage_key('problem', 'choiceprob'), different_course_id.make_usage_key('html', 'for_testing_import_rewrites') ], conditional_block.sources_list ) - self.assertListEqual( + self.assertListEqual( # noqa: PT009 [ target_id.make_usage_key('html', 'congrats'), target_id.make_usage_key('html', 'secret_page') @@ -240,7 +239,7 @@ def test_rewrite_reference_value_dict_draft(self): {"0": '9f0941d021414798836ef140fb5f6841', "1": '0faf29473cf1497baa33fcc828b179cd'}, ) - def _verify_split_test_import(self, target_course_name, source_course_name, split_test_name, groups_to_verticals): # lint-amnesty, pylint: disable=missing-function-docstring + def _verify_split_test_import(self, target_course_name, source_course_name, split_test_name, groups_to_verticals): # pylint: disable=missing-function-docstring module_store = modulestore() target_id = module_store.make_course_key('testX', target_course_name, 'copy_run') import_course_from_xml( @@ -254,13 +253,13 @@ def _verify_split_test_import(self, target_course_name, source_course_name, spli split_test_block = module_store.get_item( target_id.make_usage_key('split_test', split_test_name) ) - self.assertIsNotNone(split_test_block) + self.assertIsNotNone(split_test_block) # noqa: PT009 remapped_verticals = { key: target_id.make_usage_key('vertical', value) for key, value in groups_to_verticals.items() } - self.assertEqual(remapped_verticals, split_test_block.group_id_to_child) + self.assertEqual(remapped_verticals, split_test_block.group_id_to_child) # noqa: PT009 def test_video_components_present_while_import(self): """ @@ -278,7 +277,7 @@ def test_video_components_present_while_import(self): vertical = module_store.get_item(re_course.id.make_usage_key('vertical', 'vertical_test')) video = module_store.get_item(vertical.children[1]) - self.assertEqual(video.display_name, 'default') + self.assertEqual(video.display_name, 'default') # noqa: PT009 @override_settings( COURSE_IMPORT_EXPORT_STORAGE="cms.djangoapps.contentstore.storage.ImportExportS3Storage", @@ -291,7 +290,7 @@ def test_video_components_present_while_import(self): def test_default_storage(self): """ Ensure the default storage is invoked, even if course export storage is configured """ storage = storages["default"] - self.assertEqual(storage.__class__.__name__, "FileSystemStorage") + self.assertEqual(storage.__class__.__name__, "FileSystemStorage") # noqa: PT009 @override_settings( COURSE_IMPORT_EXPORT_STORAGE="cms.djangoapps.contentstore.storage.ImportExportS3Storage", @@ -308,8 +307,8 @@ def test_resolve_happy_path_storage(self): storage_key="course_import_export", legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" ) - self.assertEqual(storage.__class__.__name__, "ImportExportS3Storage") - self.assertEqual(storage.bucket_name, "bucket_name_test") + self.assertEqual(storage.__class__.__name__, "ImportExportS3Storage") # noqa: PT009 + self.assertEqual(storage.bucket_name, "bucket_name_test") # noqa: PT009 @override_settings() def test_resolve_storage_with_no_config(self): @@ -320,7 +319,7 @@ def test_resolve_storage_with_no_config(self): storage_key="course_import_export", legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" ) - self.assertEqual(storage.__class__.__name__, "FileSystemStorage") + self.assertEqual(storage.__class__.__name__, "FileSystemStorage") # noqa: PT009 @override_settings( COURSE_IMPORT_EXPORT_STORAGE=None, @@ -338,8 +337,8 @@ def test_resolve_storage_using_django5_settings(self): storage_key="course_import_export", legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" ) - self.assertEqual(storage.__class__.__name__, "ImportExportS3Storage") - self.assertEqual(storage.bucket_name, "bucket_name_test") + self.assertEqual(storage.__class__.__name__, "ImportExportS3Storage") # noqa: PT009 + self.assertEqual(storage.bucket_name, "bucket_name_test") # noqa: PT009 @override_settings( STORAGES={ @@ -359,5 +358,5 @@ def test_resolve_storage_using_django5_settings_with_options(self): storage_key="course_import_export", legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" ) - self.assertEqual(storage.__class__.__name__, S3Boto3Storage.__name__) - self.assertEqual(storage.bucket_name, "bucket_name_test") + self.assertEqual(storage.__class__.__name__, S3Boto3Storage.__name__) # noqa: PT009 + self.assertEqual(storage.bucket_name, "bucket_name_test") # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_import_draft_order.py b/cms/djangoapps/contentstore/tests/test_import_draft_order.py index f87efc043fbc..3783dafc9708 100644 --- a/cms/djangoapps/contentstore/tests/test_import_draft_order.py +++ b/cms/djangoapps/contentstore/tests/test_import_draft_order.py @@ -14,7 +14,7 @@ # This test is in the CMS module because the test configuration to use a draft # modulestore is dependent on django. -class DraftReorderTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class DraftReorderTestCase(ModuleStoreTestCase): # pylint: disable=missing-class-docstring def test_order(self): """ @@ -36,21 +36,21 @@ def test_order(self): # 2 , 4 , 6 , 5 , and 0 respectively. # # '5a05be9d59fc4bb79282c94c9e6b88c7' and 'second' are public verticals. - self.assertEqual(7, len(verticals)) - self.assertEqual(course_key.make_usage_key('vertical', 'z'), verticals[0]) - self.assertEqual(course_key.make_usage_key('vertical', '5a05be9d59fc4bb79282c94c9e6b88c7'), verticals[1]) - self.assertEqual(course_key.make_usage_key('vertical', 'a'), verticals[2]) - self.assertEqual(course_key.make_usage_key('vertical', 'second'), verticals[3]) - self.assertEqual(course_key.make_usage_key('vertical', 'b'), verticals[4]) - self.assertEqual(course_key.make_usage_key('vertical', 'd'), verticals[5]) - self.assertEqual(course_key.make_usage_key('vertical', 'c'), verticals[6]) + self.assertEqual(7, len(verticals)) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'z'), verticals[0]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', '5a05be9d59fc4bb79282c94c9e6b88c7'), verticals[1]) # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(course_key.make_usage_key('vertical', 'a'), verticals[2]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'second'), verticals[3]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'b'), verticals[4]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'd'), verticals[5]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'c'), verticals[6]) # noqa: PT009 # Now also test that the verticals in a second sequential are correct. sequential = store.get_item(course_key.make_usage_key('sequential', 'secondseq')) verticals = sequential.children # 'asecond' and 'zsecond' are drafts with 'index_in_children_list' 0 and 2, respectively. # 'secondsubsection' is a public vertical. - self.assertEqual(3, len(verticals)) - self.assertEqual(course_key.make_usage_key('vertical', 'asecond'), verticals[0]) - self.assertEqual(course_key.make_usage_key('vertical', 'secondsubsection'), verticals[1]) - self.assertEqual(course_key.make_usage_key('vertical', 'zsecond'), verticals[2]) + self.assertEqual(3, len(verticals)) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'asecond'), verticals[0]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'secondsubsection'), verticals[1]) # noqa: PT009 + self.assertEqual(course_key.make_usage_key('vertical', 'zsecond'), verticals[2]) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py index 0a0e8663bae3..8b998ff852ff 100644 --- a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py +++ b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py @@ -62,5 +62,5 @@ def _assert_import(self, course_dir, expected_field_val, has_draft=False): xblock_location = courses[0].id.make_usage_key('stubxblock', 'xblock_test') xblock = self.store.get_item(xblock_location) - self.assertTrue(isinstance(xblock, StubXBlock)) - self.assertEqual(xblock.test_field, expected_field_val) + self.assertTrue(isinstance(xblock, StubXBlock)) # noqa: PT009 + self.assertEqual(xblock.test_field, expected_field_val) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 376ba56d8dd9..29a194fd017e 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -8,15 +8,9 @@ import ddt from django.test.utils import override_settings from opaque_keys.edx.locator import CourseKey, LibraryLocator -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import TEST_DATA_MONGO_MODULESTORE, ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory -from xmodule.x_module import STUDIO_VIEW from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json -from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, \ - reverse_usage_url, duplicate_block +from cms.djangoapps.contentstore.utils import duplicate_block, reverse_library_url, reverse_url, reverse_usage_url from cms.djangoapps.contentstore.views.preview import _load_preview_block from cms.djangoapps.contentstore.views.tests.test_library import LIBRARY_REST_URL from cms.djangoapps.course_creators.views import add_user_with_status_granted @@ -28,10 +22,15 @@ LibraryUserRole, OrgInstructorRole, OrgLibraryUserRole, - OrgStaffRole + OrgStaffRole, ) from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import TEST_DATA_MONGO_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory +from xmodule.x_module import STUDIO_VIEW class LibraryTestCase(ModuleStoreTestCase): @@ -67,10 +66,10 @@ def _create_library(self, org="org", library="lib", display_name="Test Library") 'library': library, 'display_name': display_name, }) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 lib_info = parse_json(response) lib_key = CourseKey.from_string(lib_info['library_key']) - self.assertIsInstance(lib_key, LibraryLocator) + self.assertIsInstance(lib_key, LibraryLocator) # noqa: PT009 return lib_key def _add_library_content_block(self, course, library_key, publish_item=False, other_settings=None): @@ -111,7 +110,7 @@ def _upgrade_and_sync(self, lib_content_block, status_code_expected=200): kwargs={'handler': 'upgrade_and_sync'} ) response = self.client.ajax_post(handler_url) - self.assertEqual(response.status_code, status_code_expected) + self.assertEqual(response.status_code, status_code_expected) # noqa: PT009 return modulestore().get_item(lib_content_block.location) def _bind_block(self, block, user=None): @@ -143,7 +142,7 @@ def _list_libraries(self): Use the REST API to get a list of libraries visible to the current user. """ response = self.client.get_json(LIBRARY_REST_URL) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 return parse_json(response) @@ -170,7 +169,7 @@ def test_max_items(self, num_to_create, num_to_select, num_expected): course = CourseFactory.create() lc_block = self._add_library_content_block(course, self.lib_key, other_settings={'max_count': num_to_select}) - self.assertEqual(len(lc_block.children), 0) + self.assertEqual(len(lc_block.children), 0) # noqa: PT009 lc_block = self._upgrade_and_sync(lc_block) # Now, we want to make sure that .children has the total # of potential @@ -179,8 +178,8 @@ def test_max_items(self, num_to_create, num_to_select, num_expected): # In order to be able to call get_child_blocks(), we must first # call bind_for_student: self._bind_block(lc_block) - self.assertEqual(len(lc_block.children), num_to_create) - self.assertEqual(len(lc_block.get_child_blocks()), num_expected) + self.assertEqual(len(lc_block.children), num_to_create) # noqa: PT009 + self.assertEqual(len(lc_block.get_child_blocks()), num_expected) # noqa: PT009 def test_consistent_children(self): """ @@ -205,7 +204,7 @@ def get_child_of_lc_block(block): Fetch the child shown to the current user. """ children = block.get_child_blocks() - self.assertEqual(len(children), 1) + self.assertEqual(len(children), 1) # noqa: PT009 return children[0] # Check which child a student will see: @@ -225,9 +224,9 @@ def check(): lc_block = modulestore().get_item(lc_block_key) # Reload block from the database self._bind_block(lc_block) current_child = get_child_of_lc_block(lc_block) - self.assertEqual(current_child.location, chosen_child.location) - self.assertEqual(current_child.data, chosen_child.data) - self.assertEqual(current_child.definition_locator.definition_id, chosen_child_defn_id) + self.assertEqual(current_child.location, chosen_child.location) # noqa: PT009 + self.assertEqual(current_child.data, chosen_child.data) # noqa: PT009 + self.assertEqual(current_child.definition_locator.definition_id, chosen_child_defn_id) # noqa: PT009 check() # Refresh the children: @@ -243,7 +242,7 @@ def test_definition_shared_with_library(self): def_id1 = block1.definition_locator.definition_id block2 = self._add_simple_content_block() def_id2 = block2.definition_locator.definition_id - self.assertNotEqual(def_id1, def_id2) + self.assertNotEqual(def_id1, def_id2) # noqa: PT009 # Next, create a course: with modulestore().default_store(ModuleStoreEnum.Type.split): @@ -255,7 +254,7 @@ def test_definition_shared_with_library(self): for child_key in lc_block.children: child = modulestore().get_item(child_key) def_id = child.definition_locator.definition_id - self.assertIn(def_id, (def_id1, def_id2)) + self.assertIn(def_id, (def_id1, def_id2)) # noqa: PT009 def test_fields(self): """ @@ -272,8 +271,8 @@ def test_fields(self): display_name=name_value, data=data_value, ) - self.assertEqual(lib_block.data, data_value) - self.assertEqual(lib_block.display_name, name_value) + self.assertEqual(lib_block.data, data_value) # noqa: PT009 + self.assertEqual(lib_block.display_name, name_value) # noqa: PT009 # Next, create a course: with modulestore().default_store(ModuleStoreEnum.Type.split): @@ -284,8 +283,8 @@ def test_fields(self): lc_block = self._upgrade_and_sync(lc_block) course_block = modulestore().get_item(lc_block.children[0]) - self.assertEqual(course_block.data, data_value) - self.assertEqual(course_block.display_name, name_value) + self.assertEqual(course_block.data, data_value) # noqa: PT009 + self.assertEqual(course_block.display_name, name_value) # noqa: PT009 def test_block_with_children(self): """ @@ -308,8 +307,8 @@ def test_block_with_children(self): display_name=name_value, data=data_value, ) - self.assertEqual(child_block.data, data_value) - self.assertEqual(child_block.display_name, name_value) + self.assertEqual(child_block.data, data_value) # noqa: PT009 + self.assertEqual(child_block.display_name, name_value) # noqa: PT009 # Next, create a course: with modulestore().default_store(ModuleStoreEnum.Type.split): @@ -318,13 +317,13 @@ def test_block_with_children(self): # Add a LibraryContent block to the course: lc_block = self._add_library_content_block(course, self.lib_key) lc_block = self._upgrade_and_sync(lc_block) - self.assertEqual(len(lc_block.children), 1) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 course_vert_block = modulestore().get_item(lc_block.children[0]) - self.assertEqual(len(course_vert_block.children), 1) + self.assertEqual(len(course_vert_block.children), 1) # noqa: PT009 course_child_block = modulestore().get_item(course_vert_block.children[0]) - self.assertEqual(course_child_block.data, data_value) - self.assertEqual(course_child_block.display_name, name_value) + self.assertEqual(course_child_block.data, data_value) # noqa: PT009 + self.assertEqual(course_child_block.display_name, name_value) # noqa: PT009 def test_switch_to_unknown_source_library_preserves_settings(self): """ @@ -354,8 +353,8 @@ def test_switch_to_unknown_source_library_preserves_settings(self): good_library_version = lc_block.source_library_version assert good_library_id assert good_library_version - self.assertEqual(len(lc_block.children), 1) - self.assertEqual(modulestore().get_item(lc_block.children[0]).data, data_value) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 + self.assertEqual(modulestore().get_item(lc_block.children[0]).data, data_value) # noqa: PT009 # Now, change the block settings to have an invalid library key: bad_library_id = "library-v1:NOT+FOUND" @@ -363,7 +362,7 @@ def test_switch_to_unknown_source_library_preserves_settings(self): lc_block.location, {"source_library_id": bad_library_id}, ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 lc_block = modulestore().get_item(lc_block.location) # Source library id should be set to the new bad one... @@ -371,8 +370,8 @@ def test_switch_to_unknown_source_library_preserves_settings(self): # ...but old source library version should be preserved... assert lc_block.source_library_version == good_library_version # ...and children should not be deleted due to a bad setting. - self.assertEqual(len(lc_block.children), 1) - self.assertEqual(modulestore().get_item(lc_block.children[0]).data, data_value) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 + self.assertEqual(modulestore().get_item(lc_block.children[0]).data, data_value) # noqa: PT009 # Attempting to force an upgrade (the user would have to do this through the API, as # the UI wouldn't give them the option) returns a 400 and preserves the LC block's state. @@ -385,8 +384,8 @@ def test_switch_to_unknown_source_library_preserves_settings(self): # ...but old source library version should be preserved... assert lc_block.source_library_version == good_library_version # ...and children should not be deleted due to a bad setting. - self.assertEqual(len(lc_block.children), 1) - self.assertEqual(modulestore().get_item(lc_block.children[0]).data, data_value) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 + self.assertEqual(modulestore().get_item(lc_block.children[0]).data, data_value) # noqa: PT009 def test_sync_if_source_library_changed(self): """ @@ -422,9 +421,9 @@ def test_sync_if_source_library_changed(self): lc_block = self._upgrade_and_sync(lc_block) # Sanity check the initial condition. - self.assertEqual(len(lc_block.children), 1) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 html_block_1 = modulestore().get_item(lc_block.children[0]) - self.assertEqual(html_block_1.data, data1) + self.assertEqual(html_block_1.data, data1) # noqa: PT009 # Now, switch over to new library. Don't call upgrade_and_sync, because we are # testing that it happens automatically. @@ -432,13 +431,13 @@ def test_sync_if_source_library_changed(self): lc_block.location, {"source_library_id": str(library2key)}, ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Check that the course now has the new lib's new block. lc_block = modulestore().get_item(lc_block.location) - self.assertEqual(len(lc_block.children), 1) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 html_block_2 = modulestore().get_item(lc_block.children[0]) - self.assertEqual(html_block_2.data, data2) + self.assertEqual(html_block_2.data, data2) # noqa: PT009 def test_sync_if_capa_type_changed(self): """ Tests that children are automatically refreshed if capa type field changes """ @@ -467,29 +466,29 @@ def test_sync_if_capa_type_changed(self): # Add a LibraryContent block to the course: lc_block = self._add_library_content_block(course, self.lib_key) lc_block = self._upgrade_and_sync(lc_block) - self.assertEqual(len(lc_block.children), 2) + self.assertEqual(len(lc_block.children), 2) # noqa: PT009 resp = self._update_block( lc_block.location, {"capa_type": 'optionresponse'}, ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 lc_block = modulestore().get_item(lc_block.location) - self.assertEqual(len(lc_block.children), 1) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 html_block = modulestore().get_item(lc_block.children[0]) - self.assertEqual(html_block.display_name, name1) + self.assertEqual(html_block.display_name, name1) # noqa: PT009 resp = self._update_block( lc_block.location, {"capa_type": 'multiplechoiceresponse'}, ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 lc_block = modulestore().get_item(lc_block.location) - self.assertEqual(len(lc_block.children), 1) + self.assertEqual(len(lc_block.children), 1) # noqa: PT009 html_block = modulestore().get_item(lc_block.children[0]) - self.assertEqual(html_block.display_name, name2) + self.assertEqual(html_block.display_name, name2) # noqa: PT009 def test_library_filters(self): """ @@ -500,12 +499,12 @@ def test_library_filters(self): self._create_library(library="l3", display_name="Library-Title-3", org='org-test1') self._create_library(library="l4", display_name="Library-Title-4", org='org-test2') - self.assertEqual(len(self.client.get_json(LIBRARY_REST_URL).json()), 5) # 1 more from self.setUp() - self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?org=org-test1').json()), 2) - self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=test-lib').json()), 2) - self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=library-title').json()), 3) - self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=library-').json()), 3) - self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=org-test').json()), 3) + self.assertEqual(len(self.client.get_json(LIBRARY_REST_URL).json()), 5) # 1 more from self.setUp() # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?org=org-test1').json()), 2) # noqa: PT009 + self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=test-lib').json()), 2) # noqa: PT009 + self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=library-title').json()), 3) # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=library-').json()), 3) # noqa: PT009 + self.assertEqual(len(self.client.get_json(f'{LIBRARY_REST_URL}?text_search=org-test').json()), 3) # noqa: PT009 @ddt.ddt @@ -529,14 +528,14 @@ def _login_as_non_staff_user(self, logout_first=True): def _assert_cannot_create_library(self, org="org", library="libfail", expected_code=403): """ Ensure the current user is not able to create a library. """ - self.assertGreaterEqual(expected_code, 300) + self.assertGreaterEqual(expected_code, 300) # noqa: PT009 response = self.client.ajax_post( LIBRARY_REST_URL, {'org': org, 'library': library, 'display_name': "Irrelevant"} ) - self.assertEqual(response.status_code, expected_code) + self.assertEqual(response.status_code, expected_code) # noqa: PT009 key = LibraryLocator(org=org, library=library) - self.assertEqual(modulestore().get_library(key), None) + self.assertEqual(modulestore().get_library(key), None) # noqa: PT009 def _can_access_library(self, library): """ @@ -549,7 +548,7 @@ def _can_access_library(self, library): else: lib_key = library.location.library_key response = self.client.get(reverse_library_url('library_handler', str(lib_key))) - self.assertIn(response.status_code, (200, 302, 403)) + self.assertIn(response.status_code, (200, 302, 403)) # noqa: PT009 return response.status_code == 200 def tearDown(self): @@ -564,10 +563,10 @@ def test_creation(self): The user that creates a library should have instructor (admin) and staff permissions """ # self.library has been auto-created by the staff user. - self.assertTrue(has_studio_write_access(self.user, self.lib_key)) - self.assertTrue(has_studio_read_access(self.user, self.lib_key)) + self.assertTrue(has_studio_write_access(self.user, self.lib_key)) # noqa: PT009 + self.assertTrue(has_studio_read_access(self.user, self.lib_key)) # noqa: PT009 # Make sure the user was actually assigned the instructor role and not just using is_staff superpowers: - self.assertTrue(CourseInstructorRole(self.lib_key).has_user(self.user)) + self.assertTrue(CourseInstructorRole(self.lib_key).has_user(self.user)) # noqa: PT009 # Now log out and ensure we are forbidden from creating a library: self.client.logout() @@ -583,7 +582,7 @@ def test_creation(self): with patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}): lib_key2 = self._create_library(library="lib2", display_name="Test Library 2") library2 = modulestore().get_library(lib_key2) - self.assertIsNotNone(library2) + self.assertIsNotNone(library2) # noqa: PT009 @ddt.data( CourseInstructorRole, @@ -602,19 +601,19 @@ def test_acccess(self, access_role): # non_staff_user shouldn't be able to access any libraries: lib_list = self._list_libraries() - self.assertEqual(len(lib_list), 0) - self.assertFalse(self._can_access_library(self.library)) - self.assertFalse(self._can_access_library(library2_key)) + self.assertEqual(len(lib_list), 0) # noqa: PT009 + self.assertFalse(self._can_access_library(self.library)) # noqa: PT009 + self.assertFalse(self._can_access_library(library2_key)) # noqa: PT009 # Now manually intervene to give non_staff_user access to library2_key: access_role(library2_key).add_users(self.non_staff_user) # Now non_staff_user should be able to access library2_key only: lib_list = self._list_libraries() - self.assertEqual(len(lib_list), 1) - self.assertEqual(lib_list[0]["library_key"], str(library2_key)) - self.assertTrue(self._can_access_library(library2_key)) - self.assertFalse(self._can_access_library(self.library)) + self.assertEqual(len(lib_list), 1) # noqa: PT009 + self.assertEqual(lib_list[0]["library_key"], str(library2_key)) # noqa: PT009 + self.assertTrue(self._can_access_library(library2_key)) # noqa: PT009 + self.assertFalse(self._can_access_library(self.library)) # noqa: PT009 @ddt.data( OrgStaffRole, @@ -638,11 +637,11 @@ def test_org_based_access(self, org_access_role): # Now non_staff_user should be able to access lib_key_pacific only: lib_list = self._list_libraries() - self.assertEqual(len(lib_list), 1) - self.assertEqual(lib_list[0]["library_key"], str(lib_key_pacific)) - self.assertTrue(self._can_access_library(lib_key_pacific)) - self.assertFalse(self._can_access_library(lib_key_atlantic)) - self.assertFalse(self._can_access_library(self.lib_key)) + self.assertEqual(len(lib_list), 1) # noqa: PT009 + self.assertEqual(lib_list[0]["library_key"], str(lib_key_pacific)) # noqa: PT009 + self.assertTrue(self._can_access_library(lib_key_pacific)) # noqa: PT009 + self.assertFalse(self._can_access_library(lib_key_atlantic)) # noqa: PT009 + self.assertFalse(self._can_access_library(self.lib_key)) # noqa: PT009 @ddt.data(True, False) def test_read_only_role(self, use_org_level_role): @@ -654,26 +653,26 @@ def test_read_only_role(self, use_org_level_role): # Login as a non_staff_user: self._login_as_non_staff_user() - self.assertFalse(self._can_access_library(self.library)) + self.assertFalse(self._can_access_library(self.library)) # noqa: PT009 block_url = reverse_usage_url('xblock_handler', block.location) def can_read_block(): """ Check if studio lets us view the XBlock in the library """ response = self.client.get_json(block_url) - self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous + self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous # noqa: PT009 return response.status_code == 200 def can_edit_block(): """ Check if studio lets us edit the XBlock in the library """ response = self.client.ajax_post(block_url) - self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous + self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous # noqa: PT009 return response.status_code == 200 def can_delete_block(): """ Check if studio lets us delete the XBlock in the library """ response = self.client.delete(block_url) - self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous + self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous # noqa: PT009 return response.status_code == 200 def can_copy_block(): @@ -682,7 +681,7 @@ def can_copy_block(): 'parent_locator': str(self.library.location), 'duplicate_source_locator': str(block.location), }) - self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous + self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous # noqa: PT009 return response.status_code == 200 def can_create_block(): @@ -690,15 +689,15 @@ def can_create_block(): response = self.client.ajax_post(reverse_url('xblock_handler'), { 'parent_locator': str(self.library.location), 'category': 'html', }) - self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous + self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous # noqa: PT009 return response.status_code == 200 # Check that we do not have read or write access to block: - self.assertFalse(can_read_block()) - self.assertFalse(can_edit_block()) - self.assertFalse(can_delete_block()) - self.assertFalse(can_copy_block()) - self.assertFalse(can_create_block()) + self.assertFalse(can_read_block()) # noqa: PT009 + self.assertFalse(can_edit_block()) # noqa: PT009 + self.assertFalse(can_delete_block()) # noqa: PT009 + self.assertFalse(can_copy_block()) # noqa: PT009 + self.assertFalse(can_create_block()) # noqa: PT009 # Give non_staff_user read-only permission: if use_org_level_role: @@ -706,12 +705,12 @@ def can_create_block(): else: LibraryUserRole(self.lib_key).add_users(self.non_staff_user) - self.assertTrue(self._can_access_library(self.library)) - self.assertTrue(can_read_block()) - self.assertFalse(can_edit_block()) - self.assertFalse(can_delete_block()) - self.assertFalse(can_copy_block()) - self.assertFalse(can_create_block()) + self.assertTrue(self._can_access_library(self.library)) # noqa: PT009 + self.assertTrue(can_read_block()) # noqa: PT009 + self.assertFalse(can_edit_block()) # noqa: PT009 + self.assertFalse(can_delete_block()) # noqa: PT009 + self.assertFalse(can_copy_block()) # noqa: PT009 + self.assertFalse(can_create_block()) # noqa: PT009 @ddt.data( (LibraryUserRole, CourseStaffRole, True), @@ -744,9 +743,9 @@ def test_duplicate_across_courses(self, library_role, course_role, expected_resu 'parent_locator': str(course.location), 'duplicate_source_locator': str(block.location), }) - self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous + self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous # noqa: PT009 duplicate_action_allowed = (response.status_code == 200) - self.assertEqual(duplicate_action_allowed, expected_result) + self.assertEqual(duplicate_action_allowed, expected_result) # noqa: PT009 @ddt.data( (LibraryUserRole, CourseStaffRole, True), @@ -781,7 +780,7 @@ def test_upgrade_and_sync_handler_content_permissions(self, library_role, course # We must use the CMS's module system in order to get permissions checks. self._bind_block(lc_block, user=self.non_staff_user) lc_block = self._upgrade_and_sync(lc_block, status_code_expected=200 if expected_result else 403) - self.assertEqual(len(lc_block.children), 1 if expected_result else 0) + self.assertEqual(len(lc_block.children), 1 if expected_result else 0) # noqa: PT009 def test_studio_user_permissions(self): """ @@ -819,27 +818,27 @@ def _get_settings_html(): edit_view_url = reverse_usage_url("xblock_view_handler", lib_block.location, {"view_name": STUDIO_VIEW}) resp = self.client.get_json(edit_view_url) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 return parse_json(resp)['html'] self._login_as_staff_user() staff_settings_html = _get_settings_html() - self.assertIn('staff_lib_1', staff_settings_html) - self.assertIn('staff_lib_2', staff_settings_html) - self.assertIn('admin_lib_1', staff_settings_html) - self.assertIn('admin_lib_2', staff_settings_html) + self.assertIn('staff_lib_1', staff_settings_html) # noqa: PT009 + self.assertIn('staff_lib_2', staff_settings_html) # noqa: PT009 + self.assertIn('admin_lib_1', staff_settings_html) # noqa: PT009 + self.assertIn('admin_lib_2', staff_settings_html) # noqa: PT009 self._login_as_non_staff_user() response = self.client.get_json(LIBRARY_REST_URL) staff_libs = parse_json(response) - self.assertEqual(2, len(staff_libs)) + self.assertEqual(2, len(staff_libs)) # noqa: PT009 non_staff_settings_html = _get_settings_html() - self.assertIn('staff_lib_1', non_staff_settings_html) - self.assertIn('staff_lib_2', non_staff_settings_html) - self.assertNotIn('admin_lib_1', non_staff_settings_html) - self.assertNotIn('admin_lib_2', non_staff_settings_html) + self.assertIn('staff_lib_1', non_staff_settings_html) # noqa: PT009 + self.assertIn('staff_lib_2', non_staff_settings_html) # noqa: PT009 + self.assertNotIn('admin_lib_1', non_staff_settings_html) # noqa: PT009 + self.assertNotIn('admin_lib_2', non_staff_settings_html) # noqa: PT009 @ddt.ddt @@ -893,11 +892,11 @@ def test_overrides(self): self.problem_in_course = modulestore().get_item(self.lc_block.children[0]) problem2_in_course = modulestore().get_item(lc_block2.children[0]) - self.assertEqual(self.problem_in_course.display_name, new_display_name) - self.assertEqual(self.problem_in_course.weight, new_weight) + self.assertEqual(self.problem_in_course.display_name, new_display_name) # noqa: PT009 + self.assertEqual(self.problem_in_course.weight, new_weight) # noqa: PT009 - self.assertEqual(problem2_in_course.display_name, self.original_display_name) - self.assertEqual(problem2_in_course.weight, self.original_weight) + self.assertEqual(problem2_in_course.display_name, self.original_display_name) # noqa: PT009 + self.assertEqual(problem2_in_course.weight, self.original_weight) # noqa: PT009 def test_reset_override(self): """ @@ -910,8 +909,8 @@ def test_reset_override(self): modulestore().update_item(self.problem_in_course, self.user.id) self.problem_in_course = modulestore().get_item(self.problem_in_course.location) - self.assertEqual(self.problem_in_course.display_name, new_display_name) - self.assertEqual(self.problem_in_course.weight, new_weight) + self.assertEqual(self.problem_in_course.display_name, new_display_name) # noqa: PT009 + self.assertEqual(self.problem_in_course.weight, new_weight) # noqa: PT009 # Reset: for field_name in ["display_name", "weight"]: @@ -921,8 +920,8 @@ def test_reset_override(self): modulestore().update_item(self.problem_in_course, self.user.id) self.problem_in_course = modulestore().get_item(self.problem_in_course.location) - self.assertEqual(self.problem_in_course.display_name, self.original_display_name) - self.assertEqual(self.problem_in_course.weight, self.original_weight) + self.assertEqual(self.problem_in_course.display_name, self.original_display_name) # noqa: PT009 + self.assertEqual(self.problem_in_course.weight, self.original_weight) # noqa: PT009 def test_consistent_definitions(self): """ @@ -932,7 +931,7 @@ def test_consistent_definitions(self): This test is specific to split mongo. """ definition_id = self.problem.definition_locator.definition_id - self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id) + self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id) # noqa: PT009 # Now even if we change some Scope.settings fields and refresh, the definition should be unchanged self.problem.weight = 20 @@ -941,8 +940,8 @@ def test_consistent_definitions(self): self.lc_block = self._upgrade_and_sync(self.lc_block) self.problem_in_course = modulestore().get_item(self.problem_in_course.location) - self.assertEqual(self.problem.definition_locator.definition_id, definition_id) - self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id) + self.assertEqual(self.problem.definition_locator.definition_id, definition_id) # noqa: PT009 + self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id) # noqa: PT009 @ddt.data(False, True) def test_persistent_overrides(self, duplicate): @@ -965,8 +964,8 @@ def test_persistent_overrides(self, duplicate): self.problem_in_course = modulestore().get_item(self.lc_block.children[0]) else: self.problem_in_course = modulestore().get_item(self.problem_in_course.location) - self.assertEqual(self.problem_in_course.display_name, new_display_name) - self.assertEqual(self.problem_in_course.weight, new_weight) + self.assertEqual(self.problem_in_course.display_name, new_display_name) # noqa: PT009 + self.assertEqual(self.problem_in_course.weight, new_weight) # noqa: PT009 # Change the settings in the library version: self.problem.display_name = "X" @@ -978,9 +977,9 @@ def test_persistent_overrides(self, duplicate): self.lc_block = self._upgrade_and_sync(self.lc_block) self.problem_in_course = modulestore().get_item(self.problem_in_course.location) - self.assertEqual(self.problem_in_course.display_name, new_display_name) - self.assertEqual(self.problem_in_course.weight, new_weight) - self.assertEqual(self.problem_in_course.data, new_data_value) + self.assertEqual(self.problem_in_course.display_name, new_display_name) # noqa: PT009 + self.assertEqual(self.problem_in_course.weight, new_weight) # noqa: PT009 + self.assertEqual(self.problem_in_course.data, new_data_value) # noqa: PT009 def test_duplicated_version(self): """ @@ -988,8 +987,8 @@ def test_duplicated_version(self): the new block will use the old library version and not the new one. """ store = modulestore() - self.assertEqual(len(self.library.children), 1) - self.assertEqual(len(self.lc_block.children), 1) + self.assertEqual(len(self.library.children), 1) # noqa: PT009 + self.assertEqual(len(self.lc_block.children), 1) # noqa: PT009 # Edit the only problem in the library: self.problem.display_name = "--changed in library--" @@ -1006,28 +1005,28 @@ def test_duplicated_version(self): self.library = store.get_library(self.lib_key) # The library has changed... - self.assertEqual(len(self.library.children), 2) + self.assertEqual(len(self.library.children), 2) # noqa: PT009 # But the block hasn't. self.lc_block = store.get_item(self.lc_block.location) - self.assertEqual(len(self.lc_block.children), 1) - self.assertEqual(self.problem_in_course.location, self.lc_block.children[0]) - self.assertEqual(self.problem_in_course.display_name, self.original_display_name) + self.assertEqual(len(self.lc_block.children), 1) # noqa: PT009 + self.assertEqual(self.problem_in_course.location, self.lc_block.children[0]) # noqa: PT009 + self.assertEqual(self.problem_in_course.display_name, self.original_display_name) # noqa: PT009 # Duplicate self.lc_block: duplicate = store.get_item( duplicate_block(self.course.location, self.lc_block.location, self.user) ) # The duplicate should have identical children to the original: - self.assertTrue(self.lc_block.source_library_version) - self.assertEqual(self.lc_block.source_library_version, duplicate.source_library_version) - self.assertEqual(len(duplicate.children), 1) + self.assertTrue(self.lc_block.source_library_version) # noqa: PT009 + self.assertEqual(self.lc_block.source_library_version, duplicate.source_library_version) # noqa: PT009 + self.assertEqual(len(duplicate.children), 1) # noqa: PT009 problem2_in_course = store.get_item(duplicate.children[0]) - self.assertEqual(problem2_in_course.display_name, self.original_display_name) + self.assertEqual(problem2_in_course.display_name, self.original_display_name) # noqa: PT009 # Refresh our reference to the block self.lc_block = self._upgrade_and_sync(self.lc_block) self.problem_in_course = store.get_item(self.problem_in_course.location) # and the block has changed too. - self.assertEqual(len(self.lc_block.children), 2) + self.assertEqual(len(self.lc_block.children), 2) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_orphan.py b/cms/djangoapps/contentstore/tests/test_orphan.py index 244eecebf9af..af63f9f8028d 100644 --- a/cms/djangoapps/contentstore/tests/test_orphan.py +++ b/cms/djangoapps/contentstore/tests/test_orphan.py @@ -10,9 +10,12 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url from common.djangoapps.student.models import CourseEnrollment -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.search import path_to_location # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.search import path_to_location # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + CourseFactory, + check_mongo_calls_range, +) class TestOrphanBase(CourseTestCase): @@ -70,7 +73,7 @@ def assertOrphanCount(self, course_key, number): Asserts that we have the expected count of orphans for a given course_key """ - self.assertEqual(len(self.store.get_orphans(course_key)), number) + self.assertEqual(len(self.store.get_orphans(course_key)), number) # noqa: PT009 class TestOrphan(TestOrphanBase): @@ -91,13 +94,13 @@ def test_get_orphans(self): HTTP_ACCEPT='application/json' ).content.decode('utf-8') ) - self.assertEqual(len(orphans), 3, f"Wrong # {orphans}") + self.assertEqual(len(orphans), 3, f"Wrong # {orphans}") # noqa: PT009 location = course.location.replace(category='chapter', name='OrphanChapter') - self.assertIn(str(location), orphans) + self.assertIn(str(location), orphans) # noqa: PT009 location = course.location.replace(category='vertical', name='OrphanVert') - self.assertIn(str(location), orphans) + self.assertIn(str(location), orphans) # noqa: PT009 location = course.location.replace(category='html', name='OrphanHtml') - self.assertIn(str(location), orphans) + self.assertIn(str(location), orphans) # noqa: PT009 def test_delete_orphans(self): """ @@ -112,11 +115,11 @@ def test_delete_orphans(self): orphans = json.loads( self.client.get(orphan_url, HTTP_ACCEPT='application/json').content.decode('utf-8') ) - self.assertEqual(len(orphans), 0, f"Orphans not deleted {orphans}") + self.assertEqual(len(orphans), 0, f"Orphans not deleted {orphans}") # noqa: PT009 # make sure that any children with one orphan parent and one non-orphan # parent are not deleted - self.assertTrue(self.store.has_item(course.id.make_usage_key('html', "multi_parent_html"))) + self.assertTrue(self.store.has_item(course.id.make_usage_key('html', "multi_parent_html"))) # noqa: PT009 def test_not_permitted(self): """ @@ -128,9 +131,9 @@ def test_not_permitted(self): test_user_client, test_user = self.create_non_staff_authed_user_client() CourseEnrollment.enroll(test_user, course.id) response = test_user_client.get(orphan_url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 response = test_user_client.delete(orphan_url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 def test_path_to_location_for_orphan_vertical(self): r""" @@ -155,16 +158,16 @@ def test_path_to_location_for_orphan_vertical(self): multi_parent_html = self.store.get_item(BlockUsageLocator(course.id, 'html', 'multi_parent_html')) # Verify `OrphanVert` is an orphan - self.assertIn(orphan_vertical.location, self.store.get_orphans(course.id)) + self.assertIn(orphan_vertical.location, self.store.get_orphans(course.id)) # noqa: PT009 # Verify `multi_parent_html` is child of both `Vertical1` and `OrphanVert` - self.assertIn(multi_parent_html.location, orphan_vertical.children) - self.assertIn(multi_parent_html.location, vertical1.children) + self.assertIn(multi_parent_html.location, orphan_vertical.children) # noqa: PT009 + self.assertIn(multi_parent_html.location, vertical1.children) # noqa: PT009 # HTML component has `vertical1` as its parent. html_parent = self.store.get_parent_location(multi_parent_html.location) - self.assertNotEqual(str(html_parent), str(orphan_vertical.location)) - self.assertEqual(str(html_parent), str(vertical1.location)) + self.assertNotEqual(str(html_parent), str(orphan_vertical.location)) # noqa: PT009 + self.assertEqual(str(html_parent), str(vertical1.location)) # noqa: PT009 # Get path of the `multi_parent_html` & verify path_to_location returns a expected path path = path_to_location(self.store, multi_parent_html.location) @@ -176,9 +179,9 @@ def test_path_to_location_for_orphan_vertical(self): "", path[-1] ) - self.assertIsNotNone(path) - self.assertEqual(len(path), 6) - self.assertEqual(path, expected_path) + self.assertIsNotNone(path) # noqa: PT009 + self.assertEqual(len(path), 6) # noqa: PT009 + self.assertEqual(path, expected_path) # noqa: PT009 def test_path_to_location_for_orphan_chapter(self): r""" @@ -201,7 +204,7 @@ def test_path_to_location_for_orphan_chapter(self): vertical1 = self.store.get_item(BlockUsageLocator(course.id, 'vertical', 'Vertical1')) # Verify `OrhanChapter` is an orphan - self.assertIn(orphan_chapter.location, self.store.get_orphans(course.id)) + self.assertIn(orphan_chapter.location, self.store.get_orphans(course.id)) # noqa: PT009 # Create a vertical (`Vertical0`) in orphan chapter (`OrphanChapter`). # OrphanChapter -> Vertical0 @@ -215,7 +218,7 @@ def test_path_to_location_for_orphan_chapter(self): # Verify chapter1 is parent of vertical1. vertical1_parent = self.store.get_parent_location(vertical1.location) - self.assertEqual(str(vertical1_parent), str(chapter1.location)) + self.assertEqual(str(vertical1_parent), str(chapter1.location)) # noqa: PT009 # Make `Vertical1` the parent of `HTML0`. So `HTML0` will have to parents (`Vertical0` & `Vertical1`) vertical1.children.append(html.location) @@ -224,7 +227,7 @@ def test_path_to_location_for_orphan_chapter(self): # Get parent location & verify its either of the two verticals. As both parents are non-orphan, # alphabetically least is returned html_parent = self.store.get_parent_location(html.location) - self.assertEqual(str(html_parent), str(vertical1.location)) + self.assertEqual(str(html_parent), str(vertical1.location)) # noqa: PT009 # verify path_to_location returns a expected path path = path_to_location(self.store, html.location) @@ -236,6 +239,6 @@ def test_path_to_location_for_orphan_chapter(self): "", path[-1] ) - self.assertIsNotNone(path) - self.assertEqual(len(path), 6) - self.assertEqual(path, expected_path) + self.assertIsNotNone(path) # noqa: PT009 + self.assertEqual(len(path), 6) # noqa: PT009 + self.assertEqual(path, expected_path) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_outlines.py b/cms/djangoapps/contentstore/tests/test_outlines.py index 108edd82df43..c94c12f7bdad 100644 --- a/cms/djangoapps/contentstore/tests/test_outlines.py +++ b/cms/djangoapps/contentstore/tests/test_outlines.py @@ -8,9 +8,14 @@ from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData, ExamData, VisibilityData -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) from ..outlines import get_outline_from_modulestore @@ -66,7 +71,7 @@ def test_empty_course_metadata(self): # published_at assert isinstance(outline.published_at, datetime) assert outline.published_at == published_course.subtree_edited_on - assert outline.published_at.tzinfo == timezone.utc + assert outline.published_at.tzinfo == timezone.utc # noqa: UP017 # published_version assert isinstance(outline.published_version, str) @@ -517,7 +522,7 @@ class OutlineFromModuleStoreTaskTestCase(ModuleStoreTestCase): def test_task_invocation(self): """Test outline auto-creation after course publish""" course_key = CourseKey.from_string("course-v1:TNL+7733+2021-01-21") - with self.assertRaises(CourseOutlineData.DoesNotExist): + with self.assertRaises(CourseOutlineData.DoesNotExist): # noqa: PT027 get_course_outline(course_key) course = CourseFactory.create( diff --git a/cms/djangoapps/contentstore/tests/test_permissions.py b/cms/djangoapps/contentstore/tests/test_permissions.py index d8fad571658d..6cdd8629ec52 100644 --- a/cms/djangoapps/contentstore/tests/test_permissions.py +++ b/cms/djangoapps/contentstore/tests/test_permissions.py @@ -4,15 +4,14 @@ import copy -from edx_toggles.toggles.testutils import override_waffle_flag - -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient -from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_url +from cms.djangoapps.contentstore.utils import reverse_url from common.djangoapps.student import auth from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, OrgInstructorRole, OrgStaffRole from common.djangoapps.student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) class TestCourseAccess(ModuleStoreTestCase): @@ -66,19 +65,14 @@ def tearDown(self): self.client.logout() ModuleStoreTestCase.tearDown(self) # pylint: disable=non-parent-method-called - @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True) def test_get_all_users(self): """ Test getting all authors for a course where their permissions run the gamut of allowed group types. - - TODO: Replace the call to the legacy course_team_handler with a call to the course team REST API. - The legacy page will be removed, but we still want to the test these behaviors. - Part of https://github.com/openedx/edx-platform/issues/36275. """ # first check the course creator.has explicit access (don't use has_access as is_staff # will trump the actual test) - self.assertTrue( + self.assertTrue( # noqa: PT009 CourseInstructorRole(self.course_key).has_user(self.user), "Didn't add creator as instructor." ) @@ -99,13 +93,7 @@ def test_get_all_users(self): user = users.pop() group.add_users(user) user_by_role[role].append(user) - self.assertTrue(auth.has_course_author_access(user, self.course_key), f"{user} does not have access") # lint-amnesty, pylint: disable=line-too-long - - course_team_url = reverse_course_url('course_team_handler', self.course_key) - response = self.client.get_html(course_team_url) - for role in [CourseInstructorRole, CourseStaffRole]: # Global and org-based roles don't appear on this page - for user in user_by_role[role]: - self.assertContains(response, user.email) + self.assertTrue(auth.has_course_author_access(user, self.course_key), f"{user} does not have access") # pylint: disable=line-too-long # noqa: PT009 # test copying course permissions copy_course_key = self.store.make_course_key('copyu', 'copydept.mycourse', 'myrun') @@ -132,9 +120,9 @@ def test_get_all_users(self): if hasattr(user, '_roles'): del user._roles - self.assertTrue(auth.has_course_author_access(user, copy_course_key), f"{user} no copy access") + self.assertTrue(auth.has_course_author_access(user, copy_course_key), f"{user} no copy access") # noqa: PT009 # pylint: disable=line-too-long if (role is OrgStaffRole) or (role is OrgInstructorRole): auth.remove_users(self.user, role(self.course_key.org), user) else: auth.remove_users(self.user, role(self.course_key), user) - self.assertFalse(auth.has_course_author_access(user, self.course_key), f"{user} remove didn't work") # lint-amnesty, pylint: disable=line-too-long + self.assertFalse(auth.has_course_author_access(user, self.course_key), f"{user} remove didn't work") # pylint: disable=line-too-long # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_proctoring.py b/cms/djangoapps/contentstore/tests/test_proctoring.py index d9b83a811ef0..83b46ca2a063 100644 --- a/cms/djangoapps/contentstore/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/tests/test_proctoring.py @@ -4,18 +4,18 @@ from datetime import datetime, timedelta -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import ddt from django.conf import settings from edx_proctoring.api import get_all_exams_for_course, get_review_policy_by_exam_id from pytz import UTC -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from cms.djangoapps.contentstore.signals.handlers import listen_for_course_publish from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @ddt.ddt @@ -52,27 +52,27 @@ def _verify_exam_data(self, sequence, expected_active): """ exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), 1) + self.assertEqual(len(exams), 1) # noqa: PT009 exam = exams[0] if exam['is_proctored'] and not exam['is_practice_exam']: # get the review policy object exam_review_policy = get_review_policy_by_exam_id(exam['id']) - self.assertEqual(exam_review_policy['review_policy'], sequence.exam_review_rules) + self.assertEqual(exam_review_policy['review_policy'], sequence.exam_review_rules) # noqa: PT009 if not exam['is_proctored'] and not exam['is_practice_exam']: # the hide after due value only applies to timed exams - self.assertEqual(exam['hide_after_due'], sequence.hide_after_due) + self.assertEqual(exam['hide_after_due'], sequence.hide_after_due) # noqa: PT009 - self.assertEqual(exam['course_id'], str(self.course.id)) - self.assertEqual(exam['content_id'], str(sequence.location)) - self.assertEqual(exam['exam_name'], sequence.display_name) - self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes) - self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam) - self.assertEqual(exam['is_practice_exam'], sequence.is_practice_exam or sequence.is_onboarding_exam) - self.assertEqual(exam['is_active'], expected_active) - self.assertEqual(exam['backend'], self.course.proctoring_provider) + self.assertEqual(exam['course_id'], str(self.course.id)) # noqa: PT009 + self.assertEqual(exam['content_id'], str(sequence.location)) # noqa: PT009 + self.assertEqual(exam['exam_name'], sequence.display_name) # noqa: PT009 + self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes) # noqa: PT009 + self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam) # noqa: PT009 + self.assertEqual(exam['is_practice_exam'], sequence.is_practice_exam or sequence.is_onboarding_exam) # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(exam['is_active'], expected_active) # noqa: PT009 + self.assertEqual(exam['backend'], self.course.proctoring_provider) # noqa: PT009 @ddt.data( (False, True), @@ -174,7 +174,7 @@ def test_unpublishing_proctored_exam(self): listen_for_course_publish(self, self.course.id) exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), 1) + self.assertEqual(len(exams), 1) # noqa: PT009 sequence.is_time_limited = False sequence.is_proctored_exam = False @@ -205,7 +205,7 @@ def test_dangling_exam(self): listen_for_course_publish(self, self.course.id) exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), 1) + self.assertEqual(len(exams), 1) # noqa: PT009 self.store.delete_item(chapter.location, self.user.id) @@ -215,10 +215,10 @@ def test_dangling_exam(self): # look through exam table, the dangling exam # should be disabled exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), 1) + self.assertEqual(len(exams), 1) # noqa: PT009 exam = exams[0] - self.assertEqual(exam['is_active'], False) + self.assertEqual(exam['is_active'], False) # noqa: PT009 @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': False}) def test_feature_flag_off(self): @@ -240,7 +240,7 @@ def test_feature_flag_off(self): listen_for_course_publish(self, self.course.id) exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), 0) + self.assertEqual(len(exams), 0) # noqa: PT009 @ddt.data( (True, False, 1), @@ -279,7 +279,7 @@ def test_advanced_settings(self, enable_timed_exams, enable_proctored_exams, exp # there shouldn't be any exams because we haven't enabled that # advanced setting flag exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), expected_count) + self.assertEqual(len(exams), expected_count) # noqa: PT009 def test_self_paced_no_due_dates(self): self.course = CourseFactory.create( @@ -336,7 +336,7 @@ def test_async_waffle_flag_publishes(self): listen_for_course_publish(self, self.course.id) exams = get_all_exams_for_course(str(self.course.id)) - self.assertEqual(len(exams), 1) + self.assertEqual(len(exams), 1) # noqa: PT009 self._verify_exam_data(sequence, True) def test_async_waffle_flag_task(self): diff --git a/cms/djangoapps/contentstore/tests/test_request_event.py b/cms/djangoapps/contentstore/tests/test_request_event.py index c4d21f72497a..ee99dc12c934 100644 --- a/cms/djangoapps/contentstore/tests/test_request_event.py +++ b/cms/djangoapps/contentstore/tests/test_request_event.py @@ -23,7 +23,7 @@ def test_post_answers_to_log(self): ] for request_params in requests: response = self.client.post(reverse(cms_user_track), request_params) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 def test_get_answers_to_log(self): """ @@ -36,4 +36,4 @@ def test_get_answers_to_log(self): ] for request_params in requests: response = self.client.get(reverse(cms_user_track), request_params) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/test_signals.py b/cms/djangoapps/contentstore/tests/test_signals.py index de66134a440f..17338814ae92 100644 --- a/cms/djangoapps/contentstore/tests/test_signals.py +++ b/cms/djangoapps/contentstore/tests/test_signals.py @@ -8,8 +8,10 @@ from cms.djangoapps.contentstore.signals.handlers import GRADING_POLICY_COUNTDOWN_SECONDS, handle_grading_policy_changed from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order @ddt.ddt @@ -36,6 +38,6 @@ def test_locked(self, lock_available, compute_grades_async_mock, add_mock): handle_grading_policy_changed(sender, course_key=str(self.course.id)) cache_key = f'handle_grading_policy_changed-{str(self.course.id)}' - self.assertEqual(lock_available, compute_grades_async_mock.called) + self.assertEqual(lock_available, compute_grades_async_mock.called) # noqa: PT009 if lock_available: add_mock.assert_called_once_with(cache_key, "true", GRADING_POLICY_COUNTDOWN_SECONDS) diff --git a/cms/djangoapps/contentstore/tests/test_tasks.py b/cms/djangoapps/contentstore/tests/test_tasks.py index 5a76b7d67fe7..1f8f0348a11c 100644 --- a/cms/djangoapps/contentstore/tests/test_tasks.py +++ b/cms/djangoapps/contentstore/tests/test_tasks.py @@ -5,13 +5,15 @@ import json import logging from unittest import mock -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 -from celery import Task +import ddt import pytest +from celery import Task from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.db import models from django.test.utils import override_settings from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey @@ -25,29 +27,36 @@ from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA +from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) + from ..tasks import ( LinkState, - export_olx, - update_special_exams_and_publish, - rerun_course, - _validate_urls_access_in_batches, - _filter_by_status, _check_broken_links, + _convert_to_standard_url, + _filter_by_status, _is_studio_url, _scan_course_for_links, - _convert_to_standard_url, - extract_content_URLs_from_course + _validate_urls_access_in_batches, + export_olx, + extract_content_URLs_from_course, + rerun_course, + sync_discussion_settings, + update_special_exams_and_publish, ) logging = logging.getLogger(__name__) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex # noqa: UP031 def side_effect_exception(*args, **kwargs): @@ -70,11 +79,11 @@ def test_success(self): key = str(self.course.location.course_key) result = export_olx.delay(self.user.id, key, 'en') status = UserTaskStatus.objects.get(task_id=result.id) - self.assertEqual(status.state, UserTaskStatus.SUCCEEDED) + self.assertEqual(status.state, UserTaskStatus.SUCCEEDED) # noqa: PT009 artifacts = UserTaskArtifact.objects.filter(status=status) - self.assertEqual(len(artifacts), 1) + self.assertEqual(len(artifacts), 1) # noqa: PT009 output = artifacts[0] - self.assertEqual(output.name, 'Output') + self.assertEqual(output.name, 'Output') # noqa: PT009 @mock.patch('cms.djangoapps.contentstore.tasks.export_course_to_xml', side_effect=side_effect_exception) def test_exception(self, mock_export): # pylint: disable=unused-argument @@ -109,12 +118,12 @@ def _assert_failed(self, task_result, error_message): Verify that a task failed with the specified error message """ status = UserTaskStatus.objects.get(task_id=task_result.id) - self.assertEqual(status.state, UserTaskStatus.FAILED) + self.assertEqual(status.state, UserTaskStatus.FAILED) # noqa: PT009 artifacts = UserTaskArtifact.objects.filter(status=status) - self.assertEqual(len(artifacts), 1) + self.assertEqual(len(artifacts), 1) # noqa: PT009 error = artifacts[0] - self.assertEqual(error.name, 'Error') - self.assertEqual(error.text, error_message) + self.assertEqual(error.name, 'Error') # noqa: PT009 + self.assertEqual(error.text, error_message) # noqa: PT009 @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) @@ -130,15 +139,15 @@ def test_success(self): key = str(self.lib_key) result = export_olx.delay(self.user.id, key, 'en') status = UserTaskStatus.objects.get(task_id=result.id) - self.assertEqual(status.state, UserTaskStatus.SUCCEEDED) + self.assertEqual(status.state, UserTaskStatus.SUCCEEDED) # noqa: PT009 artifacts = UserTaskArtifact.objects.filter(status=status) - self.assertEqual(len(artifacts), 1) + self.assertEqual(len(artifacts), 1) # noqa: PT009 output = artifacts[0] - self.assertEqual(output.name, 'Output') + self.assertEqual(output.name, 'Output') # noqa: PT009 @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) -class RerunCourseTaskTestCase(CourseTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class RerunCourseTaskTestCase(CourseTestCase): # pylint: disable=missing-class-docstring MODULESTORE = TEST_DATA_SPLIT_MODULESTORE @@ -171,18 +180,18 @@ def test_success(self): # Verify the new course run exists course = modulestore().get_course(new_course_key) - self.assertIsNotNone(course) + self.assertIsNotNone(course) # noqa: PT009 # Verify the OrganizationCourse is cloned - self.assertEqual(OrganizationCourse.objects.count(), 2) + self.assertEqual(OrganizationCourse.objects.count(), 2) # noqa: PT009 # This will raise an error if the OrganizationCourse object was not cloned OrganizationCourse.objects.get(course_id=new_course_id, organization=organization) # Verify the RestrictedCourse and related objects are cloned - self.assertEqual(RestrictedCourse.objects.count(), 2) + self.assertEqual(RestrictedCourse.objects.count(), 2) # noqa: PT009 restricted_course = RestrictedCourse.objects.get(course_key=new_course_key) - self.assertEqual(CountryAccessRule.objects.count(), 2) + self.assertEqual(CountryAccessRule.objects.count(), 2) # noqa: PT009 CountryAccessRule.objects.get( rule_type=CountryAccessRule.BLACKLIST_RULE, restricted_course=restricted_course, @@ -206,7 +215,7 @@ def test_success_different_org(self): self._rerun_course(old_course_key, new_course_key) # Verify the OrganizationCourse is cloned with a different org - self.assertEqual(OrganizationCourse.objects.count(), 2) + self.assertEqual(OrganizationCourse.objects.count(), 2) # noqa: PT009 OrganizationCourse.objects.get(course_id=new_course_id, organization__short_name='neworg') @@ -215,7 +224,7 @@ class RegisterExamsTaskTestCase(CourseTestCase): # pylint: disable=missing-clas @mock.patch('cms.djangoapps.contentstore.exams.register_exams') @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') - def test_exam_service_not_enabled_success(self, _mock_register_exams_proctoring, _mock_register_exams_service): + def test_exam_service_not_enabled_success(self, _mock_register_exams_proctoring, _mock_register_exams_service): # noqa: PT019 # pylint: disable=line-too-long """ edx-proctoring interface is called if exam service is not enabled """ update_special_exams_and_publish(str(self.course.id)) _mock_register_exams_proctoring.assert_called_once_with(self.course.id) @@ -224,7 +233,7 @@ def test_exam_service_not_enabled_success(self, _mock_register_exams_proctoring, @mock.patch('cms.djangoapps.contentstore.exams.register_exams') @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') @override_waffle_flag(EXAMS_IDA, active=True) - def test_exam_service_enabled_success(self, _mock_register_exams_proctoring, _mock_register_exams_service): + def test_exam_service_enabled_success(self, _mock_register_exams_proctoring, _mock_register_exams_service): # noqa: PT019 # pylint: disable=line-too-long """ exams service interface is called if exam service is enabled """ update_special_exams_and_publish(str(self.course.id)) _mock_register_exams_proctoring.assert_not_called() @@ -232,7 +241,7 @@ def test_exam_service_enabled_success(self, _mock_register_exams_proctoring, _mo @mock.patch('cms.djangoapps.contentstore.exams.register_exams') @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') - def test_register_exams_failure(self, _mock_register_exams_proctoring, _mock_register_exams_service): + def test_register_exams_failure(self, _mock_register_exams_proctoring, _mock_register_exams_service): # noqa: PT019 """ credit requirements update signal fires even if exam registration fails """ with mock.patch('openedx.core.djangoapps.credit.signals.handlers.on_course_publish') as course_publish: _mock_register_exams_proctoring.side_effect = Exception('boom!') @@ -252,7 +261,7 @@ class CheckBrokenLinksTaskTest(ModuleStoreTestCase): """Tests for CheckBrokenLinksTask""" def setUp(self): super().setUp() - self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # lint-amnesty, pylint: disable=protected-access + self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # pylint: disable=protected-access self.test_course = CourseFactory.create( org="test", course="course1", display_name="run1" ) @@ -354,22 +363,22 @@ def test_hash_tags_stripped_from_url_lists(self): f'Processed URL list lines = {processed_lines}; expected {original_lines - 2}' def test_http_url_not_recognized_as_studio_url_scheme(self): - self.assertFalse(_is_studio_url('http://www.google.com')) + self.assertFalse(_is_studio_url('http://www.google.com')) # noqa: PT009 def test_https_url_not_recognized_as_studio_url_scheme(self): - self.assertFalse(_is_studio_url('https://www.google.com')) + self.assertFalse(_is_studio_url('https://www.google.com')) # noqa: PT009 def test_http_with_studio_base_url_recognized_as_studio_url_scheme(self): - self.assertTrue(_is_studio_url(f'http://{settings.CMS_BASE}/testurl')) + self.assertTrue(_is_studio_url(f'http://{settings.CMS_BASE}/testurl')) # noqa: PT009 def test_https_with_studio_base_url_recognized_as_studio_url_scheme(self): - self.assertTrue(_is_studio_url(f'https://{settings.CMS_BASE}/testurl')) + self.assertTrue(_is_studio_url(f'https://{settings.CMS_BASE}/testurl')) # noqa: PT009 def test_container_url_without_url_base_is_recognized_as_studio_url_scheme(self): - self.assertTrue(_is_studio_url('container/test')) + self.assertTrue(_is_studio_url('container/test')) # noqa: PT009 def test_slash_url_without_url_base_is_recognized_as_studio_url_scheme(self): - self.assertTrue(_is_studio_url('/static/test')) + self.assertTrue(_is_studio_url('/static/test')) # noqa: PT009 @mock.patch('cms.djangoapps.contentstore.tasks.ModuleStoreEnum', autospec=True) @mock.patch('cms.djangoapps.contentstore.tasks.modulestore', autospec=True) @@ -398,7 +407,7 @@ def test_number_of_scanned_blocks_equals_blocks_in_course(self, mockextract_cont expected_blocks = self.store.get_items(self.test_course.id) _scan_course_for_links(self.test_course.id) - self.assertEqual(len(expected_blocks), mockextract_content_URLs_from_course.call_count) + self.assertEqual(len(expected_blocks), mockextract_content_URLs_from_course.call_count) # noqa: PT009 @mock.patch('cms.djangoapps.contentstore.tasks.get_block_info', autospec=True) @mock.patch('cms.djangoapps.contentstore.tasks.modulestore', autospec=True) @@ -434,11 +443,11 @@ def get_block_side_effect(block): urls = _scan_course_for_links(self.test_course.id) # The drag-and-drop block should not appear in the results - self.assertFalse( + self.assertFalse( # noqa: PT009 any(block_id == str(drag_and_drop_block.usage_key) for block_id, _ in urls), "Drag and Drop blocks should be excluded" ) - self.assertTrue( + self.assertTrue( # noqa: PT009 any(block_id == str(text_block.usage_key) for block_id, _ in urls), "Text block should be included" ) @@ -536,8 +545,8 @@ def test_filter_by_status(self): filtered_results, retry_list = _filter_by_status(results) - self.assertEqual(filtered_results, expected_filtered_results) - self.assertEqual(retry_list, expected_retry_list) + self.assertEqual(filtered_results, expected_filtered_results) # noqa: PT009 + self.assertEqual(retry_list, expected_retry_list) # noqa: PT009 @patch("cms.djangoapps.contentstore.tasks._validate_user", return_value=MagicMock()) @patch("cms.djangoapps.contentstore.tasks._scan_course_for_links", return_value=["url1", "url2"]) @@ -606,7 +615,7 @@ def __init__(self): logging.exception("Error checking links for course %s", course_key_string, exc_info=True) if mock_self.status.state != "FAILED": mock_self.status.fail({"raw_error_msg": str(e)}) - assert False, "Exception should not occur" + assert False, "Exception should not occur" # noqa: B011, PT015 # Assertions to confirm patched calls were invoked mock_validate_user.assert_called_once_with(mock_self, user_id, language) @@ -638,7 +647,7 @@ def test_convert_to_standard_url(self): ] for url, expected in test_cases: - self.assertEqual( + self.assertEqual( # noqa: PT009 _convert_to_standard_url(url, course_key), expected, f"Failed for URL: {url}", @@ -667,4 +676,137 @@ def test_extract_content_URLs_from_course(self): "https://validsite.com", "https://another-valid.com" ] - self.assertEqual(extract_content_URLs_from_course(content), set(expected)) + self.assertEqual(extract_content_URLs_from_course(content), set(expected)) # noqa: PT009 + + +@ddt.ddt +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +class SyncDiscussionSettingsTaskTestCase(CourseTestCase): + """Tests for the `sync_discussion_settings` task.""" + + def setUp(self): + super().setUp() + self.discussion_config = DiscussionsConfiguration.objects.create(context_key=self.course.id) + + def _update_discussion_settings(self, discussions_settings: dict): + """Helper method to set discussion settings in the course.""" + self.course.discussions_settings = discussions_settings + modulestore().update_item(self.course, self.user.id) + + def test_sync_settings(self): + """Test syncing discussion settings to DiscussionsConfiguration.""" + self._update_discussion_settings( + { + "enable_graded_units": True, + "unit_level_visibility": False, + "enable_in_context": True, + "posting_restrictions": "enabled", + } + ) + + sync_discussion_settings(self.course.id, self.user) + + self.discussion_config.refresh_from_db() + assert self.discussion_config.enable_graded_units is True + assert self.discussion_config.unit_level_visibility is False + assert self.discussion_config.enable_in_context is True + assert self.discussion_config.posting_restrictions == "enabled" + assert self.discussion_config.provider_type == Provider.LEGACY + + def test_sync_plugin_configuration(self): + """Test syncing plugin configuration from provider settings.""" + # Set up course discussion settings with provider-specific config + provider_config = {"test_key": "test_value", "test_key_2": "test_value_2"} + self._update_discussion_settings({self.discussion_config.provider_type: provider_config}) + + sync_discussion_settings(self.course.id, self.user) + + self.discussion_config.refresh_from_db() + assert self.discussion_config.plugin_configuration == provider_config + + @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, active=True) + def test_auto_migrate_to_new_structure(self): + """Test automatic migration to the `OPEN_EDX` provider when new structure is enabled.""" + with self.assertLogs("cms.djangoapps.contentstore.tasks", level="INFO") as logs: + sync_discussion_settings(self.course.id, self.user) + + migration_log = f"New structure is enabled, also updating {self.course.id} to use new provider" + assert any(migration_log in log for log in logs.output) + + self.discussion_config.refresh_from_db() + assert self.discussion_config.provider_type == Provider.OPEN_EDX + + course = modulestore().get_course(self.course.id) + assert course.discussions_settings.get("provider_type") == Provider.OPEN_EDX + + @ddt.data( + {"provider_type": Provider.OPEN_EDX}, # Using the `provider_type` field. + {"provider": Provider.OPEN_EDX}, # Using the `provider` field as fallback. + ) + @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, active=True) + def test_no_provider_migration_when_already_openedx(self, provider_settings: dict): + """Test no migration occurs when provider is already `OPEN_EDX`.""" + self._update_discussion_settings(provider_settings) + + with self.assertLogs("cms.djangoapps.contentstore.tasks", level="INFO") as logs: + sync_discussion_settings(self.course.id, self.user) + + migration_log = f"New structure is enabled, also updating {self.course.id} to use new provider" + assert not any(migration_log in log for log in logs.output) + + def test_all_syncable_fields_are_overridden(self): + """ + Verify that all syncable DiscussionsConfiguration fields are updated during course import. + + If this test fails after adding a new field, update `sync_discussion_settings` to handle it. + """ + + excluded_fields = { + "context_key", # Primary key - not synced. + "enabled", # Handled separately in `update_discussions_settings_from_course`. + "created", # Auto-generated by TimeStampedModel. + "modified", # Auto-generated by TimeStampedModel. + "plugin_configuration", # Custom logic. Already tested in `test_sync_plugin_configuration`. + "provider_type", # Custom logic. Already tested in `test_auto_migrate_to_new_structure`. + } + + test_values = {} + for field in DiscussionsConfiguration._meta.get_fields(): + if not getattr(field, "concrete", False): + continue + if field.primary_key or field.name in excluded_fields: + continue + if isinstance(field, (models.ForeignKey, models.ManyToManyField)): + continue + + if isinstance(field, models.BooleanField): + test_values[field.name] = not field.default + elif isinstance(field, models.CharField) and field.choices: + test_values[field.name] = next(v for v, _ in field.choices if v != field.default) + elif isinstance(field, models.CharField): + test_values[field.name] = "test_sync_value" + else: + test_values[field.name] = {"synced_key": "synced_value"} + + self._update_discussion_settings(test_values) + sync_discussion_settings(self.course.id, self.user) + + self.discussion_config.refresh_from_db() + for name, expected in test_values.items(): + assert getattr(self.discussion_config, name) == expected, ( + f"Field '{name}' was not synced during course import. " + f"Update sync_discussion_settings to handle this field.", + ) + + def test_handling_exceptions(self): + """Test that exceptions are caught and logged properly.""" + test_error_message = "Test error" + + with mock.patch.object(DiscussionsConfiguration.objects, "get", side_effect=Exception(test_error_message)): + with self.assertLogs("cms.djangoapps.contentstore.tasks", level="INFO") as logs: + sync_discussion_settings(self.course.id, self.user) + + expected_log = ( + f"Course import {self.course.id}: DiscussionsConfiguration sync failed: {test_error_message}" + ) + assert any(expected_log in log for log in logs.output) diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index 9163ff1ffc75..f9cc996717ee 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py @@ -1,12 +1,12 @@ """ Tests for transcripts_utils. """ -from contextlib import contextmanager import copy import json import re import tempfile import textwrap import unittest +from contextlib import contextmanager from unittest.mock import patch from uuid import uuid4 @@ -15,19 +15,24 @@ from django.conf import settings from django.test.utils import override_settings from django.utils import translation +from xblocks_contrib.video.exceptions import TranscriptsGenerationException from cms.djangoapps.contentstore.tests.utils import setup_caption_responses from common.djangoapps.student.tests.factories import UserFactory -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -from openedx.core.djangoapps.video_config import transcripts_utils # lint-amnesty, pylint: disable=wrong-import-order -from xblocks_contrib.video.exceptions import TranscriptsGenerationException +from openedx.core.djangoapps.video_config import transcripts_utils # pylint: disable=wrong-import-order +from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.exceptions import NotFoundError # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + SharedModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex +TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex # noqa: UP031 class TestGenerateSubs(unittest.TestCase): @@ -49,7 +54,7 @@ def setUp(self): def test_generate_subs_increase_speed(self): subs = transcripts_utils.generate_subs(2, 1, self.source_subs) - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 subs, { 'start': [200, 400, 480, 780, 2000], @@ -60,7 +65,7 @@ def test_generate_subs_increase_speed(self): def test_generate_subs_decrease_speed_1(self): subs = transcripts_utils.generate_subs(0.5, 1, self.source_subs) - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 subs, { 'start': [50, 100, 120, 195, 500], @@ -72,7 +77,7 @@ def test_generate_subs_decrease_speed_1(self): def test_generate_subs_decrease_speed_2(self): """Test for correct devision during `generate_subs` process.""" subs = transcripts_utils.generate_subs(1, 2, self.source_subs) - self.assertDictEqual( + self.assertDictEqual( # noqa: PT009 subs, { 'start': [50, 100, 120, 195, 500], @@ -143,7 +148,7 @@ def setUp(self): self.clear_subs_content() def test_save_subs_to_store(self): - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 contentstore().find(self.content_location) result_location = transcripts_utils.save_subs_to_store( @@ -151,23 +156,23 @@ def test_save_subs_to_store(self): self.subs_id, self.course) - self.assertTrue(contentstore().find(self.content_location)) - self.assertEqual(result_location, self.content_location) + self.assertTrue(contentstore().find(self.content_location)) # noqa: PT009 + self.assertEqual(result_location, self.content_location) # noqa: PT009 def test_save_unjsonable_subs_to_store(self): """ Ensures that subs, that can't be dumped, can't be found later. """ - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 contentstore().find(self.content_location_unjsonable) - with self.assertRaises(TypeError): + with self.assertRaises(TypeError): # noqa: PT027 transcripts_utils.save_subs_to_store( self.unjsonable_subs, self.unjsonable_subs_id, self.course) - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 contentstore().find(self.content_location_unjsonable) @@ -181,7 +186,7 @@ class TestYoutubeSubsBase(SharedModuleStoreTestCase): def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create( - org=cls.org, number=cls.number, display_name=cls.display_name) # lint-amnesty, pylint: disable=no-member + org=cls.org, number=cls.number, display_name=cls.display_name) # pylint: disable=no-member @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) @@ -233,11 +238,11 @@ def test_success_downloading_subs(self): setup_caption_responses(mock_get, language_code, caption_response_string) transcripts_utils.download_youtube_subs(good_youtube_sub, self.course, settings) - self.assertEqual(2, len(mock_get.mock_calls)) + self.assertEqual(2, len(mock_get.mock_calls)) # noqa: PT009 args, kwargs = mock_get.call_args_list[0] - self.assertEqual(args[0], 'https://www.youtube.com/watch?v=good_id_2') + self.assertEqual(args[0], 'https://www.youtube.com/watch?v=good_id_2') # noqa: PT009 args, kwargs = mock_get.call_args_list[1] - self.assertTrue(re.match(r"^https://www\.youtube\.com/api/timedtext.*", args[0])) + self.assertTrue(re.match(r"^https://www\.youtube\.com/api/timedtext.*", args[0])) # noqa: PT009 def test_subs_for_html5_vid_with_periods(self): """ @@ -246,11 +251,11 @@ def test_subs_for_html5_vid_with_periods(self): incorrect subs name parsing """ html5_ids = transcripts_utils.get_html5_ids(['foo.mp4', 'foo.1.bar.mp4', 'foo/bar/baz.1.4.mp4', 'foo']) - self.assertEqual(4, len(html5_ids)) - self.assertEqual(html5_ids[0], 'foo') - self.assertEqual(html5_ids[1], 'foo.1.bar') - self.assertEqual(html5_ids[2], 'baz.1.4') - self.assertEqual(html5_ids[3], 'foo') + self.assertEqual(4, len(html5_ids)) # noqa: PT009 + self.assertEqual(html5_ids[0], 'foo') # noqa: PT009 + self.assertEqual(html5_ids[1], 'foo.1.bar') # noqa: PT009 + self.assertEqual(html5_ids[2], 'baz.1.4') # noqa: PT009 + self.assertEqual(html5_ids[3], 'foo') # noqa: PT009 @patch('openedx.core.djangoapps.video_config.transcripts_utils.requests.get') def test_fail_downloading_subs(self, mock_get): @@ -261,7 +266,7 @@ def test_fail_downloading_subs(self, mock_get): bad_youtube_sub = 'BAD_YOUTUBE_ID2' self.clear_sub_content(bad_youtube_sub) - with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): # noqa: PT027 transcripts_utils.download_youtube_subs(bad_youtube_sub, self.course, settings) def test_success_downloading_chinese_transcripts(self): @@ -278,17 +283,17 @@ def test_success_downloading_chinese_transcripts(self): transcripts_utils.download_youtube_subs(good_youtube_sub, self.course, settings) # Check assets status after importing subtitles. - for subs_id in good_youtube_subs.values(): # lint-amnesty, pylint: disable=undefined-variable + for subs_id in good_youtube_subs.values(): # pylint: disable=undefined-variable # noqa: F821 filename = f'subs_{subs_id}.srt.sjson' content_location = StaticContent.compute_location( self.course.id, filename ) - self.assertTrue(contentstore().find(content_location)) + self.assertTrue(contentstore().find(content_location)) # noqa: PT009 self.clear_sub_content(good_youtube_sub) -class TestGenerateSubsFromSource(TestDownloadYoutubeSubs): # lint-amnesty, pylint: disable=test-inherits-tests +class TestGenerateSubsFromSource(TestDownloadYoutubeSubs): # pylint: disable=test-inherits-tests """Tests for `generate_subs_from_source` function.""" def test_success_generating_subs(self): @@ -318,7 +323,7 @@ def test_success_generating_subs(self): content_location = StaticContent.compute_location( self.course.id, filename ) - self.assertTrue(contentstore().find(content_location)) + self.assertTrue(contentstore().find(content_location)) # noqa: PT009 self.clear_subs_content(youtube_subs) @@ -339,10 +344,10 @@ def test_fail_bad_subs_type(self): At the left we can see... """) - with self.assertRaises(TranscriptsGenerationException) as cm: + with self.assertRaises(TranscriptsGenerationException) as cm: # noqa: PT027 transcripts_utils.generate_subs_from_source(youtube_subs, 'BAD_FORMAT', srt_filedata, self.course) exception_message = str(cm.exception) - self.assertEqual(exception_message, "We support only SubRip (*.srt) transcripts format.") + self.assertEqual(exception_message, "We support only SubRip (*.srt) transcripts format.") # noqa: PT009 def test_fail_bad_subs_filedata(self): youtube_subs = { @@ -353,13 +358,13 @@ def test_fail_bad_subs_filedata(self): srt_filedata = """BAD_DATA""" - with self.assertRaises(TranscriptsGenerationException) as cm: + with self.assertRaises(TranscriptsGenerationException) as cm: # noqa: PT027 transcripts_utils.generate_subs_from_source(youtube_subs, 'srt', srt_filedata, self.course) exception_message = str(cm.exception) - self.assertEqual(exception_message, "Something wrong with SubRip transcripts file during parsing.") + self.assertEqual(exception_message, "Something wrong with SubRip transcripts file during parsing.") # noqa: PT009 # pylint: disable=line-too-long -class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs): # lint-amnesty, pylint: disable=test-inherits-tests +class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs): # pylint: disable=test-inherits-tests """Tests for `generate_srt_from_sjson` function.""" def test_success_generating_subs(self): @@ -375,7 +380,7 @@ def test_success_generating_subs(self): ] } srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1) - self.assertTrue(srt_subs) + self.assertTrue(srt_subs) # noqa: PT009 expected_subs = [ '00:00:00,100 --> 00:00:00,200\nsubs #1', '00:00:00,200 --> 00:00:00,240\nsubs #2', @@ -385,7 +390,7 @@ def test_success_generating_subs(self): ] for sub in expected_subs: - self.assertIn(sub, srt_subs) + self.assertIn(sub, srt_subs) # noqa: PT009 def test_success_generating_subs_speed_up(self): sjson_subs = { @@ -400,7 +405,7 @@ def test_success_generating_subs_speed_up(self): ] } srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 0.5) - self.assertTrue(srt_subs) + self.assertTrue(srt_subs) # noqa: PT009 expected_subs = [ '00:00:00,050 --> 00:00:00,100\nsubs #1', '00:00:00,100 --> 00:00:00,120\nsubs #2', @@ -409,7 +414,7 @@ def test_success_generating_subs_speed_up(self): '00:00:27,000 --> 00:00:39,200\nsubs #5', ] for sub in expected_subs: - self.assertIn(sub, srt_subs) + self.assertIn(sub, srt_subs) # noqa: PT009 def test_success_generating_subs_speed_down(self): sjson_subs = { @@ -424,7 +429,7 @@ def test_success_generating_subs_speed_down(self): ] } srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 2) - self.assertTrue(srt_subs) + self.assertTrue(srt_subs) # noqa: PT009 expected_subs = [ '00:00:00,200 --> 00:00:00,400\nsubs #1', @@ -434,7 +439,7 @@ def test_success_generating_subs_speed_down(self): '00:01:48,000 --> 00:02:36,800\nsubs #5', ] for sub in expected_subs: - self.assertIn(sub, srt_subs) + self.assertIn(sub, srt_subs) # noqa: PT009 def test_fail_generating_subs(self): sjson_subs = { @@ -446,7 +451,7 @@ def test_fail_generating_subs(self): ] } srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1) - self.assertFalse(srt_subs) + self.assertFalse(srt_subs) # noqa: PT009 class TestYoutubeTranscripts(unittest.TestCase): @@ -458,7 +463,7 @@ def test_youtube_bad_status_code(self, mock_get): track_status_code = 404 setup_caption_responses(mock_get, 'en', 'test', track_status_code) youtube_id = 'bad_youtube_id' - with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): # noqa: PT027 link = transcripts_utils.get_transcript_links_from_youtube(youtube_id, settings, translation) transcripts_utils.get_transcript_from_youtube(link, youtube_id, translation) @@ -466,7 +471,7 @@ def test_youtube_bad_status_code(self, mock_get): def test_youtube_empty_text(self, mock_get): setup_caption_responses(mock_get, 'en', '') youtube_id = 'bad_youtube_id' - with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): # noqa: PT027 link = transcripts_utils.get_transcript_links_from_youtube(youtube_id, settings, translation) transcripts_utils.get_transcript_from_youtube(link, youtube_id, translation) @@ -491,12 +496,12 @@ def test_youtube_good_result(self): link = transcripts_utils.get_transcript_links_from_youtube(youtube_id, settings, translation) transcripts = transcripts_utils.get_transcript_from_youtube(link['en'], youtube_id, translation) - self.assertEqual(transcripts, expected_transcripts) - self.assertEqual(2, len(mock_get.mock_calls)) + self.assertEqual(transcripts, expected_transcripts) # noqa: PT009 + self.assertEqual(2, len(mock_get.mock_calls)) # noqa: PT009 args, kwargs = mock_get.call_args_list[0] - self.assertEqual(args[0], f'https://www.youtube.com/watch?v={youtube_id}') + self.assertEqual(args[0], f'https://www.youtube.com/watch?v={youtube_id}') # noqa: PT009 args, kwargs = mock_get.call_args_list[1] - self.assertTrue(re.match(r"^https://www\.youtube\.com/api/timedtext.*", args[0])) + self.assertTrue(re.match(r"^https://www\.youtube\.com/api/timedtext.*", args[0])) # noqa: PT009 class TestTranscript(unittest.TestCase): @@ -542,7 +547,7 @@ def test_convert_srt_to_txt(self): """ expected = self.txt_transcript actual = transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'txt') - self.assertEqual(actual, expected) + self.assertEqual(actual, expected) # noqa: PT009 def test_convert_srt_to_srt(self): """ @@ -550,7 +555,7 @@ def test_convert_srt_to_srt(self): """ expected = self.srt_transcript actual = transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'srt') - self.assertEqual(actual, expected) + self.assertEqual(actual, expected) # noqa: PT009 def test_convert_sjson_to_txt(self): """ @@ -558,7 +563,7 @@ def test_convert_sjson_to_txt(self): """ expected = self.txt_transcript actual = transcripts_utils.Transcript.convert(self.sjson_transcript, 'sjson', 'txt') - self.assertEqual(actual, expected) + self.assertEqual(actual, expected) # noqa: PT009 def test_convert_sjson_to_srt(self): """ @@ -566,7 +571,7 @@ def test_convert_sjson_to_srt(self): """ expected = self.srt_transcript actual = transcripts_utils.Transcript.convert(self.sjson_transcript, 'sjson', 'srt') - self.assertEqual(actual, expected) + self.assertEqual(actual, expected) # noqa: PT009 def test_convert_srt_to_sjson(self): """ @@ -574,7 +579,7 @@ def test_convert_srt_to_sjson(self): """ expected = self.sjson_transcript actual = transcripts_utils.Transcript.convert(self.srt_transcript, 'srt', 'sjson') - self.assertDictEqual(json.loads(actual), json.loads(expected)) + self.assertDictEqual(json.loads(actual), json.loads(expected)) # noqa: PT009 def test_convert_invalid_srt_to_sjson(self): """ @@ -582,7 +587,7 @@ def test_convert_invalid_srt_to_sjson(self): to convert invalid srt transcript to sjson. """ invalid_srt_transcript = 'invalid SubRip file content' - with self.assertRaises(TranscriptsGenerationException): + with self.assertRaises(TranscriptsGenerationException): # noqa: PT027 transcripts_utils.Transcript.convert(invalid_srt_transcript, 'srt', 'sjson') def test_convert_invalid_invalid_sjson_to_srt(self): @@ -595,10 +600,10 @@ def test_dummy_non_existent_transcript(self): """ Test `Transcript.asset` raises `NotFoundError` for dummy non-existent transcript. """ - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 transcripts_utils.Transcript.asset(None, transcripts_utils.NON_EXISTENT_TRANSCRIPT) - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 transcripts_utils.Transcript.asset(None, None, filename=transcripts_utils.NON_EXISTENT_TRANSCRIPT) def test_latin1(self): @@ -644,9 +649,9 @@ class TestSubsFilename(unittest.TestCase): def test_unicode(self): name = transcripts_utils.subs_filename("˙∆©ƒƒƒ") - self.assertEqual(name, 'subs_˙∆©ƒƒƒ.srt.sjson') + self.assertEqual(name, 'subs_˙∆©ƒƒƒ.srt.sjson') # noqa: PT009 name = transcripts_utils.subs_filename("˙∆©ƒƒƒ", 'uk') - self.assertEqual(name, 'uk_subs_˙∆©ƒƒƒ.srt.sjson') + self.assertEqual(name, 'uk_subs_˙∆©ƒƒƒ.srt.sjson') # noqa: PT009 @ddt.ddt @@ -686,7 +691,7 @@ def test_get_video_ids_info(self, edx_video_id, youtube_id_1_0, html5_sources, e Verify that `get_video_ids_info` works as expected. """ actual_result = transcripts_utils.get_video_ids_info(edx_video_id, youtube_id_1_0, html5_sources) - self.assertEqual(actual_result, expected_result) + self.assertEqual(actual_result, expected_result) # noqa: PT009 @ddt.ddt @@ -763,7 +768,7 @@ def create_srt_file(self, content): """ Create srt file. """ - srt_file = tempfile.NamedTemporaryFile(suffix=".srt") # lint-amnesty, pylint: disable=consider-using-with + srt_file = tempfile.NamedTemporaryFile(suffix=".srt") # pylint: disable=consider-using-with srt_file.content_type = transcripts_utils.Transcript.SRT srt_file.write(content) srt_file.seek(0) @@ -796,7 +801,7 @@ def test_get_transcript_not_found(self, lang): """ Verify that `NotFoundError` exception is raised when transcript is not found in both the content store and val. """ - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 transcripts_utils.get_transcript( self.video, lang=lang @@ -864,9 +869,9 @@ def test_get_transcript_from_contentstore( language ) - self.assertEqual(content, self.subs[language]) - self.assertEqual(file_name, expected_filename) - self.assertEqual(mimetype, self.srt_mime_type) + self.assertEqual(content, self.subs[language]) # noqa: PT009 + self.assertEqual(file_name, expected_filename) # noqa: PT009 + self.assertEqual(mimetype, self.srt_mime_type) # noqa: PT009 def test_get_transcript_from_content_store_for_ur(self): """ @@ -880,9 +885,9 @@ def test_get_transcript_from_content_store_for_ur(self): output_format=transcripts_utils.Transcript.SJSON ) - self.assertEqual(json.loads(content), self.subs_sjson) - self.assertEqual(filename, 'ur_video_101.sjson') - self.assertEqual(mimetype, self.sjson_mime_type) + self.assertEqual(json.loads(content), self.subs_sjson) # noqa: PT009 + self.assertEqual(filename, 'ur_video_101.sjson') # noqa: PT009 + self.assertEqual(mimetype, self.sjson_mime_type) # noqa: PT009 @patch('openedx.core.djangoapps.video_config.transcripts_utils.get_video_transcript_content') def test_get_transcript_from_val(self, mock_get_video_transcript_content): @@ -897,15 +902,15 @@ def test_get_transcript_from_val(self, mock_get_video_transcript_content): content, filename, mimetype = transcripts_utils.get_transcript( self.video, ) - self.assertEqual(content, self.subs_srt) - self.assertEqual(filename, 'edx.srt') - self.assertEqual(mimetype, self.srt_mime_type) + self.assertEqual(content, self.subs_srt) # noqa: PT009 + self.assertEqual(filename, 'edx.srt') # noqa: PT009 + self.assertEqual(mimetype, self.srt_mime_type) # noqa: PT009 def test_get_transcript_invalid_format(self): """ Verify that `get_transcript` raises correct exception if transcript format is invalid. """ - with self.assertRaises(NotFoundError) as invalid_format_exception: + with self.assertRaises(NotFoundError) as invalid_format_exception: # noqa: PT027 transcripts_utils.get_transcript( self.video, 'ur', @@ -913,7 +918,7 @@ def test_get_transcript_invalid_format(self): ) exception_message = str(invalid_format_exception.exception) - self.assertEqual(exception_message, 'Invalid transcript format `mpeg`') + self.assertEqual(exception_message, 'Invalid transcript format `mpeg`') # noqa: PT009 def test_get_transcript_no_content(self): """ @@ -922,14 +927,14 @@ def test_get_transcript_no_content(self): self.upload_file(self.create_srt_file(b''), self.video.location, 'ur_video_101.srt') self.create_transcript('', 'ur', 'ur_video_101.srt') - with self.assertRaises(NotFoundError) as no_content_exception: + with self.assertRaises(NotFoundError) as no_content_exception: # noqa: PT027 transcripts_utils.get_transcript( self.video, 'ur' ) exception_message = str(no_content_exception.exception) - self.assertEqual(exception_message, 'No transcript content') + self.assertEqual(exception_message, 'No transcript content') # noqa: PT009 def test_get_transcript_no_en_transcript(self): """ @@ -937,14 +942,14 @@ def test_get_transcript_no_en_transcript(self): """ self.video.youtube_id_1_0 = '' self.store.update_item(self.video, self.user.id) - with self.assertRaises(NotFoundError) as no_en_transcript_exception: + with self.assertRaises(NotFoundError) as no_en_transcript_exception: # noqa: PT027 transcripts_utils.get_transcript( self.video, 'en' ) exception_message = str(no_en_transcript_exception.exception) - self.assertEqual(exception_message, 'No transcript for `en` language') + self.assertEqual(exception_message, 'No transcript for `en` language') # noqa: PT009 @patch('openedx.core.djangoapps.video_config.transcripts_utils.edxval_api.get_video_transcript_data') def test_get_transcript_incorrect_json_(self, mock_get_video_transcript_data): @@ -969,7 +974,7 @@ def test_get_transcript_val_exceptions(self, exception_to_raise, mock_Transcript transcripts_info = self.video.get_transcripts_info() lang = self.video.get_default_transcript_language(transcripts_info) edx_video_id = transcripts_utils.clean_video_id(self.video.edx_video_id) - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 transcripts_utils.get_transcript_from_val( edx_video_id, lang=lang, @@ -988,7 +993,7 @@ def test_get_transcript_content_store_exceptions(self, exception_to_raise, mock_ mock_Transcript.asset.side_effect = exception_to_raise transcripts_info = self.video.get_transcripts_info() lang = self.video.get_default_transcript_language(transcripts_info) - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 transcripts_utils.get_transcript_from_contentstore( self.video, language=lang, @@ -1016,7 +1021,7 @@ def test_resolve_lang(self, lang, expected): Test that resolve_language_code_to_transcript_code will successfully match language codes of different cases, and return None if it isn't found """ - self.assertEqual( + self.assertEqual( # noqa: PT009 transcripts_utils.resolve_language_code_to_transcript_code(self.TEST_TRANSCRIPTS, lang), expected ) @@ -1053,7 +1058,7 @@ def mock_django_get_language_info(self, side_effect=None): def test_language_in_languages(self): """ If language is found in LANGUAGE_DICT that value should be returned """ with override_settings(LANGUAGE_DICT=self.TEST_LANGUAGE_DICT): - self.assertEqual( + self.assertEqual( # noqa: PT009 transcripts_utils.get_endonym_or_label(self.LANG_CODE), self.LANG_ENTONYM ) @@ -1065,7 +1070,7 @@ def test_language_in_django_lang_info(self): """ with override_settings(LANGUAGE_DICT={}): with self.mock_django_get_language_info() as mock_get_language_info: - self.assertEqual( + self.assertEqual( # noqa: PT009 transcripts_utils.get_endonym_or_label(self.LANG_CODE), mock_get_language_info.return_value['name_local'] ) @@ -1079,7 +1084,7 @@ def test_language_exact_in_all_languages(self): with self.mock_django_get_language_info(side_effect=KeyError): with override_settings(ALL_LANGUAGES=self.TEST_ALL_LANGUAGES): label = transcripts_utils.get_endonym_or_label(self.LANG_CODE) - self.assertEqual(label, self.LANG_LABEL) + self.assertEqual(label, self.LANG_LABEL) # noqa: PT009 def test_language_generic_in_all_languages(self): """ @@ -1096,7 +1101,7 @@ def test_language_generic_in_all_languages(self): with self.mock_django_get_language_info(side_effect=KeyError): with override_settings(ALL_LANGUAGES=all_languages): label = transcripts_utils.get_endonym_or_label(self.LANG_CODE) - self.assertEqual(label, self.GENERIC_LABEL) + self.assertEqual(label, self.GENERIC_LABEL) # noqa: PT009 def test_language_not_found_anywhere(self): """ @@ -1106,5 +1111,5 @@ def test_language_not_found_anywhere(self): with override_settings(LANGUAGE_DICT={}): with self.mock_django_get_language_info(side_effect=KeyError): with override_settings(ALL_LANGUAGES=all_languages): - with self.assertRaises(NotFoundError): + with self.assertRaises(NotFoundError): # noqa: PT027 transcripts_utils.get_endonym_or_label(self.LANG_CODE) diff --git a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py index 5c3ba8386480..f649299d560b 100644 --- a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py +++ b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py @@ -10,14 +10,14 @@ from django.test import TestCase from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2 -from openedx_events.tests.utils import OpenEdxEventsTestMixin +from openedx_events.testing import OpenEdxEventsTestMixin from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangolib.testing.utils import skip_unless_cms -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin +from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, ModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory -from ..models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink +from ..models import ComponentLink, ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices class BaseUpstreamLinksHelpers(TestCase): @@ -108,7 +108,7 @@ def _compare_links(self, course_key, expected_component_links, expected_containe 'version_synced', 'version_declined', )) - self.assertListEqual(links, expected_component_links) + self.assertListEqual(links, expected_component_links) # noqa: PT009 container_links = list(ContainerLink.objects.filter(downstream_context_key=course_key).values( 'upstream_container', 'upstream_container_key', @@ -118,7 +118,7 @@ def _compare_links(self, course_key, expected_component_links, expected_containe 'version_synced', 'version_declined', )) - self.assertListEqual(container_links, expected_container_links) + self.assertListEqual(container_links, expected_container_links) # noqa: PT009 @skip_unless_cms @@ -167,9 +167,9 @@ def test_call_with_invalid_args(self): """ Test command with invalid args. """ - with self.assertRaisesRegex(CommandError, 'Either --course or --all argument'): + with self.assertRaisesRegex(CommandError, 'Either --course or --all argument'): # noqa: PT027 self.call_command() - with self.assertRaisesRegex(CommandError, 'Only one of --course or --all argument'): + with self.assertRaisesRegex(CommandError, 'Only one of --course or --all argument'): # noqa: PT027 self.call_command('--all', '--course', str(self.course_key_1)) def test_call_for_single_course(self): @@ -246,7 +246,7 @@ def test_call_for_invalid_course(self): course_key = "invalid-course" with self.assertLogs(level="ERROR") as ctx: self.call_command('--course', course_key) - self.assertEqual( + self.assertEqual( # noqa: PT009 f'Invalid course key: {course_key}, skipping..', ctx.records[0].getMessage() ) @@ -258,7 +258,7 @@ def test_call_for_nonexistent_course(self): course_key = "course-v1:unix+ux1+2024_T2" with self.assertLogs(level="ERROR") as ctx: self.call_command('--course', course_key) - self.assertIn( + self.assertIn( # noqa: PT009 f'Could not find items for given course: {course_key}', ctx.records[0].getMessage() ) diff --git a/cms/djangoapps/contentstore/tests/test_users_default_role.py b/cms/djangoapps/contentstore/tests/test_users_default_role.py index 1abf0a0ec697..2f05801e5010 100644 --- a/cms/djangoapps/contentstore/tests/test_users_default_role.py +++ b/cms/djangoapps/contentstore/tests/test_users_default_role.py @@ -5,12 +5,11 @@ from unittest import skip -from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase - from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.utils import delete_course, reverse_url from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase class TestUsersDefaultRole(ModuleStoreTestCase): @@ -61,18 +60,18 @@ def test_user_forum_default_role_on_course_deletion(self): enrolled even the course is deleted and keeps its "Student" forum role for that course """ # check that user has enrollment for this course - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) # noqa: PT009 # check that user has his default "Student" forum role for this course - self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) + self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) # noqa: PT009 delete_course(self.course_key, self.user.id) # check that user's enrollment for this course is not deleted - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) # noqa: PT009 # check that user has forum role for this course even after deleting it - self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) + self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) # noqa: PT009 def test_user_role_on_course_recreate(self): """ @@ -80,19 +79,19 @@ def test_user_role_on_course_recreate(self): forum role "Student" for that course """ # check that user has enrollment and his default "Student" forum role for this course - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) - self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) # noqa: PT009 + self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) # noqa: PT009 # delete this course and recreate this course with same user delete_course(self.course_key, self.user.id) resp = self._create_course_with_given_location(self.course_key) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # check that user has his enrollment for this course - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) # noqa: PT009 # check that user has his default "Student" forum role for this course - self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) + self.assertTrue(self.user.roles.filter(name="Student", course_id=self.course_key)) # noqa: PT009 @skip("OldMongo Deprecation") # Issue with case-insensitive course keys @@ -102,17 +101,17 @@ def test_user_role_on_course_recreate_with_change_name_case(self): his default forum role "Student" for that course """ # check that user has enrollment and his default "Student" forum role for this course - self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_key)) # noqa: PT009 # delete this course and recreate this course with same user delete_course(self.course_key, self.user.id) # now create same course with different name case ('uppercase') new_course_key = self.course_key.replace(course=self.course_key.course.upper()) resp = self._create_course_with_given_location(new_course_key) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # check that user has his default "Student" forum role again for this course (with changed name case) - self.assertTrue( + self.assertTrue( # noqa: PT009 self.user.roles.filter(name="Student", course_id=new_course_key) ) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index c529410cd110..e04cabd226a4 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -9,10 +9,9 @@ from django.conf import settings from django.test import TestCase from django.test.utils import override_settings -from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator -from openedx_events.tests.utils import OpenEdxEventsTestMixin +from openedx_events.testing import OpenEdxEventsTestMixin from path import Path as path from pytz import UTC from rest_framework import status @@ -24,20 +23,16 @@ from cms.djangoapps.contentstore.utils import send_course_update_notification from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS from openedx.core.djangoapps.notifications.models import Notification from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( # pylint: disable=wrong-import-order TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase, ) -from xmodule.modulestore.tests.factories import ( - BlockFactory, - CourseFactory, -) +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from xmodule.partitions.partitions import Group, UserPartition @@ -49,12 +44,12 @@ def lms_link_test(self): course_key = CourseLocator('mitX', '101', 'test') location = course_key.make_usage_key('vertical', 'contacting_us') link = utils.get_lms_link_for_item(location, False) - self.assertEqual(link, "//localhost:8000/courses/course-v1:mitX+101+test/jump_to/block-v1:mitX+101+test+type" + self.assertEqual(link, "//localhost:8000/courses/course-v1:mitX+101+test/jump_to/block-v1:mitX+101+test+type" # noqa: PT009 # pylint: disable=line-too-long "@vertical+block@contacting_us") # test preview link = utils.get_lms_link_for_item(location, True) - self.assertEqual( + self.assertEqual( # noqa: PT009 link, "//preview.localhost/courses/course-v1:mitX+101+test/jump_to/block-v1:mitX+101+test+type@vertical+block" "@contacting_us " @@ -63,27 +58,27 @@ def lms_link_test(self): # now test with the course' location location = course_key.make_usage_key('course', 'test') link = utils.get_lms_link_for_item(location) - self.assertEqual(link, "//localhost:8000/courses/course-v1:mitX+101+test/jump_to/block-v1:mitX+101+test+type" + self.assertEqual(link, "//localhost:8000/courses/course-v1:mitX+101+test/jump_to/block-v1:mitX+101+test+type" # noqa: PT009 # pylint: disable=line-too-long "@course+block@test") def lms_link_for_certificate_web_view_test(self): """ Tests get_lms_link_for_certificate_web_view. """ course_key = CourseLocator('mitX', '101', 'test') - dummy_user = ModuleStoreEnum.UserID.test + dummy_user = ModuleStoreEnum.UserID.test # noqa: F841 mode = 'professional' - self.assertEqual( + self.assertEqual( # noqa: PT009 utils.get_lms_link_for_certificate_web_view(course_key, mode), - "//localhost:8000/certificates/course/{course_key}?preview={mode}".format( + "//localhost:8000/certificates/course/{course_key}?preview={mode}".format( # noqa: UP032 course_key=course_key, mode=mode ) ) with with_site_configuration_context(configuration={"course_org_filter": "mitX", "LMS_BASE": "dummyhost:8000"}): - self.assertEqual( + self.assertEqual( # noqa: PT009 utils.get_lms_link_for_certificate_web_view(course_key, mode), - "//dummyhost:8000/certificates/course/{course_key}?preview={mode}".format( + "//dummyhost:8000/certificates/course/{course_key}?preview={mode}".format( # noqa: UP032 course_key=course_key, mode=mode ) @@ -156,7 +151,7 @@ def test_draft_released_xblock(self): vertical.start = self.future modulestore().update_item(vertical, self.dummy_user) - self.assertTrue(utils.is_currently_visible_to_students(vertical)) + self.assertTrue(utils.is_currently_visible_to_students(vertical)) # noqa: PT009 def _test_visible_to_students(self, expected_visible_without_lock, name, start_date, publish=False): """ @@ -164,13 +159,13 @@ def _test_visible_to_students(self, expected_visible_without_lock, name, start_d with and without visible_to_staff_only set. """ no_staff_lock = self._create_xblock_with_start_date(name, start_date, publish, visible_to_staff_only=False) - self.assertEqual(expected_visible_without_lock, utils.is_currently_visible_to_students(no_staff_lock)) + self.assertEqual(expected_visible_without_lock, utils.is_currently_visible_to_students(no_staff_lock)) # noqa: PT009 # pylint: disable=line-too-long # any xblock with visible_to_staff_only set to True should not be visible to students. staff_lock = self._create_xblock_with_start_date( name + "_locked", start_date, publish, visible_to_staff_only=True ) - self.assertFalse(utils.is_currently_visible_to_students(staff_lock)) + self.assertFalse(utils.is_currently_visible_to_students(staff_lock)) # noqa: PT009 def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False): """Helper to create an xblock with a start date, optionally publishing it""" @@ -216,8 +211,8 @@ def _update_release_dates(self, chapter_start, sequential_start, vertical_start) def _verify_release_date_source(self, item, expected_source): """Helper to verify that the release date source of a given item matches the expected source""" source = utils.find_release_date_source(item) - self.assertEqual(source.location, expected_source.location) - self.assertEqual(source.start, expected_source.start) + self.assertEqual(source.location, expected_source.location) # noqa: PT009 + self.assertEqual(source.start, expected_source.start) # noqa: PT009 def test_chapter_source_for_vertical(self): """Tests a vertical's release date being set by its chapter""" @@ -285,8 +280,8 @@ class StaffLockSourceTest(StaffLockTest): def _verify_staff_lock_source(self, item, expected_source): """Helper to verify that the staff lock source of a given item matches the expected source""" source = utils.find_staff_lock_source(item) - self.assertEqual(source.location, expected_source.location) - self.assertTrue(source.visible_to_staff_only) + self.assertEqual(source.location, expected_source.location) # noqa: PT009 + self.assertTrue(source.visible_to_staff_only) # noqa: PT009 def test_chapter_source_for_vertical(self): """Tests a vertical's staff lock being set by its chapter""" @@ -311,12 +306,12 @@ def test_vertical_source_for_vertical(self): def test_orphan_has_no_source(self): """Tests that a orphaned xblock has no staff lock source""" - self.assertIsNone(utils.find_staff_lock_source(self.orphan)) + self.assertIsNone(utils.find_staff_lock_source(self.orphan)) # noqa: PT009 def test_no_source_for_vertical(self): """Tests a vertical with no staff lock set anywhere""" self._update_staff_locks(False, False, False) - self.assertIsNone(utils.find_staff_lock_source(self.vertical)) + self.assertIsNone(utils.find_staff_lock_source(self.vertical)) # noqa: PT009 class InheritedStaffLockTest(StaffLockTest): @@ -325,27 +320,27 @@ class InheritedStaffLockTest(StaffLockTest): def test_no_inheritance(self): """Tests that a locked or unlocked vertical with no locked ancestors does not have an inherited lock""" self._update_staff_locks(False, False, False) - self.assertFalse(utils.ancestor_has_staff_lock(self.vertical)) + self.assertFalse(utils.ancestor_has_staff_lock(self.vertical)) # noqa: PT009 self._update_staff_locks(False, False, True) - self.assertFalse(utils.ancestor_has_staff_lock(self.vertical)) + self.assertFalse(utils.ancestor_has_staff_lock(self.vertical)) # noqa: PT009 def test_inheritance_in_locked_section(self): """Tests that a locked or unlocked vertical in a locked section has an inherited lock""" self._update_staff_locks(True, False, False) - self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) + self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) # noqa: PT009 self._update_staff_locks(True, False, True) - self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) + self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) # noqa: PT009 def test_inheritance_in_locked_subsection(self): """Tests that a locked or unlocked vertical in a locked subsection has an inherited lock""" self._update_staff_locks(False, True, False) - self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) + self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) # noqa: PT009 self._update_staff_locks(False, True, True) - self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) + self.assertTrue(utils.ancestor_has_staff_lock(self.vertical)) # noqa: PT009 def test_no_inheritance_for_orphan(self): """Tests that an orphaned xblock does not inherit staff lock""" - self.assertFalse(utils.ancestor_has_staff_lock(self.orphan)) + self.assertFalse(utils.ancestor_has_staff_lock(self.orphan)) # noqa: PT009 class GroupVisibilityTest(CourseTestCase): @@ -417,8 +412,8 @@ def test_no_visibility_set(self): def verify_all_components_visible_to_all(): """ Verifies when group_access has not been set on anything. """ for item in (self.sequential, self.vertical, self.html, self.problem): - self.assertFalse(utils.has_children_visible_to_specific_partition_groups(item)) - self.assertFalse(utils.is_visible_to_specific_partition_groups(item)) + self.assertFalse(utils.has_children_visible_to_specific_partition_groups(item)) # noqa: PT009 + self.assertFalse(utils.is_visible_to_specific_partition_groups(item)) # noqa: PT009 verify_all_components_visible_to_all() @@ -440,15 +435,15 @@ def test_sequential_and_problem_have_group_access(self): self.problem = self.store.get_item(self.problem.location) # Note that "has_children_visible_to_specific_partition_groups" only checks immediate children. - self.assertFalse(utils.has_children_visible_to_specific_partition_groups(self.sequential)) - self.assertTrue(utils.has_children_visible_to_specific_partition_groups(self.vertical)) - self.assertFalse(utils.has_children_visible_to_specific_partition_groups(self.html)) - self.assertFalse(utils.has_children_visible_to_specific_partition_groups(self.problem)) + self.assertFalse(utils.has_children_visible_to_specific_partition_groups(self.sequential)) # noqa: PT009 + self.assertTrue(utils.has_children_visible_to_specific_partition_groups(self.vertical)) # noqa: PT009 + self.assertFalse(utils.has_children_visible_to_specific_partition_groups(self.html)) # noqa: PT009 + self.assertFalse(utils.has_children_visible_to_specific_partition_groups(self.problem)) # noqa: PT009 - self.assertTrue(utils.is_visible_to_specific_partition_groups(self.sequential)) - self.assertFalse(utils.is_visible_to_specific_partition_groups(self.vertical)) - self.assertFalse(utils.is_visible_to_specific_partition_groups(self.html)) - self.assertTrue(utils.is_visible_to_specific_partition_groups(self.problem)) + self.assertTrue(utils.is_visible_to_specific_partition_groups(self.sequential)) # noqa: PT009 + self.assertFalse(utils.is_visible_to_specific_partition_groups(self.vertical)) # noqa: PT009 + self.assertFalse(utils.is_visible_to_specific_partition_groups(self.html)) # noqa: PT009 + self.assertTrue(utils.is_visible_to_specific_partition_groups(self.problem)) # noqa: PT009 class GetUserPartitionInfoTest(ModuleStoreTestCase): @@ -523,12 +518,12 @@ def test_retrieves_partition_info_with_selected_groups(self): ] } ] - self.assertEqual(self._get_partition_info(schemes=["cohort", "random"]), expected) + self.assertEqual(self._get_partition_info(schemes=["cohort", "random"]), expected) # noqa: PT009 # Update group access and expect that now one group is marked as selected. self._set_group_access({0: [1]}) expected[0]["groups"][1]["selected"] = True - self.assertEqual(self._get_partition_info(schemes=["cohort", "random"]), expected) + self.assertEqual(self._get_partition_info(schemes=["cohort", "random"]), expected) # noqa: PT009 def test_deleted_groups(self): # Select a group that is not defined in the partition @@ -537,8 +532,8 @@ def test_deleted_groups(self): # Expect that the group appears as selected but is marked as deleted partitions = self._get_partition_info() groups = partitions[0]["groups"] - self.assertEqual(len(groups), 3) - self.assertEqual(groups[2], { + self.assertEqual(len(groups), 3) # noqa: PT009 + self.assertEqual(groups[2], { # noqa: PT009 "id": 3, "name": "Deleted Group", "selected": True, @@ -562,8 +557,8 @@ def test_singular_deleted_group(self): self._set_group_access({0: [1]}) partitions = self._get_partition_info() groups = partitions[0]["groups"] - self.assertEqual(len(groups), 1) - self.assertEqual(groups[0], { + self.assertEqual(len(groups), 1) # noqa: PT009 + self.assertEqual(groups[0], { # noqa: PT009 "id": 1, "name": "Deleted Group", "selected": True, @@ -572,8 +567,8 @@ def test_singular_deleted_group(self): def test_filter_by_partition_scheme(self): partitions = self._get_partition_info(schemes=["random"]) - self.assertEqual(len(partitions), 1) - self.assertEqual(partitions[0]["scheme"], "random") + self.assertEqual(len(partitions), 1) # noqa: PT009 + self.assertEqual(partitions[0]["scheme"], "random") # noqa: PT009 def test_exclude_inactive_partitions(self): # Include an inactive verification scheme @@ -602,8 +597,8 @@ def test_exclude_inactive_partitions(self): # Expect that the inactive scheme is excluded from the results partitions = self._get_partition_info(schemes=["cohort", "verification"]) - self.assertEqual(len(partitions), 1) - self.assertEqual(partitions[0]["scheme"], "cohort") + self.assertEqual(len(partitions), 1) # noqa: PT009 + self.assertEqual(partitions[0]["scheme"], "cohort") # noqa: PT009 def test_exclude_partitions_with_no_groups(self): # The cohort partition has no groups defined @@ -628,8 +623,8 @@ def test_exclude_partitions_with_no_groups(self): # Expect that the partition with no groups is excluded from the results partitions = self._get_partition_info(schemes=["cohort", "random"]) - self.assertEqual(len(partitions), 1) - self.assertEqual(partitions[0]["scheme"], "random") + self.assertEqual(len(partitions), 1) # noqa: PT009 + self.assertEqual(partitions[0]["scheme"], "random") # noqa: PT009 def _set_partitions(self, partitions): """Set the user partitions of the course block. """ @@ -666,24 +661,24 @@ def test_with_library_locator(self, mock_olxcleaner_validate): Tests that olx is validation is skipped with library locator. """ library_key = LibraryLocator(org='TestOrg', library='TestProbs') - self.assertTrue(validate_course_olx(library_key, self.toy_course_path, self.status)) - self.assertFalse(mock_olxcleaner_validate.called) + self.assertTrue(validate_course_olx(library_key, self.toy_course_path, self.status)) # noqa: PT009 + self.assertFalse(mock_olxcleaner_validate.called) # noqa: PT009 def test_config_settings_enabled(self, mock_olxcleaner_validate): """ Tests olx validation with config setting is disabled. """ with patch.dict(settings.FEATURES, ENABLE_COURSE_OLX_VALIDATION=False): - self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) - self.assertFalse(mock_olxcleaner_validate.called) + self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) # noqa: PT009 + self.assertFalse(mock_olxcleaner_validate.called) # noqa: PT009 def test_config_settings_disabled(self, mock_olxcleaner_validate): """ Tests olx validation with config setting is enabled. """ with patch.dict(settings.FEATURES, ENABLE_COURSE_OLX_VALIDATION=True): - self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) - self.assertTrue(mock_olxcleaner_validate.called) + self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) # noqa: PT009 + self.assertTrue(mock_olxcleaner_validate.called) # noqa: PT009 def test_exception_during_validation(self, mock_olxcleaner_validate): """ @@ -694,8 +689,8 @@ def test_exception_during_validation(self, mock_olxcleaner_validate): """ mock_olxcleaner_validate.side_effect = Exception with mock.patch(self.LOGGER) as patched_log: - self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) - self.assertTrue(mock_olxcleaner_validate.called) + self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) # noqa: PT009 + self.assertTrue(mock_olxcleaner_validate.called) # noqa: PT009 patched_log.exception.assert_called_once_with( f'Course import {self.course.id}: CourseOlx could not be validated') @@ -709,10 +704,10 @@ def test_no_errors(self, mock_olxcleaner_validate): Mock(errors=[], return_error=Mock(return_value=False)), Mock() ] - self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) + self.assertTrue(validate_course_olx(self.course.id, self.toy_course_path, self.status)) # noqa: PT009 task_artifact = UserTaskArtifact.objects.filter(status=self.status, name='OLX_VALIDATION_ERROR').first() - self.assertIsNone(task_artifact) - self.assertTrue(mock_olxcleaner_validate.called) + self.assertIsNone(task_artifact) # noqa: PT009 + self.assertTrue(mock_olxcleaner_validate.called) # noqa: PT009 @mock.patch('cms.djangoapps.contentstore.tasks.report_error_summary') @mock.patch('cms.djangoapps.contentstore.tasks.report_errors') @@ -733,12 +728,12 @@ def test_creates_artifact(self, mock_report_errors, mock_report_error_summary, m mock_report_error_summary.return_value = [f'Errors: {len(errors)}'] with patch(self.LOGGER) as patched_log: - self.assertFalse(validate_course_olx(self.course.id, self.toy_course_path, self.status)) + self.assertFalse(validate_course_olx(self.course.id, self.toy_course_path, self.status)) # noqa: PT009 patched_log.error.assert_called_once_with( f'Course import {self.course.id}: CourseOlx validation failed.') task_artifact = UserTaskArtifact.objects.filter(status=self.status, name='OLX_VALIDATION_ERROR').first() - self.assertIsNotNone(task_artifact) + self.assertIsNotNone(task_artifact) # noqa: PT009 def test_validate_calls_with(self, mock_olxcleaner_validate): """ @@ -769,7 +764,7 @@ def test_html_replaced_with_text_for_none(self): display_name = None block_type = "html" result = utils.determine_label(display_name, block_type) - self.assertEqual(result, "Text") + self.assertEqual(result, "Text") # noqa: PT009 def test_html_replaced_with_text_for_empty(self): """ @@ -778,7 +773,7 @@ def test_html_replaced_with_text_for_empty(self): display_name = "" block_type = "html" result = utils.determine_label(display_name, block_type) - self.assertEqual(result, "Text") + self.assertEqual(result, "Text") # noqa: PT009 def test_set_titles_not_replaced(self): """ @@ -787,7 +782,7 @@ def test_set_titles_not_replaced(self): display_name = "Something" block_type = "html" result = utils.determine_label(display_name, block_type) - self.assertEqual(result, "Something") + self.assertEqual(result, "Something") # noqa: PT009 def test_non_html_blocks_titles_not_replaced(self): """ @@ -796,10 +791,10 @@ def test_non_html_blocks_titles_not_replaced(self): display_name = None block_type = "something else" result = utils.determine_label(display_name, block_type) - self.assertEqual(result, "something else") + self.assertEqual(result, "something else") # noqa: PT009 -class AuthorizeStaffTestCase(): +class AuthorizeStaffTestCase(): # noqa: UP039 """ Test that only staff roles can access an API endpoint. """ @@ -938,7 +933,6 @@ def test_update_course_details_instructor_paced(self, mock_update): mock_update.assert_called_once_with(self.course.id, payload, mock_request.user) -@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) class CourseUpdateNotificationTests(OpenEdxEventsTestMixin, ModuleStoreTestCase): """ Unit tests for the course_update notification. diff --git a/cms/djangoapps/contentstore/tests/test_video_utils.py b/cms/djangoapps/contentstore/tests/test_video_utils.py index e65e4f6638c6..21ae94c1334a 100644 --- a/cms/djangoapps/contentstore/tests/test_video_utils.py +++ b/cms/djangoapps/contentstore/tests/test_video_utils.py @@ -4,8 +4,7 @@ from datetime import datetime -from unittest import TestCase -from unittest import mock +from unittest import TestCase, mock import ddt import pytz @@ -23,7 +22,7 @@ YOUTUBE_THUMBNAIL_SIZES, download_youtube_video_thumbnail, scrape_youtube_thumbnail, - validate_video_image + validate_video_image, ) from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file @@ -37,7 +36,7 @@ def test_invalid_image_file_info(self): Test that when no file information is provided to validate_video_image, it gives proper error message. """ error = validate_video_image({}) - self.assertEqual(error, 'The image must have name, content type, and size information.') + self.assertEqual(error, 'The image must have name, content type, and size information.') # noqa: PT009 def test_corrupt_image_file(self): """ @@ -50,7 +49,7 @@ def test_corrupt_image_file(self): size=settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES'] ) error = validate_video_image(uploaded_image_file) - self.assertEqual(error, 'There is a problem with this image file. Try to upload a different file.') + self.assertEqual(error, 'There is a problem with this image file. Try to upload a different file.') # noqa: PT009 # pylint: disable=line-too-long @ddt.ddt @@ -215,7 +214,7 @@ def mocked_youtube_thumbnail_responses(resolutions): mocked_responses = [] for resolution in YOUTUBE_THUMBNAIL_SIZES: mocked_content = resolutions.get(resolution, '') - error_response = False if mocked_content else True # lint-amnesty, pylint: disable=simplifiable-if-expression + error_response = False if mocked_content else True # pylint: disable=simplifiable-if-expression mocked_responses.append(self.mocked_youtube_thumbnail_response(mocked_content, error_response)) return mocked_responses @@ -224,8 +223,8 @@ def mocked_youtube_thumbnail_responses(resolutions): thumbnail_content, thumbnail_content_type = download_youtube_video_thumbnail('test-yt-id') # Verify that we get the expected thumbnail content. - self.assertEqual(thumbnail_content, expected_thumbnail_content) - self.assertEqual(thumbnail_content_type, 'image/jpeg') + self.assertEqual(thumbnail_content, expected_thumbnail_content) # noqa: PT009 + self.assertEqual(thumbnail_content_type, 'image/jpeg') # noqa: PT009 @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret') @mock.patch('requests.get') @@ -242,11 +241,11 @@ def test_scrape_youtube_thumbnail(self, mocked_request): # Verify that video1 has no image attached. video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id) - self.assertIsNone(video1_image_url) + self.assertIsNone(video1_image_url) # noqa: PT009 # Verify that video2 has already image attached. video2_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video2_edx_video_id) - self.assertIsNotNone(video2_image_url) + self.assertIsNotNone(video2_image_url) # noqa: PT009 # Scrape video thumbnails. scrape_youtube_thumbnail(course_id, video1_edx_video_id, 'test-yt-id') @@ -254,11 +253,11 @@ def test_scrape_youtube_thumbnail(self, mocked_request): # Verify that now video1 image is attached. video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id) - self.assertIsNotNone(video1_image_url) + self.assertIsNotNone(video1_image_url) # noqa: PT009 # Also verify that video2's image is not updated. video2_image_url_latest = get_course_video_image_url(course_id=course_id, edx_video_id=video2_edx_video_id) - self.assertEqual(video2_image_url, video2_image_url_latest) + self.assertEqual(video2_image_url, video2_image_url_latest) # noqa: PT009 @ddt.data( ( @@ -314,21 +313,21 @@ def test_scrape_youtube_thumbnail_logging( ( None, 'image/jpeg', - 'This image file must be larger than {image_min_size}.'.format( + 'This image file must be larger than {image_min_size}.'.format( # noqa: UP032 image_min_size=settings.VIDEO_IMAGE_MIN_FILE_SIZE_KB ) ), ( b'dummy-content', None, - 'This image file type is not supported. Supported file types are {supported_file_formats}.'.format( + 'This image file type is not supported. Supported file types are {supported_file_formats}.'.format( # noqa: UP032 # pylint: disable=line-too-long supported_file_formats=list(settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys()) ) ), ( None, None, - 'This image file type is not supported. Supported file types are {supported_file_formats}.'.format( + 'This image file type is not supported. Supported file types are {supported_file_formats}.'.format( # noqa: UP032 # pylint: disable=line-too-long supported_file_formats=list(settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys()) ) ), @@ -353,7 +352,7 @@ def test_no_video_thumbnail_downloaded( # Verify that video1 has no image attached. video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id) - self.assertIsNone(video1_image_url) + self.assertIsNone(video1_image_url) # noqa: PT009 # Scrape video thumbnail. scrape_youtube_thumbnail(course_id, video1_edx_video_id, 'test-yt-id') @@ -367,7 +366,7 @@ def test_no_video_thumbnail_downloaded( # Verify that no image is attached to video1. video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id) - self.assertIsNone(video1_image_url) + self.assertIsNone(video1_image_url) # noqa: PT009 @ddt.ddt @@ -388,7 +387,7 @@ def order_dict(self, dictionary): return dictionary def test_video_backend(self): - self.assertEqual( + self.assertEqual( # noqa: PT009 S3Boto3Storage, import_string( 'storages.backends.s3boto3.S3Boto3Storage', @@ -405,7 +404,7 @@ def test_boto3_backend_with_params(self): settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_CLASS', {}) )(**settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_KWARGS', {})) - self.assertEqual(S3Boto3Storage, storage.__class__) + self.assertEqual(S3Boto3Storage, storage.__class__) # noqa: PT009 def test_storage_without_global_default_acl_setting(self): """ diff --git a/cms/djangoapps/contentstore/tests/test_xblock_handler_permissions.py b/cms/djangoapps/contentstore/tests/test_xblock_handler_permissions.py new file mode 100644 index 000000000000..cc7b53ca0991 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_xblock_handler_permissions.py @@ -0,0 +1,470 @@ +""" +Tests verifying that xblock_handler enforces the correct permissions. +""" +from unittest.mock import patch + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from openedx.core import toggles as core_toggles +from xmodule.modulestore.tests.factories import BlockFactory + + +class XBlockHandlerPermissionsTest(CourseTestCase): + """ + Tests for xblock_storage_handlers.view_handlers.handle_xblock. + + Verifies legacy permission enforcement (staff vs non-staff). + """ + + def setUp(self): + super().setUp() + self.chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + self.sequential = BlockFactory.create(category='sequential', parent_location=self.chapter.location) + self.vertical = BlockFactory.create(category='vertical', parent_location=self.sequential.location) + self.html_block = BlockFactory.create(category='html', parent_location=self.vertical.location) + self.static_tab = BlockFactory.create(category='static_tab', parent_location=self.course.location) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() + + # --- GET /xblock/{blockId} --- + + def test_get_block_fields_staff_allowed(self): + self.assertEqual(self.client.get_json(f'/xblock/{self.html_block.location}').status_code, 200) # noqa: PT009 + + def test_get_block_fields_non_staff_forbidden(self): + self.assertEqual(self.non_staff_client.get_json(f'/xblock/{self.html_block.location}').status_code, 403) # noqa: PT009 # pylint: disable=line-too-long + + # --- POST /xblock/{blockId} metadata --- + + def test_post_metadata_staff_allowed(self): + resp = self.client.ajax_post( + f'/xblock/{self.html_block.location}', data={'metadata': {'display_name': 'New Name'}} + ) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + def test_post_metadata_non_staff_forbidden(self): + resp = self.non_staff_client.ajax_post( + f'/xblock/{self.html_block.location}', data={'metadata': {'display_name': 'New Name'}} + ) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # --- POST /xblock/{blockId} publish --- + + def test_publish_staff_allowed(self): + resp = self.client.ajax_post(f'/xblock/{self.vertical.location}', data={'publish': 'make_public'}) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + def test_publish_non_staff_forbidden(self): + resp = self.non_staff_client.ajax_post(f'/xblock/{self.vertical.location}', data={'publish': 'make_public'}) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # --- DELETE /xblock/{blockId} --- + + def test_delete_block_staff_allowed(self): + resp = self.client.delete(f'/xblock/{self.html_block.location}', HTTP_ACCEPT='application/json') + self.assertEqual(resp.status_code, 204) # noqa: PT009 + + def test_delete_block_non_staff_forbidden(self): + resp = self.non_staff_client.delete(f'/xblock/{self.html_block.location}', HTTP_ACCEPT='application/json') + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # --- POST /xblock/ (create/duplicate) --- + + def test_post_duplicate_staff_allowed(self): + data = { + 'duplicate_source_locator': str(self.html_block.location), + 'parent_locator': str(self.vertical.location), + } + self.assertEqual(self.client.ajax_post('/xblock/', data=data).status_code, 200) # noqa: PT009 + + def test_post_duplicate_non_staff_forbidden(self): + data = { + 'duplicate_source_locator': str(self.html_block.location), + 'parent_locator': str(self.vertical.location), + } + self.assertEqual(self.non_staff_client.ajax_post('/xblock/', data=data).status_code, 403) # noqa: PT009 + + def test_post_add_component_staff_allowed(self): + data = {'category': 'html', 'parent_locator': str(self.vertical.location)} + self.assertEqual(self.client.ajax_post('/xblock/', data=data).status_code, 200) # noqa: PT009 + + def test_post_add_component_non_staff_forbidden(self): + data = {'category': 'html', 'parent_locator': str(self.vertical.location)} + self.assertEqual(self.non_staff_client.ajax_post('/xblock/', data=data).status_code, 403) # noqa: PT009 + + # --- PUT /xblock/{blockId} (reorder) --- + + def test_put_reorder_staff_allowed(self): + data={'children': [str(self.html_block.location)]} + resp = self.client.put( + f'/xblock/{self.vertical.location}', data=data, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + def test_put_reorder_non_staff_forbidden(self): + data={'children': [str(self.html_block.location)]} + resp = self.non_staff_client.put( + f'/xblock/{self.vertical.location}', data=data, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # --- PATCH /xblock/ (move) --- + + def test_patch_move_component_staff_allowed(self): + vertical2 = BlockFactory.create(category='vertical', parent_location=self.sequential.location) + data={ + 'move_source_locator': str(self.html_block.location), + 'parent_locator': str(vertical2.location), + } + resp = self.client.patch( + '/xblock/', data=data, content_type='application/json', HTTP_ACCEPT='application/json', + ) + self.assertNotEqual(resp.status_code, 403) # noqa: PT009 + + def test_patch_move_component_non_staff_forbidden(self): + data={ + 'move_source_locator': str(self.html_block.location), + 'parent_locator': str(self.vertical.location), + } + resp = self.non_staff_client.patch( + '/xblock/', data=data, content_type='application/json', HTTP_ACCEPT='application/json', + ) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # --- static_tab and course_info --- + + def test_put_update_custom_page_staff_allowed(self): + data={'metadata': {'display_name': 'Updated Page'}} + resp = self.client.put( + f'/xblock/{self.static_tab.location}', data=data, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + def test_put_update_custom_page_non_staff_forbidden(self): + data={'metadata': {'display_name': 'Updated Page'}} + resp = self.non_staff_client.put( + f'/xblock/{self.static_tab.location}', data=data, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + def test_delete_custom_page_staff_allowed(self): + resp = self.client.delete(f'/xblock/{self.static_tab.location}', HTTP_ACCEPT='application/json') + self.assertEqual(resp.status_code, 204) # noqa: PT009 + + def test_delete_custom_page_non_staff_forbidden(self): + resp = self.non_staff_client.delete(f'/xblock/{self.static_tab.location}', HTTP_ACCEPT='application/json') + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + def test_post_static_tab_content_staff_allowed(self): + resp = self.client.ajax_post( + f'/xblock/{self.static_tab.location}', data={'data': '

Content

', 'metadata': {'display_name': 'Page'}} + ) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + def test_post_static_tab_content_non_staff_forbidden(self): + resp = self.non_staff_client.ajax_post( + f'/xblock/{self.static_tab.location}', data={'data': '

Content

', 'metadata': {'display_name': 'Page'}} + ) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + def test_get_handouts_staff_allowed(self): + handouts = BlockFactory.create(category='course_info', parent_location=self.course.location) + self.assertEqual(self.client.get_json(f'/xblock/{handouts.location}').status_code, 200) # noqa: PT009 + + def test_get_handouts_non_staff_forbidden(self): + handouts = BlockFactory.create(category='course_info', parent_location=self.course.location) + self.assertEqual(self.non_staff_client.get_json(f'/xblock/{handouts.location}').status_code, 403) # noqa: PT009 + + +@patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.authz_api.is_user_allowed', return_value=True) +@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True) +class XBlockHandlerAuthzPermissionsTest(CourseTestCase): + """ + Tests for authz-based permission checks in xblock_handler. + + Verifies that when AUTHZ_COURSE_AUTHORING_FLAG is enabled, the handler + uses granular authz permissions instead of legacy permission checks. + """ + + def setUp(self): + super().setUp() + self.chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + self.sequential = BlockFactory.create(category='sequential', parent_location=self.chapter.location) + self.vertical = BlockFactory.create(category='vertical', parent_location=self.sequential.location) + self.html_block = BlockFactory.create(category='html', parent_location=self.vertical.location) + self.static_tab = BlockFactory.create(category='static_tab', parent_location=self.course.location) + self.course_info = BlockFactory.create(category='course_info', parent_location=self.course.location) + + # --- GET /xblock/{blockId} --- + + def test_get_regular_block_checks_view_course(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """GET on regular block should check courses.view_course permission""" + self.client.get_json(f'/xblock/{self.html_block.location}') + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.view_course', + str(self.course.id) + ) + + def test_get_course_info_checks_view_course_updates(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """GET on course_info block should check courses.view_course_updates permission""" + self.client.get_json(f'/xblock/{self.course_info.location}') + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.view_course_updates', + str(self.course.id) + ) + + def test_get_static_tab_checks_view_course(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """GET on static_tab should check courses.view_course""" + self.client.get_json(f'/xblock/{self.static_tab.location}') + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.view_course', + str(self.course.id) + ) + + # --- POST /xblock/{blockId} metadata --- + + def test_post_regular_block_checks_edit_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST on regular block without publish should check courses.edit_course_content""" + self.client.ajax_post(f'/xblock/{self.html_block.location}', data={'metadata': {'display_name': 'New'}}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + def test_post_with_publish_none_and_metadata_checks_edit(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish=None + metadata should check courses.edit_course_content""" + self.client.ajax_post( + f'/xblock/{self.vertical.location}', + data={'publish': None, 'metadata': {'visible_to_staff_only': True}} + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + # --- POST /xblock/{blockId} publish --- + + def test_post_with_publish_checks_publish_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish='make_public' should check courses.publish_course_content""" + self.client.ajax_post(f'/xblock/{self.vertical.location}', data={'publish': 'make_public'}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.publish_course_content', + str(self.course.id) + ) + + def test_post_discard_changes_checks_publish(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish='discard_changes' should check courses.publish_course_content""" + self.client.ajax_post(f'/xblock/{self.vertical.location}', data={'publish': 'discard_changes'}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.publish_course_content', + str(self.course.id) + ) + + def test_post_republish_without_changes_checks_publish(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish='republish' and no content changes should check courses.publish_course_content""" + self.client.ajax_post(f'/xblock/{self.vertical.location}', data={'publish': 'republish'}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.publish_course_content', + str(self.course.id) + ) + + def test_post_make_public_with_content_changes_checks_edit(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish='make_public' + metadata should check courses.edit_course_content""" + self.client.ajax_post( + f'/xblock/{self.vertical.location}', + data={'publish': 'make_public', 'metadata': {'display_name': 'New'}} + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + def test_post_republish_with_metadata_checks_edit(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish='republish' + metadata changes should check courses.edit_course_content""" + self.client.ajax_post( + f'/xblock/{self.chapter.location}', + data={'publish': 'republish', 'metadata': {'highlights': ['Week 1']}} + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + def test_post_republish_with_grader_type_checks_edit(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST with publish='republish' + graderType should check courses.edit_course_content""" + self.client.ajax_post( + f'/xblock/{self.sequential.location}', + data={'publish': 'republish', 'graderType': 'Homework', 'prereqMinScore': 100} + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + # --- DELETE /xblock/{blockId} --- + + def test_delete_regular_block_checks_edit_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """DELETE on regular block should check courses.edit_course_content""" + self.client.delete(f'/xblock/{self.html_block.location}', HTTP_ACCEPT='application/json') + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + def test_delete_static_tab_checks_manage_pages_and_resources(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """DELETE on static_tab should check courses.manage_pages_and_resources""" + self.client.delete(f'/xblock/{self.static_tab.location}', HTTP_ACCEPT='application/json') + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.manage_pages_and_resources', + str(self.course.id) + ) + + # --- POST /xblock/ (create/duplicate) --- + + def test_create_block_checks_edit_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST /xblock/ to create block should check courses.edit_course_content""" + self.client.ajax_post('/xblock/', data={'category': 'html', 'parent_locator': str(self.vertical.location)}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + def test_create_static_tab_checks_manage_pages_and_resources(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """PUT /xblock/ to create static_tab should check courses.manage_pages_and_resources""" + self.client.put( + '/xblock/', + data={'category': 'static_tab', 'parent_locator': str(self.course.location)}, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.manage_pages_and_resources', + str(self.course.id) + ) + + def test_duplicate_block_checks_edit_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST /xblock/ to duplicate should check courses.edit_course_content""" + self.client.ajax_post( + '/xblock/', + data={ + 'duplicate_source_locator': str(self.html_block.location), + 'parent_locator': str(self.vertical.location), + } + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + # --- PUT /xblock/{blockId} (reorder) --- + + def test_put_reorder_checks_edit_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """PUT on regular block (reorder children) should check courses.edit_course_content""" + self.client.put( + f'/xblock/{self.vertical.location}', + data={'children': [str(self.html_block.location)]}, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + # --- PATCH /xblock/ (move) --- + + def test_move_block_checks_edit_course_content(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """PATCH /xblock/ to move should check courses.edit_course_content""" + vertical2 = BlockFactory.create(category='vertical', parent_location=self.sequential.location) + self.client.patch( + '/xblock/', + data={ + 'move_source_locator': str(self.html_block.location), + 'parent_locator': str(vertical2.location), + }, + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.edit_course_content', + str(self.course.id) + ) + + # --- static_tab and course_info --- + + def test_post_static_tab_checks_manage_pages_and_resources(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST on static_tab should check courses.manage_pages_and_resources""" + self.client.ajax_post(f'/xblock/{self.static_tab.location}', data={'metadata': {'display_name': 'Updated'}}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.manage_pages_and_resources', + str(self.course.id) + ) + + def test_put_static_tab_checks_manage_pages_and_resources(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """PUT on static_tab should check courses.manage_pages_and_resources""" + self.client.put( + f'/xblock/{self.static_tab.location}', + data={'metadata': {'display_name': 'Updated'}}, + content_type='application/json', HTTP_ACCEPT='application/json', + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.manage_pages_and_resources', + str(self.course.id) + ) + + def test_post_course_info_checks_manage_course_updates(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """POST on course_info block should check courses.manage_course_updates""" + self.client.ajax_post(f'/xblock/{self.course_info.location}', data={'data': '

Updated

'}) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.manage_course_updates', + str(self.course.id) + ) + + def test_put_course_info_checks_manage_course_updates(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """PUT on course_info should check courses.manage_course_updates""" + self.client.put( + f'/xblock/{self.course_info.location}', + data={'data': '

Updated

'}, + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + mock_is_allowed.assert_called_with( + self.user.username, + 'courses.manage_course_updates', + str(self.course.id) + ) + + # --- authz flag behavior --- + + def test_authz_denied_raises_permission_denied(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """When authz denies permission, PermissionDenied should be raised""" + mock_is_allowed.return_value = False + response = self.client.get_json(f'/xblock/{self.html_block.location}') + self.assertEqual(response.status_code, 403) # noqa: PT009 + + def test_authz_flag_disabled_uses_legacy_permissions(self, _mock_flag, mock_is_allowed): # noqa: PT019 + """When authz flag is disabled, should use legacy permission checks""" + with patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=False): + self.client.get_json(f'/xblock/{self.html_block.location}') + mock_is_allowed.assert_not_called() diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index d151b1d58526..67558b9e5826 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -2,31 +2,28 @@ This test file will test registration, login, activation, and session activity timeouts TODO: Rewrite several of these assertions so that they check the output of the REST or Python -APIs rather than parsing HTML from the deprecated legacy frontend pages. In particular, any -test case using override_waffle_flag(toggles.LEGACY_STUDIO_*, True) will need to be fixed. -Part of https://github.com/openedx/edx-platform/issues/36275. +APIs rather than parsing HTML from the deprecated legacy frontend pages. """ import datetime import time -from unittest import mock -from urllib.parse import quote_plus, unquote +from urllib.parse import unquote from ddt import data, ddt, unpack from django.conf import settings from django.core.cache import cache from django.test.utils import override_settings from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag from pytz import UTC -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json, registration, user from cms.djangoapps.contentstore.utils import get_studio_home_url -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, # pylint: disable=wrong-import-order +) +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order class ContentStoreTestCase(ModuleStoreTestCase): @@ -46,7 +43,7 @@ def _login(self, email, password): def login(self, email, password): """Login, check that it worked.""" resp = self._login(email, password) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 return resp def _create_account(self, username, email, password): @@ -67,12 +64,12 @@ def _create_account(self, username, email, password): def create_account(self, username, email, password): """Create the account and check that it worked""" resp = self._create_account(username, email, password) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 json_data = parse_json(resp) - self.assertEqual(json_data['success'], True) + self.assertEqual(json_data['success'], True) # noqa: PT009 # Check both that the user is created, and inactive - self.assertFalse(user(email).is_active) + self.assertFalse(user(email).is_active) # noqa: PT009 return resp @@ -87,9 +84,9 @@ def _activate_user(self, email): def activate_user(self, email): resp = self._activate_user(email) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Now make sure that the user is now actually activated - self.assertTrue(user(email).is_active) + self.assertTrue(user(email).is_active) # noqa: PT009 @ddt @@ -177,32 +174,6 @@ def test_inactive_session_timeout(self): # re-request, and we should get a redirect to login page self.assertRedirects(resp, settings.LOGIN_URL + '?next=/home/', target_status_code=302) - @data( - (True, 'assertContains'), - (False, 'assertNotContains')) - @unpack - @override_waffle_flag(toggles.LEGACY_STUDIO_LOGGED_OUT_HOME, True) - def test_signin_and_signup_buttons_index_page(self, allow_account_creation, assertion_method_name): - """ - Navigate to the home page and check the Sign Up button is hidden when ALLOW_PUBLIC_ACCOUNT_CREATION flag - is turned off, and not when it is turned on. The Sign In button should always appear. - """ - with mock.patch.dict(settings.FEATURES, {"ALLOW_PUBLIC_ACCOUNT_CREATION": allow_account_creation}): - response = self.client.get(reverse('homepage')) - assertion_method = getattr(self, assertion_method_name) - login_url = quote_plus(f"http://testserver{settings.LOGIN_URL}") - assertion_method( - response, - f'' - ) - self.assertContains( - response, - '' - ) - - class ForumTestCase(CourseTestCase): """Tests class to verify course to forum operations""" @@ -224,31 +195,31 @@ def test_blackouts(self): (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30)) ] self.set_blackout_dates(times1) - self.assertTrue(self.course.forum_posts_allowed) + self.assertTrue(self.course.forum_posts_allowed) # noqa: PT009 times2 = [ (now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30)) ] self.set_blackout_dates(times2) - self.assertFalse(self.course.forum_posts_allowed) + self.assertFalse(self.course.forum_posts_allowed) # noqa: PT009 # Single date set for allowed forum posts. self.course.discussion_blackouts = [ now + datetime.timedelta(days=24), now + datetime.timedelta(days=30) ] - self.assertTrue(self.course.forum_posts_allowed) + self.assertTrue(self.course.forum_posts_allowed) # noqa: PT009 # Single date set for restricted forum posts. self.course.discussion_blackouts = [ now - datetime.timedelta(days=24), now + datetime.timedelta(days=30) ] - self.assertFalse(self.course.forum_posts_allowed) + self.assertFalse(self.course.forum_posts_allowed) # noqa: PT009 # test if user gives empty blackout date it should return true for forum_posts_allowed self.course.discussion_blackouts = [[]] - self.assertTrue(self.course.forum_posts_allowed) + self.assertTrue(self.course.forum_posts_allowed) # noqa: PT009 @ddt @@ -262,20 +233,19 @@ def setUp(self): super().setUp() self.course = CourseFactory.create(org='edX', number='test_course_key', display_name='Test Course') - @data(('edX/test_course_key/Test_Course', 200), ('garbage:edX+test_course_key+Test_Course', 404)) + @data(('edX/test_course_key/Test_Course', 302, 200), ('garbage:edX+test_course_key+Test_Course', 404, 404)) @unpack - @override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True) - def test_course_key_decorator(self, course_key, status_code): + def test_course_key_decorator(self, course_key, import_status_code, import_status_handler_code): """ Tests for the ensure_valid_course_key decorator. """ url = f'/import/{course_key}' resp = self.client.get_html(url) - self.assertEqual(resp.status_code, status_code) + self.assertEqual(resp.status_code, import_status_code) # noqa: PT009 url = '/import_status/{course_key}/{filename}'.format( course_key=course_key, filename='xyz.tar.gz' ) resp = self.client.get_html(url) - self.assertEqual(resp.status_code, status_code) + self.assertEqual(resp.status_code, import_status_handler_code) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 42f57563c292..4b5e33fb65ec 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -6,20 +6,19 @@ import json from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.models import User # pylint: disable=imported-auth-user from django.test.client import Client from opaque_keys.edx.keys import AssetKey + +from cms.djangoapps.contentstore.utils import reverse_url +from common.djangoapps.student.models import Registration +from openedx.core.djangoapps.video_config.tests.test_transcripts_utils import YoutubeVideoHTMLResponse from xmodule.contentstore.django import contentstore from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.utils import ProceduralCourseTestMixin -from openedx.core.djangoapps.video_config.tests.test_transcripts_utils import YoutubeVideoHTMLResponse - -from cms.djangoapps.contentstore.utils import reverse_url -from common.djangoapps.student.models import Registration - TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -131,12 +130,12 @@ def assertCoursesEqual(self, course1_id, course2_id): """ course1_items = self.store.get_items(course1_id) course2_items = self.store.get_items(course2_id) - self.assertGreater(len(course1_items), 0) # ensure it found content instead of [] == [] + self.assertGreater(len(course1_items), 0) # ensure it found content instead of [] == [] # noqa: PT009 if len(course1_items) != len(course2_items): course1_block_ids = {item.location.block_id for item in course1_items} course2_block_ids = {item.location.block_id for item in course2_items} raise AssertionError( - "Course1 extra blocks: {}; course2 extra blocks: {}".format( + "Course1 extra blocks: {}; course2 extra blocks: {}".format( # noqa: UP032 course1_block_ids - course2_block_ids, course2_block_ids - course1_block_ids ) ) @@ -152,15 +151,15 @@ def assertCoursesEqual(self, course1_id, course2_id): course2_item = self.store.get_item(course2_item_loc) # compare published state - self.assertEqual( + self.assertEqual( # noqa: PT009 self.store.has_published_version(course1_item), self.store.has_published_version(course2_item) ) # compare data - self.assertEqual(hasattr(course1_item, 'data'), hasattr(course2_item, 'data')) + self.assertEqual(hasattr(course1_item, 'data'), hasattr(course2_item, 'data')) # noqa: PT009 if hasattr(course1_item, 'data'): - self.assertEqual(course1_item.data, course2_item.data) + self.assertEqual(course1_item.data, course2_item.data) # noqa: PT009 # compare meta-data course1_metadata = own_metadata(course1_item) @@ -168,23 +167,23 @@ def assertCoursesEqual(self, course1_id, course2_id): # Omit edx_video_id as it can be different in case of extrnal video imports. course1_metadata.pop('edx_video_id', None) course2_metadata.pop('edx_video_id', None) - self.assertEqual(course1_metadata, course2_metadata) + self.assertEqual(course1_metadata, course2_metadata) # noqa: PT009 # compare children - self.assertEqual(course1_item.has_children, course2_item.has_children) + self.assertEqual(course1_item.has_children, course2_item.has_children) # noqa: PT009 if course1_item.has_children: expected_children = [] for course1_item_child in course1_item.children: expected_children.append( course2_id.make_usage_key(course1_item_child.block_type, course1_item_child.block_id) ) - self.assertEqual(expected_children, course2_item.children) + self.assertEqual(expected_children, course2_item.children) # noqa: PT009 # compare assets content_store = self.store.contentstore course1_assets, count_course1_assets = content_store.get_all_content_for_course(course1_id) _, count_course2_assets = content_store.get_all_content_for_course(course2_id) - self.assertEqual(count_course1_assets, count_course2_assets) + self.assertEqual(count_course1_assets, count_course2_assets) # noqa: PT009 for asset in course1_assets: asset_son = asset.get('content_son', asset['_id']) self.assertAssetsEqual(asset_son, course1_id, course2_id) @@ -192,10 +191,10 @@ def assertCoursesEqual(self, course1_id, course2_id): def check_verticals(self, items): """ Test getting the editing HTML for each vertical. """ # assert is here to make sure that the course being tested actually has verticals (units) to check. - self.assertGreater(len(items), 0, "Course has no verticals (units) to check") + self.assertGreater(len(items), 0, "Course has no verticals (units) to check") # noqa: PT009 for block in items: resp = self.client.get_html(get_url('container_handler', block.location)) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def assertAssetsEqual(self, asset_son, course1_id, course2_id): """Verifies the asset of the given key has the same attributes in both given courses.""" @@ -204,12 +203,12 @@ def assertAssetsEqual(self, asset_son, course1_id, course2_id): filename = asset_son.block_id if hasattr(asset_son, 'block_id') else asset_son['name'] course1_asset_attrs = content_store.get_attrs(course1_id.make_asset_key(category, filename)) course2_asset_attrs = content_store.get_attrs(course2_id.make_asset_key(category, filename)) - self.assertEqual(len(course1_asset_attrs), len(course2_asset_attrs)) + self.assertEqual(len(course1_asset_attrs), len(course2_asset_attrs)) # noqa: PT009 for key, value in course1_asset_attrs.items(): if key in ['_id', 'filename', 'uploadDate', 'content_son', 'thumbnail_location']: pass else: - self.assertEqual(value, course2_asset_attrs[key]) + self.assertEqual(value, course2_asset_attrs[key]) # noqa: PT009 class HTTPGetResponse: diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 5462a28bae4b..94960ba66ec0 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -2,10 +2,10 @@ CMS feature toggles. """ from edx_toggles.toggles import SettingToggle, WaffleFlag + from openedx.core.djangoapps.content.search import api as search_api from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag - # .. toggle_name: ENABLE_EXPORT_GIT # .. toggle_implementation: SettingToggle # .. toggle_default: False @@ -86,23 +86,21 @@ def exam_setting_view_enabled(course_key): return not LEGACY_STUDIO_EXAM_SETTINGS.is_enabled(course_key) -# .. toggle_name: legacy_studio.video_editor +# .. toggle_name: legacy_studio.pdf_editor # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Video component (a.k.a. video block) editor. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_VIDEO_EDITOR = CourseWaffleFlag('legacy_studio.video_editor', __name__) +# .. toggle_description: Use the PDF XBlock's studio_view instead of the new React-based editor. You may wish to do +# this if you run a custom PDF block. +# .. toggle_use_cases: opt_out +# .. toggle_creation_date: 2026-03-10 +LEGACY_STUDIO_PDF_EDITOR = WaffleFlag('legacy_studio.pdf_editor', __name__) -def use_new_video_editor(course_key): +def use_new_pdf_editor(): """ Returns a boolean = true if new video editor is enabled """ - return not LEGACY_STUDIO_VIDEO_EDITOR.is_enabled(course_key) + return not LEGACY_STUDIO_PDF_EDITOR.is_enabled() # .. toggle_name: new_core_editors.use_video_gallery_flow @@ -112,7 +110,7 @@ def use_new_video_editor(course_key): # .. toggle_use_cases: temporary # .. toggle_creation_date: 2023-04-03 # .. toggle_target_removal_date: 2023-6-01 -# .. toggle_warning: You need to activate the `new_core_editors.use_new_video_editor` flag to use this new flow. +# .. toggle_warning: This controls the new core video xblock editor flow. ENABLE_VIDEO_GALLERY_FLOW_FLAG = WaffleFlag('new_core_editors.use_video_gallery_flow', __name__) @@ -161,117 +159,25 @@ def use_react_markdown_editor(course_key): return ENABLE_REACT_MARKDOWN_EDITOR.is_enabled(course_key) -# .. toggle_name: legacy_studio.schedule_details -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Schedule & Details page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_SCHEDULE_DETAILS = CourseWaffleFlag('legacy_studio.schedule_details', __name__) - - -def use_new_schedule_details_page(course_key): - """ - Returns a boolean if new studio schedule and details mfe is enabled - """ - return not LEGACY_STUDIO_SCHEDULE_DETAILS.is_enabled(course_key) - - -# .. toggle_name: legacy_studio.advanced_settings -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Advanced Settings page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_ADVANCED_SETTINGS = CourseWaffleFlag('legacy_studio.advanced_settings', __name__) - - -def use_new_advanced_settings_page(course_key): - """ - Returns a boolean if new studio advanced settings pafe mfe is enabled - """ - return not LEGACY_STUDIO_ADVANCED_SETTINGS.is_enabled(course_key) - - -# .. toggle_name: legacy_studio.grading -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Course Grading page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_GRADING = CourseWaffleFlag('legacy_studio.grading', __name__) - - -def use_new_grading_page(course_key): - """ - Returns a boolean if new studio grading mfe is enabled - """ - return not LEGACY_STUDIO_GRADING.is_enabled(course_key) - - -# .. toggle_name: legacy_studio.import -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Course Import page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_IMPORT = CourseWaffleFlag('legacy_studio.import', __name__) - - -def use_new_import_page(course_key): - """ - Returns a boolean if new studio import mfe is enabled - """ - return not LEGACY_STUDIO_IMPORT.is_enabled(course_key) - - -# .. toggle_name: legacy_studio.export -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Course Export page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_EXPORT = CourseWaffleFlag('legacy_studio.export', __name__) - - -def use_new_export_page(course_key): - """ - Returns a boolean if new studio export mfe is enabled - """ - return not LEGACY_STUDIO_EXPORT.is_enabled(course_key) - - # .. toggle_name: contentstore.new_studio_mfe.use_new_video_uploads_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio video uploads page mfe -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: +# .. toggle_description: This flag enables the use of the new studio video uploads page MFE. +# Note: This page only works on edx.org or other sites that have reverse-engineered +# the edX video pipeline. It is off by default for the community. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2023-05-15 +# .. toggle_tickets: https://github.com/openedx/openedx-platform/issues/37972 ENABLE_NEW_STUDIO_VIDEO_UPLOADS_PAGE = CourseWaffleFlag( f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_video_uploads_page', __name__) def use_new_video_uploads_page(course_key): """ - Returns a boolean if new studio video uploads mfe is enabled + Returns a boolean if new studio video uploads MFE is enabled. + + This is off by default because the video uploads page requires the edX + video pipeline which is not available to the open source community. """ return ENABLE_NEW_STUDIO_VIDEO_UPLOADS_PAGE.is_enabled(course_key) @@ -307,63 +213,6 @@ def use_new_unit_page(course_key): return not LEGACY_STUDIO_UNIT_EDITOR.is_enabled(course_key) -# .. toggle_name: legacy_studio.course_team -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Course Team page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_COURSE_TEAM = CourseWaffleFlag('legacy_studio.course_team', __name__) - - -def use_new_course_team_page(course_key): - """ - Returns a boolean if new studio course team mfe is enabled - """ - return not LEGACY_STUDIO_COURSE_TEAM.is_enabled(course_key) - - -# .. toggle_name: legacy_studio.certificates -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Course Certificates page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_CERTIFICATES = CourseWaffleFlag('legacy_studio.certificates', __name__) - - -def use_new_certificates_page(course_key): - """ - Returns a boolean if new studio certificates mfe is enabled - """ - return not LEGACY_STUDIO_CERTIFICATES.is_enabled(course_key) - - -# .. toggle_name: legacy_studio.configurations -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Configurations page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_CONFIGURATIONS = CourseWaffleFlag('legacy_studio.configurations', __name__) - - -def use_new_group_configurations_page(course_key): - """ - Returns a boolean if new studio group configurations mfe is enabled - """ - return not LEGACY_STUDIO_CONFIGURATIONS.is_enabled(course_key) - - # .. toggle_name: contentstore.mock_video_uploads # .. toggle_implementation: WaffleFlag # .. toggle_default: False @@ -499,28 +348,6 @@ def enable_course_optimizer(course_id): return ENABLE_COURSE_OPTIMIZER.is_enabled(course_id) -# .. toggle_name: legacy_studio.logged_out_home -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio "How it Works" page when unauthenticated -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed, along with the legacy page. The only available -# behavior will be to send the user to the log-in page with a redirect to Studio Course Listing (/home). -LEGACY_STUDIO_LOGGED_OUT_HOME = WaffleFlag('legacy_studio.logged_out_home', __name__) - - -def use_legacy_logged_out_home(): - """ - Returns whether the old "how it works" page should be shown. - - If not, then we should just go to the login page w/ redirect to studio course listing. - """ - return LEGACY_STUDIO_LOGGED_OUT_HOME.is_enabled() - - # .. toggle_name: contentstore.enable_course_optimizer_check_prev_run_links # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False diff --git a/cms/djangoapps/contentstore/transcript_storage_handlers.py b/cms/djangoapps/contentstore/transcript_storage_handlers.py index 2c254eb359b3..0c8dc37faa0e 100644 --- a/cms/djangoapps/contentstore/transcript_storage_handlers.py +++ b/cms/djangoapps/contentstore/transcript_storage_handlers.py @@ -11,20 +11,22 @@ from django.utils.translation import gettext as _ from edxval.api import ( create_or_update_video_transcript, - delete_video_transcript as delete_video_transcript_source_function, get_3rd_party_transcription_plans, get_available_transcript_languages, + get_video_transcript, get_video_transcript_data, update_transcript_credentials_state_for_org, - get_video_transcript ) +from edxval.api import delete_video_transcript as delete_video_transcript_source_function from opaque_keys.edx.keys import CourseKey +from xblocks_contrib.video.exceptions import TranscriptsGenerationException from common.djangoapps.util.json_request import JsonResponse from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag +from openedx.core.djangoapps.video_config.transcripts_utils import ( + Transcript, # pylint: disable=wrong-import-order +) from openedx.core.djangoapps.video_pipeline.api import update_3rd_party_transcription_service_credentials -from openedx.core.djangoapps.video_config.transcripts_utils import Transcript # lint-amnesty, pylint: disable=wrong-import-order -from xblocks_contrib.video.exceptions import TranscriptsGenerationException from .toggles import use_mock_video_uploads from .video_storage_handlers import TranscriptProvider @@ -62,7 +64,7 @@ def validate_transcript_credentials(provider, **credentials): must_have_props = ['api_key', 'username'] missing = [ - must_have_prop for must_have_prop in must_have_props if must_have_prop not in list(credentials.keys()) # lint-amnesty, pylint: disable=consider-iterating-dictionary + must_have_prop for must_have_prop in must_have_props if must_have_prop not in list(credentials.keys()) # pylint: disable=consider-iterating-dictionary ] if missing: error_message = '{missing} must be specified.'.format(missing=' and '.join(missing)) @@ -231,7 +233,7 @@ def validate_transcript_upload_data(data, files): data['language_code'] != data['new_language_code'] and data['new_language_code'] in get_available_transcript_languages(video_id=data['edx_video_id']) ): - error = _('A transcript with the "{language_code}" language code already exists.'.format( # lint-amnesty, pylint: disable=translation-of-non-string + error = _('A transcript with the "{language_code}" language code already exists.'.format( # pylint: disable=translation-of-non-string language_code=data['new_language_code'] )) elif 'file' not in files: diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 779a1f218a96..4c284fb07efb 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -27,7 +27,6 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey, UsageKeyV2 from opaque_keys.edx.locator import BlockUsageLocator, LibraryContainerLocator, LibraryLocator -from openedx.core.djangoapps.video_config.services import VideoConfigService from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED from openedx_events.learning.data import CourseNotificationData @@ -42,16 +41,7 @@ libraries_v1_enabled, libraries_v2_enabled, split_library_view_on_dashboard, - use_new_advanced_settings_page, - use_new_certificates_page, - use_new_course_team_page, - use_new_export_page, - use_new_grading_page, - use_new_group_configurations_page, - use_new_import_page, - use_new_schedule_details_page, use_new_unit_page, - use_new_video_uploads_page, ) from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_metadata import CourseMetadata @@ -64,11 +54,7 @@ from common.djangoapps.student import auth from common.djangoapps.student.auth import STUDIO_EDIT_ROLES, has_studio_read_access, has_studio_write_access from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import ( - CourseInstructorRole, - CourseStaffRole, - GlobalStaff, -) +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from common.djangoapps.track import contexts from common.djangoapps.util.course import get_link_for_about_page from common.djangoapps.util.date_utils import get_default_time_display @@ -94,6 +80,7 @@ from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.models import SiteConfiguration +from openedx.core.djangoapps.video_config.services import VideoConfigService from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from openedx.core.lib.courses import course_image_url from openedx.core.lib.html_to_text import html_to_text @@ -101,14 +88,14 @@ from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML -from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.course_block import DEFAULT_START_DATE # pylint: disable=wrong-import-order from xmodule.data import CertificatesDisplayBehaviors from xmodule.library_tools import LegacyLibraryToolsService -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order from xmodule.partitions.partitions_service import ( - get_all_partitions_for_course, # lint-amnesty, pylint: disable=wrong-import-order + get_all_partitions_for_course, # pylint: disable=wrong-import-order ) from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService from xmodule.util.keys import BlockKey @@ -184,7 +171,7 @@ def _remove_instructors(course_key): try: remove_all_instructors(course_key) - except Exception as err: # lint-amnesty, pylint: disable=broad-except + except Exception as err: # pylint: disable=broad-except log.error(f"Error in deleting course groups for {course_key}: {err}") @@ -214,7 +201,7 @@ def get_lms_link_for_item(location, preview=False): query_string = urlencode(params) url_parts = list(urlparse(lms_base)) - url_parts[2] = '/courses/{course_key}/jump_to/{location}'.format( + url_parts[2] = '/courses/{course_key}/jump_to/{location}'.format( # noqa: UP032 course_key=str(location.course_key), location=str(location), ) @@ -235,7 +222,7 @@ def get_lms_link_for_certificate_web_view(course_key, mode): if lms_base is None: return None - return "//{certificate_web_base}/certificates/course/{course_id}?preview={mode}".format( + return "//{certificate_web_base}/certificates/course/{course_id}?preview={mode}".format( # noqa: UP032 certificate_web_base=lms_base, course_id=str(course_key), mode=mode @@ -309,52 +296,36 @@ def get_schedule_details_url(course_locator) -> str: """ Gets course authoring microfrontend URL for schedule and details pages view. """ - schedule_details_url = None - if use_new_schedule_details_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/details' - if mfe_base_url: - schedule_details_url = course_mfe_url - return schedule_details_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/details' + return course_mfe_url if mfe_base_url else None -def get_advanced_settings_url(course_locator) -> str: +def get_advanced_settings_url(course_locator) -> str | None: """ Gets course authoring microfrontend URL for advanced settings page view. """ - advanced_settings_url = None - if use_new_advanced_settings_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/advanced' - if mfe_base_url: - advanced_settings_url = course_mfe_url - return advanced_settings_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/advanced' + return course_mfe_url if mfe_base_url else None def get_grading_url(course_locator) -> str: """ Gets course authoring microfrontend URL for grading page view. """ - grading_url = None - if use_new_grading_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/grading' - if mfe_base_url: - grading_url = course_mfe_url - return grading_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/settings/grading' + return course_mfe_url if mfe_base_url else None def get_course_team_url(course_locator) -> str: """ Gets course authoring microfrontend URL for course team page view. """ - course_team_url = None - if use_new_course_team_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/course_team' - if mfe_base_url: - course_team_url = course_mfe_url - return course_team_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/course_team' + return course_mfe_url if mfe_base_url else None def get_updates_url(course_locator) -> str: @@ -369,30 +340,22 @@ def get_updates_url(course_locator) -> str: return updates_url -def get_import_url(course_locator) -> str: +def get_import_url(course_locator) -> str | None: """ Gets course authoring microfrontend URL for import page view. """ - import_url = None - if use_new_import_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/import' - if mfe_base_url: - import_url = course_mfe_url - return import_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/import' + return course_mfe_url if mfe_base_url else None -def get_export_url(course_locator) -> str: +def get_export_url(course_locator) -> str | None: """ Gets course authoring microfrontend URL for export page view. """ - export_url = None - if use_new_export_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/export' - if mfe_base_url: - export_url = course_mfe_url - return export_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/export' + return course_mfe_url if mfe_base_url else None def get_optimizer_url(course_locator) -> str: @@ -425,11 +388,10 @@ def get_video_uploads_url(course_locator) -> str: Gets course authoring microfrontend URL for files and uploads page view. """ video_uploads_url = None - if use_new_video_uploads_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/videos/' - if mfe_base_url: - video_uploads_url = course_mfe_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/videos/' + if mfe_base_url: + video_uploads_url = course_mfe_url return video_uploads_url @@ -476,13 +438,9 @@ def get_certificates_url(course_locator) -> str: """ Gets course authoring microfrontend URL for certificates page view. """ - certificates_url = None - if use_new_certificates_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/certificates' - if mfe_base_url: - certificates_url = course_mfe_url - return certificates_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/certificates' + return course_mfe_url if mfe_base_url else None def get_textbooks_url(course_locator) -> str: @@ -501,13 +459,9 @@ def get_group_configurations_url(course_locator) -> str: """ Gets course authoring microfrontend URL for group configurations page view. """ - group_configurations_url = None - if use_new_group_configurations_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/group_configurations' - if mfe_base_url: - group_configurations_url = course_mfe_url - return group_configurations_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/group_configurations' + return course_mfe_url if mfe_base_url else None def get_custom_pages_url(course_locator) -> str: @@ -549,6 +503,17 @@ def get_taxonomy_list_url() -> str | None: return f'{mfe_base_url}/taxonomies' +def get_libraries_list_url() -> str | None: + """ + Gets course authoring microfrontend URL for libraries list view. + """ + mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL + if not mfe_base_url: + return None + + return f'{mfe_base_url}/libraries' + + def get_taxonomy_tags_widget_url(course_locator=None) -> str | None: """ Gets course authoring microfrontend URL for taxonomy tags drawer widget view. @@ -594,7 +559,7 @@ def is_currently_visible_to_students(xblock): return False # Check start date - if 'detached' not in published._class_tags and published.start is not None: # lint-amnesty, pylint: disable=protected-access + if 'detached' not in published._class_tags and published.start is not None: # pylint: disable=protected-access return datetime.now(UTC) > published.start # No start date, so it's always visible @@ -991,7 +956,7 @@ def get_subsections_in_section(): section_subsections = section.get_children() return section_subsections except AttributeError: - log.error("URL Retrieval Error: subsection {subsection} included in section {section}".format( + log.error("URL Retrieval Error: subsection {subsection} included in section {section}".format( # noqa: UP032 # pylint: disable=line-too-long section=section.location, subsection=subsection.location )) @@ -1005,7 +970,7 @@ def get_sections_in_course(): section_subsections = section.get_parent().get_children() return section_subsections except AttributeError: - log.error("URL Retrieval Error: In section {section} in course".format( + log.error("URL Retrieval Error: In section {section} in course".format( # noqa: UP032 section=section.location, )) return None @@ -1216,7 +1181,7 @@ def duplicate_block( # .. event_implemented_name: XBLOCK_DUPLICATED # .. event_type: org.openedx.content_authoring.xblock.duplicated.v1 XBLOCK_DUPLICATED.send_event( - time=datetime.now(timezone.utc), + time=datetime.now(timezone.utc), # noqa: UP017 xblock_info=DuplicatedXBlockData( usage_key=dest_block.location, block_type=dest_block.location.block_type, @@ -1319,7 +1284,7 @@ def load_services_for_studio(runtime, user): "video_config": VideoConfigService(), } - runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access + runtime._services.update(services) # pylint: disable=protected-access def update_course_details(request, course_key, payload, course_block): @@ -1409,7 +1374,7 @@ def get_course_settings(request, course_key, course_block): It is used for both DRF and django views. """ - from .views.course import get_courses_accessible_to_user, _process_courses_list + from .views.course import _process_courses_list, get_courses_accessible_to_user credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False) upload_asset_url = reverse_course_url('assets_handler', course_key) @@ -1489,7 +1454,7 @@ def get_course_settings(request, course_key, course_block): # if 'minimum_grade_credit' of a course is not set or 0 then # show warning message to course author. - show_min_grade_warning = False if course_block.minimum_grade_credit > 0 else True # lint-amnesty, pylint: disable=simplifiable-if-expression + show_min_grade_warning = False if course_block.minimum_grade_credit > 0 else True # pylint: disable=simplifiable-if-expression settings_context.update( { 'is_credit_course': True, @@ -1585,12 +1550,8 @@ def get_library_context(request, request_is_json=False): get_allowed_organizations_for_libraries, user_can_create_organizations, ) - from cms.djangoapps.contentstore.views.library import ( - user_can_view_create_library_button, - ) - from openedx.core.djangoapps.content_libraries.api import ( - user_can_create_library, - ) + from cms.djangoapps.contentstore.views.library import user_can_view_create_library_button + from openedx.core.djangoapps.content_libraries.api import user_can_create_library is_migrated: bool | None # None means: do not filter on is_migrated if (is_migrated_param := request.GET.get('is_migrated')) is not None: @@ -1645,10 +1606,7 @@ def get_course_context(request): It is used for both DRF and django views. """ - from cms.djangoapps.contentstore.views.course import ( - get_courses_accessible_to_user, - _process_courses_list, - ) + from cms.djangoapps.contentstore.views.course import _process_courses_list, get_courses_accessible_to_user def format_in_process_course_view(uca): """ @@ -1684,9 +1642,7 @@ def get_course_context_v2(request): # Importing here to avoid circular imports: # ImportError: cannot import name 'reverse_course_url' from partially initialized module # 'cms.djangoapps.contentstore.utils' (most likely due to a circular import) - from cms.djangoapps.contentstore.views.course import ( - get_courses_accessible_to_user, - ) + from cms.djangoapps.contentstore.views.course import get_courses_accessible_to_user def format_in_process_course_view(uca): """ @@ -1723,17 +1679,13 @@ def get_home_context(request, no_course=False): """ from cms.djangoapps.contentstore.views.course import ( + _get_course_creator_status, get_allowed_organizations, get_allowed_organizations_for_libraries, user_can_create_organizations, - _get_course_creator_status, - ) - from cms.djangoapps.contentstore.views.library import ( - user_can_view_create_library_button, - ) - from openedx.core.djangoapps.content_libraries.api import ( - user_can_create_library, ) + from cms.djangoapps.contentstore.views.library import user_can_view_create_library_button + from openedx.core.djangoapps.content_libraries.api import user_can_create_library active_courses = [] archived_courses = [] @@ -1808,23 +1760,22 @@ def get_course_videos_context(course_block, pagination_conf, course_key=None): get_transcript_credentials_state_for_org, get_transcript_preferences, ) + from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag from openedx.core.djangoapps.video_config.toggles import use_xpert_translations_component - from openedx.core.djangoapps.video_config.transcripts_utils import Transcript # lint-amnesty, pylint: disable=wrong-import-order - - from .video_storage_handlers import ( - get_all_transcript_languages, - _get_index_videos, - _get_default_video_image_url + from openedx.core.djangoapps.video_config.transcripts_utils import ( + Transcript, # pylint: disable=wrong-import-order ) + from .video_storage_handlers import _get_default_video_image_url, _get_index_videos, get_all_transcript_languages + VIDEO_SUPPORTED_FILE_FORMATS = { '.mp4': 'video/mp4', '.mov': 'video/quicktime', } VIDEO_UPLOAD_MAX_FILE_SIZE_GB = 5 # Waffle switch for enabling/disabling video image upload feature - VIDEO_IMAGE_UPLOAD_ENABLED = WaffleSwitch( # lint-amnesty, pylint: disable=toggle-missing-annotation + VIDEO_IMAGE_UPLOAD_ENABLED = WaffleSwitch( # pylint: disable=toggle-missing-annotation 'videos.video_image_upload_enabled', __name__ ) @@ -1904,9 +1855,9 @@ def _get_course_index_context(request, course_key, course_block): """ from cms.djangoapps.contentstore.views.course import ( - course_outline_initial_state, _course_outline_json, _deprecated_blocks_info, + course_outline_initial_state, ) from openedx.core.djangoapps.content_staging import api as content_staging_api @@ -1959,7 +1910,7 @@ def _get_course_index_context(request, course_key, course_block): 'lms_link': lms_link, 'sections': sections, 'course_structure': course_structure, - 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # lint-amnesty, pylint: disable=line-too-long + 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # pylint: disable=line-too-long 'initial_user_clipboard': user_clipboard, 'rerun_notification_id': current_action.id if current_action else None, 'course_release_date': course_release_date, @@ -1989,13 +1940,13 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint It is used for both DRF and django views. """ + from cms.djangoapps.contentstore.helpers import get_parent_xblock, is_unit from cms.djangoapps.contentstore.views.component import ( - get_component_templates, - get_unit_tags, CONTAINER_TEMPLATES, LIBRARY_BLOCK_TYPES, + get_component_templates, + get_unit_tags, ) - from cms.djangoapps.contentstore.helpers import get_parent_xblock, is_unit from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( add_container_page_publishing_info, create_xblock_info, @@ -2179,12 +2130,13 @@ def get_group_configurations_context(course, store): """ from cms.djangoapps.contentstore.course_group_config import ( - COHORT_SCHEME, ENROLLMENT_SCHEME, GroupConfiguration, RANDOM_SCHEME - ) - from cms.djangoapps.contentstore.views.course import ( - are_content_experiments_enabled + COHORT_SCHEME, + ENROLLMENT_SCHEME, + RANDOM_SCHEME, + GroupConfiguration, ) - from xmodule.partitions.partitions import UserPartition # lint-amnesty, pylint: disable=wrong-import-order + from cms.djangoapps.contentstore.views.course import are_content_experiments_enabled + from xmodule.partitions.partitions import UserPartition # pylint: disable=wrong-import-order course_key = course.id group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) @@ -2433,7 +2385,7 @@ def _create_or_update_container_link(created: datetime | None, xblock): """ upstream_container_key = LibraryContainerLocator.from_string(xblock.upstream) try: - lib_component = get_container(upstream_container_key).container_pk + lib_component = get_container(upstream_container_key).container_id except ObjectDoesNotExist: log.error(f"Library component not found for {upstream_container_key}") lib_component = None diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py index d90dab5dc9e1..ac68ed5fee1d 100644 --- a/cms/djangoapps/contentstore/video_storage_handlers.py +++ b/cms/djangoapps/contentstore/video_storage_handlers.py @@ -9,15 +9,17 @@ import json import logging import os -import requests -import shutil import pathlib +import shutil import zipfile -import boto3 - from contextlib import closing from datetime import datetime, timedelta +from tempfile import NamedTemporaryFile, mkdtemp from uuid import uuid4 +from wsgiref.util import FileWrapper + +import boto3 +import requests from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.http import FileResponse, HttpResponseNotFound, StreamingHttpResponse @@ -32,13 +34,13 @@ create_video, get_3rd_party_transcription_plans, get_available_transcript_languages, - get_video_transcript_url, get_transcript_preferences, + get_video_transcript_url, get_videos_for_course, remove_transcript_preferences, remove_video_for_course, update_video_image, - update_video_status + update_video_status, ) from fs.osfs import OSFS from opaque_keys import InvalidKeyError @@ -46,24 +48,19 @@ from path import Path as path from pytz import UTC from rest_framework import status as rest_status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from tempfile import NamedTemporaryFile, mkdtemp -from wsgiref.util import FileWrapper -from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.util.json_request import JsonResponse from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE -from openedx.core.djangoapps.video_pipeline.config.waffle import ( - DEPRECATE_YOUTUBE, - ENABLE_DEVSTACK_VIDEO_UPLOADS, -) +from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE, ENABLE_DEVSTACK_VIDEO_UPLOADS from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from .models import VideoUploadConfig -from .toggles import use_new_video_uploads_page, use_mock_video_uploads -from .utils import get_video_uploads_url, get_course_videos_context +from .toggles import use_mock_video_uploads +from .utils import get_video_uploads_url from .video_utils import validate_video_image from .views.course import get_course_and_check_access @@ -73,14 +70,14 @@ WAFFLE_NAMESPACE = 'videos' # Waffle switch for enabling/disabling video image upload feature -VIDEO_IMAGE_UPLOAD_ENABLED = WaffleSwitch( # lint-amnesty, pylint: disable=toggle-missing-annotation +VIDEO_IMAGE_UPLOAD_ENABLED = WaffleSwitch( # pylint: disable=toggle-missing-annotation f'{WAFFLE_NAMESPACE}.video_image_upload_enabled', __name__ ) # Waffle flag namespace for studio WAFFLE_STUDIO_FLAG_NAMESPACE = 'studio' -ENABLE_VIDEO_UPLOAD_PAGINATION = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation +ENABLE_VIDEO_UPLOAD_PAGINATION = CourseWaffleFlag( # pylint: disable=toggle-missing-annotation f'{WAFFLE_STUDIO_FLAG_NAMESPACE}.enable_video_upload_pagination', __name__ ) # Default expiration, in seconds, of one-time URLs used for uploading videos. @@ -236,11 +233,34 @@ def send_zip(zip_file, size=None): """ wrapper = FileWrapper(zip_file, settings.COURSE_EXPORT_DOWNLOAD_CHUNK_SIZE) response = StreamingHttpResponse(wrapper, content_type='application/zip') - response['Content-Dispositon'] = 'attachment; filename=%s' % os.path.basename(zip_file.name) + response['Content-Dispositon'] = 'attachment; filename=%s' % os.path.basename(zip_file.name) # noqa: UP031 response['Content-Length'] = size return response +def get_course_video_download_urls(course_key_string): + """ + Return the set of encoded-video URLs that legitimately belong to the given + course, as recorded in VAL. + + The video download endpoint only ever needs to fetch URLs that were already + surfaced to the client by the video listing. Restricting fetches to this set + prevents server-side request forgery (SSRF) via attacker-supplied URLs. + """ + videos, __ = get_videos_for_course( + course_key_string, + VideoSortField.created, + SortDirection.desc, + None, + ) + return { + encoding['url'] + for video in videos + for encoding in video['encoded_videos'] + if encoding.get('url') + } + + def create_video_zip(course_key_string, files): """ Generates the video zip, or returns None if there was an error. @@ -249,10 +269,17 @@ def create_video_zip(course_key_string, files): """ name = course_key_string + '_videos' video_folder_zip = NamedTemporaryFile(prefix=name + '_', - suffix=".zip") # lint-amnesty, pylint: disable=consider-using-with + suffix=".zip") # pylint: disable=consider-using-with root_dir = path(mkdtemp()) video_dir = root_dir + '/' + name zip_folder = None + # Only allow fetching URLs that belong to this course's videos. Anything + # else (internal services, cloud metadata endpoints, arbitrary hosts) is a + # potential SSRF target and is rejected before any request is made. + allowed_urls = get_course_video_download_urls(course_key_string) + for file in files: + if file['url'] not in allowed_urls: + raise ValidationError(f"Invalid video download url: {file['url']}") try: for file in files: url = file['url'] @@ -389,7 +416,7 @@ def validate_transcript_preferences(provider, cielo24_fidelity, cielo24_turnarou # validate transcription providers transcription_plans = get_3rd_party_transcription_plans() - if provider in list(transcription_plans.keys()): # lint-amnesty, pylint: disable=consider-iterating-dictionary + if provider in list(transcription_plans.keys()): # pylint: disable=consider-iterating-dictionary # Further validations for providers if provider == TranscriptProvider.CIELO24: @@ -740,13 +767,7 @@ def videos_index_html(course, pagination_conf=None): """ Returns an HTML page to display previous video uploads and allow new ones """ - if use_new_video_uploads_page(course.id): - return redirect(get_video_uploads_url(course.id)) - context = get_course_videos_context( - course, - pagination_conf, - ) - return render_to_response('videos_index.html', context) + return redirect(get_video_uploads_url(course.id)) def videos_index_json(course): @@ -821,7 +842,7 @@ def videos_post(course, request): try: file_name.encode('ascii') except UnicodeEncodeError: - error_msg = 'The file name for %s must contain only ASCII characters.' % file_name + error_msg = 'The file name for %s must contain only ASCII characters.' % file_name # noqa: UP031 return {'error': error_msg}, 400 edx_video_id = str(uuid4()) @@ -971,10 +992,10 @@ def get_course_youtube_edx_video_ids(course_id): """ Get a list of youtube edx_video_ids """ - invalid_key_error_msg = "Invalid course_key: '%s'." % course_id - unexpected_error_msg = "Unexpected error occurred for course_id: '%s'." % course_id + invalid_key_error_msg = "Invalid course_key: '%s'." % course_id # noqa: UP031 + unexpected_error_msg = "Unexpected error occurred for course_id: '%s'." % course_id # noqa: UP031 - try: # lint-amnesty, pylint: disable=too-many-nested-blocks + try: # pylint: disable=too-many-nested-blocks course_key = CourseKey.from_string(course_id) course = modulestore().get_course(course_key) diff --git a/cms/djangoapps/contentstore/video_utils.py b/cms/djangoapps/contentstore/video_utils.py index 4681b2d98595..f60cdbb31df9 100644 --- a/cms/djangoapps/contentstore/video_utils.py +++ b/cms/djangoapps/contentstore/video_utils.py @@ -87,7 +87,7 @@ def download_youtube_video_thumbnail(youtube_id): thumbnail_content = thumbnail_content_type = None # Download highest resolution thumbnail available. for thumbnail_quality in YOUTUBE_THUMBNAIL_SIZES: - thumbnail_url = urljoin('https://img.youtube.com', '/vi/{youtube_id}/{thumbnail_quality}.jpg'.format( + thumbnail_url = urljoin('https://img.youtube.com', '/vi/{youtube_id}/{thumbnail_quality}.jpg'.format( # noqa: UP032 # pylint: disable=line-too-long youtube_id=youtube_id, thumbnail_quality=thumbnail_quality )) response = requests.get(thumbnail_url) @@ -115,7 +115,7 @@ def validate_and_update_video_image(course_key_string, edx_video_id, image_file, update_video_image(edx_video_id, course_key_string, image_file, image_filename) LOGGER.info( - 'VIDEOS: Scraping youtube video thumbnail for edx_video_id [%s] in course [%s]', edx_video_id, course_key_string # lint-amnesty, pylint: disable=line-too-long + 'VIDEOS: Scraping youtube video thumbnail for edx_video_id [%s] in course [%s]', edx_video_id, course_key_string # pylint: disable=line-too-long ) diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index ba87c54d38c0..95f176180b8d 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -1,25 +1,25 @@ "All view functions for contentstore, broken out into submodules" -from .assets import * -from .checklists import * -from .component import * -from .course import * # lint-amnesty, pylint: disable=redefined-builtin -from .entrance_exam import * -from .error import * -from .export_git import * -from .helpers import * -from .import_export import * -from .block import * -from .library import * -from .preview import * -from .public import * -from .tabs import * -from .transcript_settings import * -from .transcripts_ajax import * -from .user import * -from .videos import * +from .assets import * # noqa: F403 +from .block import * # noqa: F403 +from .checklists import * # noqa: F403 +from .component import * # noqa: F403 +from .course import * # pylint: disable=redefined-builtin # noqa: F403 +from .entrance_exam import * # noqa: F403 +from .error import * # noqa: F403 +from .export_git import * # noqa: F403 +from .helpers import * # noqa: F403 +from .import_export import * # noqa: F403 +from .library import * # noqa: F403 +from .preview import * # noqa: F403 +from .public import * # noqa: F403 +from .tabs import * # noqa: F403 +from .transcript_settings import * # noqa: F403 +from .transcripts_ajax import * # noqa: F403 +from .user import * # noqa: F403 +from .videos import * # noqa: F403 try: - from .dev import * + from .dev import * # noqa: F403 except ImportError: pass diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index c9ae16a1402c..beb378e8a759 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -3,15 +3,14 @@ from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import ensure_csrf_cookie + +from cms.djangoapps.contentstore.asset_storage_handlers import delete_asset as delete_asset_source_function +from cms.djangoapps.contentstore.asset_storage_handlers import get_asset_json as get_asset_json_source_function +from cms.djangoapps.contentstore.asset_storage_handlers import get_asset_usage_path_json, handle_assets +from cms.djangoapps.contentstore.asset_storage_handlers import get_file_size as get_file_size_source_function +from cms.djangoapps.contentstore.asset_storage_handlers import update_asset as update_asset_source_function from cms.djangoapps.contentstore.asset_storage_handlers import ( - handle_assets, - get_asset_usage_path_json, update_course_run_asset as update_course_run_asset_source_function, - get_file_size as get_file_size_source_function, - delete_asset as delete_asset_source_function, - get_asset_json as get_asset_json_source_function, - update_asset as update_asset_source_function, - ) __all__ = ['assets_handler', 'asset_usage_path_handler'] diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index b57042085df2..b2c0e39df77c 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -12,54 +12,38 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from web_fragments.fragment import Fragment from cms.djangoapps.contentstore.utils import load_services_for_studio +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( + create_xblock_info, + delete_orphans, + get_block_info, + get_xblock, + handle_xblock, +) +from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_tags_count, usage_key_with_run from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string -from common.djangoapps.student.auth import ( - has_studio_read_access, - has_studio_write_access, -) +from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access from common.djangoapps.util.json_request import JsonResponse, expect_json -from openedx.core.lib.xblock_utils import ( - hash_resource, - request_token, - wrap_xblock, - wrap_xblock_aside, -) -from xmodule.modulestore.django import ( - modulestore, -) # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled - -from xmodule.x_module import ( +from openedx.core.lib.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.x_module import ( # pylint: disable=wrong-import-order AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, STUDIO_VIEW, -) # lint-amnesty, pylint: disable=wrong-import-order - - -from ..helpers import ( - is_unit, -) -from .preview import get_preview_fragment -from .component import _get_item_in_course -from ..utils import get_container_handler_context - -from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( - handle_xblock, - create_xblock_info, - get_block_info, - get_xblock, - delete_orphans, -) -from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import ( - usage_key_with_run, - get_tags_count, ) +from ..helpers import is_unit +from ..utils import get_container_handler_context +from .component import _get_item_in_course +from .preview import get_preview_fragment __all__ = [ "orphan_handler", @@ -348,7 +332,12 @@ def xblock_outline_handler(request, usage_key_string): a course. """ usage_key = usage_key_with_run(usage_key_string) - if not has_studio_read_access(request.user, usage_key.course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + usage_key.course_key, + LegacyAuthoringPermission.READ, + ): raise PermissionDenied() response_format = request.GET.get("format", "html") diff --git a/cms/djangoapps/contentstore/views/certificate_manager.py b/cms/djangoapps/contentstore/views/certificate_manager.py index 081afdcc0dd7..0c36762f8967 100644 --- a/cms/djangoapps/contentstore/views/certificate_manager.py +++ b/cms/djangoapps/contentstore/views/certificate_manager.py @@ -6,16 +6,15 @@ from django.conf import settings from django.utils.translation import gettext as _ - -from common.djangoapps.course_modes.models import CourseMode from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import AssetKey -from .assets import delete_asset +from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id from ..exceptions import AssetNotFoundException +from .assets import delete_asset CERTIFICATE_SCHEMA_VERSION = 1 CERTIFICATE_MINIMUM_ID = 100 @@ -27,14 +26,14 @@ class CertificateException(Exception): """ Base exception for Certificates workflows """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass class CertificateValidationError(CertificateException): """ An exception raised when certificate information is invalid. """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass def _delete_asset(course_key, asset_key_string): @@ -89,7 +88,7 @@ def parse(json_string): try: certificate = json.loads(json_string) except ValueError: - raise CertificateValidationError(_("invalid JSON")) # lint-amnesty, pylint: disable=raise-missing-from + raise CertificateValidationError(_("invalid JSON")) # pylint: disable=raise-missing-from # noqa: B904 # Include the data contract version certificate["version"] = CERTIFICATE_SCHEMA_VERSION # Ensure a signatories list is always returned @@ -156,7 +155,7 @@ def assign_id(course, certificate_data, certificate_id=None): used_ids ) - for index, signatory in enumerate(certificate_data['signatories']): # pylint: disable=unused-variable + for index, signatory in enumerate(certificate_data['signatories']): # pylint: disable=unused-variable # noqa: B007 if signatory and not signatory.get('id', False): signatory['id'] = generate_int_id(used_ids=used_ids) used_ids.append(signatory['id']) @@ -252,7 +251,7 @@ def remove_certificate(request, store, course, certificate_id): if int(cert['id']) == int(certificate_id): certificate = course.certificates['certificates'][index] # Remove any signatory assets prior to dropping the entire cert record from the course - for sig_index, signatory in enumerate(certificate.get('signatories')): # pylint: disable=unused-variable + for sig_index, signatory in enumerate(certificate.get('signatories')): # pylint: disable=unused-variable # noqa: B007 _delete_asset(course.id, signatory['signature_image_path']) # Now drop the certificate record course.certificates['certificates'].pop(index) @@ -265,7 +264,7 @@ def remove_signatory(request, store, course, certificate_id, signatory_id): """ Remove the specified signatory from the provided course certificate """ - for cert_index, cert in enumerate(course.certificates['certificates']): # pylint: disable=unused-variable + for cert_index, cert in enumerate(course.certificates['certificates']): # pylint: disable=unused-variable # noqa: B007 if int(cert['id']) == int(certificate_id): for sig_index, signatory in enumerate(cert.get('signatories')): if int(signatory_id) == int(signatory['id']): diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index 50039cf00d08..08da2de77d39 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -29,35 +29,27 @@ from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.shortcuts import redirect -from django.utils.translation import gettext as _ from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods -from rest_framework.views import APIView +from opaque_keys.edx.keys import CourseKey +from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status -from opaque_keys.edx.keys import CourseKey +from rest_framework.views import APIView -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin -from common.djangoapps.edxmako.shortcuts import render_to_response +from cms.djangoapps.contentstore.views.permissions import HasStudioWriteAccess +from cms.djangoapps.contentstore.views.serializers import CertificateActivationSerializer, CertificateSerializer from common.djangoapps.student.auth import has_studio_write_access from common.djangoapps.student.roles import GlobalStaff - from common.djangoapps.util.json_request import JsonResponse -from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order - -from cms.djangoapps.contentstore.views.serializers import CertificateActivationSerializer, CertificateSerializer -from cms.djangoapps.contentstore.views.permissions import HasStudioWriteAccess +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin +from xmodule.modulestore import EdxJSONEncoder # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from ..utils import get_certificates_url, reverse_course_url from .certificate_manager import CertificateManager, CertificateValidationError -from ..toggles import use_new_certificates_page -from ..utils import ( - get_certificates_context, - get_certificates_url, - reverse_course_url, -) CERTIFICATE_MINIMUM_ID = 100 @@ -149,10 +141,7 @@ def certificates_list_handler(request, course_key_string): return JsonResponse({"error": msg}, status=403) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): - if use_new_certificates_page(course_key): - return redirect(get_certificates_url(course_key)) - certificates_context = get_certificates_context(course, request.user) - return render_to_response('certificates.html', certificates_context) + return redirect(get_certificates_url(course_key)) elif "application/json" in request.META.get('HTTP_ACCEPT'): # Retrieve the list of certificates for the specified course if request.method == 'GET': @@ -298,7 +287,7 @@ def signatory_detail_handler(request, course_key_string, certificate_id, signato match_cert = None # pylint: disable=unused-variable - for index, cert in enumerate(certificates_list): + for index, cert in enumerate(certificates_list): # noqa: B007 if certificate_id is not None: if int(cert['id']) == int(certificate_id): match_cert = cert diff --git a/cms/djangoapps/contentstore/views/checklists.py b/cms/djangoapps/contentstore/views/checklists.py index eb5c651a3da3..fa8e4856e2d4 100644 --- a/cms/djangoapps/contentstore/views/checklists.py +++ b/cms/djangoapps/contentstore/views/checklists.py @@ -1,4 +1,4 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index fbd29a89d1b7..c30eb49b9529 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -21,26 +21,18 @@ from xblock.plugin import PluginMissingError from xblock.runtime import Mixologist +from cms.djangoapps.contentstore.helpers import get_parent_if_split_test, is_library_content, is_unit +from cms.djangoapps.contentstore.toggles import libraries_v1_enabled, libraries_v2_enabled, use_new_unit_page +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag -from cms.djangoapps.contentstore.helpers import ( - get_parent_if_split_test, - is_unit, - is_library_content, -) -from cms.djangoapps.contentstore.toggles import ( - libraries_v1_enabled, - libraries_v2_enabled, - use_new_unit_page, -) -from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio -from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.content_tagging.api import get_object_tags -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order __all__ = [ 'container_handler', @@ -63,7 +55,7 @@ 'drag-and-drop-v2', ] -BETA_COMPONENT_TYPES = ['library_v2', 'itembank'] +BETA_COMPONENT_TYPES = [] ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES)) @@ -154,7 +146,7 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st try: usage_key = UsageKey.from_string(usage_key_string) except InvalidKeyError: # Raise Http404 on invalid 'usage_key_string' - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 with modulestore().bulk_operations(usage_key.course_key): try: course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key) @@ -202,13 +194,13 @@ def container_embed_handler(request, usage_key_string): # pylint: disable=too-m try: course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key) except ItemNotFoundError: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 container_handler_context = get_container_handler_context(request, usage_key, course, xblock) return render_to_response('container_chromeless.html', container_handler_context) -def get_component_templates(courselike, library=False): # lint-amnesty, pylint: disable=too-many-statements +def get_component_templates(courselike, library=False): # pylint: disable=too-many-statements """ Returns the applicable component templates that can be used by the specified course or library. """ @@ -315,7 +307,7 @@ def create_support_legend_dict(): # Content Libraries currently don't allow opting in to unsupported xblocks/problem types. allow_unsupported = getattr(courselike, "allow_unsupported_xblocks", False) - for category in component_types: # lint-amnesty, pylint: disable=too-many-nested-blocks + for category in component_types: # pylint: disable=too-many-nested-blocks authorable_variations = authorable_xblocks(allow_unsupported=allow_unsupported, name=category) support_level_without_template = component_support_level(authorable_variations, category) templates_for_category = [] @@ -358,7 +350,7 @@ def create_support_legend_dict(): templates_for_category.append( create_template_dict( - _(template['metadata'].get('display_name')), # lint-amnesty, pylint: disable=translation-of-non-string + _(template['metadata'].get('display_name')), # pylint: disable=translation-of-non-string category, support_level_with_template, template_id, @@ -588,7 +580,7 @@ def component_handler(request, usage_key_string, handler, suffix=''): resp = handler_block.handle(handler, req, suffix) except NoSuchHandlerError: log.info("XBlock %s attempted to access missing handler %r", handler_block, handler, exc_info=True) - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 # unintentional update to handle any side effects of handle call # could potentially be updating actual course data or simply caching its values @@ -614,7 +606,7 @@ def get_unit_tags(usage_key): Get the tags of a Unit and build a json to be read by the UI Note: When migrating the `TagList` subview from `container_subview.js` to the course-authoring MFE, - this function can be simplified to use the REST API of openedx-learning, + this function can be simplified to use the REST API of openedx_tagging, which already provides this grouping + sorting logic. """ # Get content tags from content tagging API diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index bf2cd9cb83d6..569871b95bea 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -7,19 +7,15 @@ import random import re import string -from typing import Dict +from typing import Dict # noqa: UP035 import django.utils from ccx_keys.locator import CCXLocator from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required -from django.core.exceptions import ( - FieldError, - ImproperlyConfigured, - PermissionDenied, - ValidationError as DjangoValidationError, -) +from django.core.exceptions import FieldError, ImproperlyConfigured, PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import QuerySet from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import redirect @@ -27,91 +23,94 @@ from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_GET, require_http_methods -from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiRequest, OpenApiResponse +from drf_spectacular.utils import OpenApiParameter, OpenApiRequest, OpenApiResponse, extend_schema from edx_django_utils.monitoring import function_trace from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator +from openedx_authz.api import get_scopes_for_user_and_permission +from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, ScopeData +from openedx_authz.constants.permissions import ( + COURSES_MANAGE_COURSE_UPDATES, + COURSES_MANAGE_GROUP_CONFIGURATIONS, + COURSES_MANAGE_PAGES_AND_RESOURCES, + COURSES_PUBLISH_COURSE_CONTENT, + COURSES_VIEW_COURSE, + COURSES_VIEW_COURSE_UPDATES, + COURSES_VIEW_PAGES_AND_RESOURCES, +) from organizations.api import add_organization_course, ensure_organization from organizations.exceptions import InvalidOrganizationException -from rest_framework.exceptions import ValidationError +from organizations.models import Organization from rest_framework.decorators import api_view -from openedx.core.lib.api.view_utils import view_auth_classes +from rest_framework.exceptions import ValidationError +from cms.djangoapps.contentstore.api.views.utils import get_bool_param from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info -from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status from cms.djangoapps.course_creators.models import CourseCreator +from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_metadata import CourseMetadata from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration -from cms.djangoapps.contentstore.api.views.utils import get_bool_param from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import ( - has_course_author_access, + has_studio_advanced_settings_access, has_studio_read_access, has_studio_write_access, - has_studio_advanced_settings_access, is_content_creator, ) from common.djangoapps.student.roles import ( CourseInstructorRole, CourseStaffRole, GlobalStaff, - UserBasedRole, OrgStaffRole, + UserBasedRole, strict_role_checking, ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json from common.djangoapps.util.string_utils import _has_non_ascii_characters +from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.djangoapps.models.course_details import CourseDetails -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json +from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.lib.course_tabs import CourseTabPluginManager -from organizations.models import Organization -from xmodule.course_block import CourseBlock, CourseFields # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.error_block import ErrorBlock # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException # lint-amnesty, pylint: disable=wrong-import-order - -from ..course_group_config import ( - COHORT_SCHEME, - RANDOM_SCHEME, - GroupConfiguration, - GroupConfigurationsValidationError +from xmodule.course_block import CourseBlock, CourseFields # pylint: disable=wrong-import-order +from xmodule.error_block import ErrorBlock # pylint: disable=wrong-import-order +from xmodule.modulestore import EdxJSONEncoder # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import DuplicateCourseError # pylint: disable=wrong-import-order +from xmodule.tabs import ( # pylint: disable=wrong-import-order + CourseTab, + CourseTabList, + InvalidTabsException, ) + +from ..course_group_config import COHORT_SCHEME, RANDOM_SCHEME, GroupConfiguration, GroupConfigurationsValidationError from ..course_info_model import delete_course_update, get_course_updates, update_course_updates from ..courseware_index import CoursewareSearchIndexer, SearchIndexingError from ..tasks import rerun_course as rerun_course_task from ..toggles import ( default_enable_flexible_peer_openassessments, - use_new_advanced_settings_page, - use_new_grading_page, - use_new_group_configurations_page, - use_new_schedule_details_page ) from ..utils import ( add_instructor, get_advanced_settings_url, - get_course_grading, get_course_outline_url, get_course_rerun_context, - get_course_settings, get_grading_url, - get_group_configurations_context, get_group_configurations_url, get_lms_link_for_item, - get_proctored_exam_settings_url, get_schedule_details_url, get_studio_home_url, - get_updates_url, get_textbooks_url, + get_updates_url, initialize_permissions, remove_all_instructors, reverse_course_url, @@ -144,25 +143,51 @@ class AccessListFallback(Exception): An exception that is raised whenever we need to `fall back` to fetching *all* courses available to a user, rather than using a shorter method (i.e. fetching by group) """ - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass -def get_course_and_check_access(course_key, user, depth=0): +def _get_course_block(course_key, depth=0): """ Function used to calculate and return the locator and course block for the view functions in this file. """ - if not has_studio_read_access(user, course_key): - raise PermissionDenied() course_block = modulestore().get_course(course_key, depth=depth) return course_block +def get_course_and_check_access(course_key, user, depth=0): + """ + Function used to validate permission and return a course block + for the view functions in this file. + """ + if not has_studio_read_access(user, course_key): + raise PermissionDenied() + return _get_course_block(course_key, depth) + +def get_course_and_check_manage_group_configurations_access(course_key, user, depth=0): + """ + Function used to validate permission and return a course block + for the view functions for group configurations in this file. + """ + if not user_has_course_permission( + user=user, + authz_permission=COURSES_MANAGE_GROUP_CONFIGURATIONS.identifier, + course_key=course_key, + legacy_permission=LegacyAuthoringPermission.READ + ): + raise PermissionDenied() + return _get_course_block(course_key, depth) + def reindex_course_and_check_access(course_key, user): """ Internal method used to restart indexing on a course. """ - if not has_course_author_access(user, course_key): + if not user_has_course_permission( + user=user, + authz_permission=COURSES_PUBLISH_COURSE_CONTENT.identifier, + course_key=course_key, + legacy_permission=LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key) @@ -305,7 +330,7 @@ def course_handler(request, course_key_string=None): else: return HttpResponseNotFound() except InvalidKeyError: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 @login_required @@ -338,10 +363,13 @@ def course_search_index_handler(request, course_key_string): html: return status of indexing task json: return status of indexing task """ - # Only global staff (PMs) are able to index courses - if not GlobalStaff().has_user(request.user): - raise PermissionDenied() course_key = CourseKey.from_string(course_key_string) + is_authz_enabled = core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key) + if not is_authz_enabled and not GlobalStaff().has_user(request.user): + # When AuthZ is disabled, restrict to global staff (legacy behavior). + # When AuthZ is enabled, access control is enforced by the AuthZ layer, + # which includes staff/superuser checks and course-level permissions. + raise PermissionDenied() content_type = request.META.get('CONTENT_TYPE', None) if content_type is None: content_type = "application/json; charset=utf-8" @@ -368,7 +396,7 @@ def _course_outline_json(request, course_block): return create_xblock_info( course_block, include_child_info=True, - course_outline=False if is_concise else True, # lint-amnesty, pylint: disable=simplifiable-if-expression + course_outline=False if is_concise else True, # pylint: disable=simplifiable-if-expression include_children_predicate=include_children_predicate, is_concise=is_concise, user=request.user @@ -385,7 +413,9 @@ def get_in_process_course_actions(request): exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True, ) - if has_studio_read_access(request.user, course.course_key) + if user_has_course_permission( + request.user, COURSES_VIEW_COURSE.identifier, course.course_key, LegacyAuthoringPermission.READ + ) ] @@ -750,26 +780,214 @@ def course_index(request, course_key): return redirect(get_course_outline_url(course_key, block_to_show)) +def _apply_course_query_filters(request, courses): + """Applies all query filters to the given courses queryset. + This includes filtering by active/archived status, search query, ordering + and any special filters (e.g. CCX courses, template courses). The filters are applied in the following order: + 1. Active/archived status + 2. Search query + 3. Ordering + 4. Special filters (e.g. CCX courses, template courses) + The first 3 filters are applied using queryset methods, while the last filter is applied using a Python filter + function since it involves checking the course type (i.e. if it's a CCX course or a template course). + """ + + def filter_course(course): + """ + Special filters + """ + # CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard. + include_course = not isinstance(course.id, CCXLocator) + + # TODO remove this condition when templates purged from db + include_course = include_course and course.location.course != 'templates' + + return include_course + + search_query, order, active_only, archived_only = get_query_params_if_present(request) + + filtered_courses = get_filtered_and_ordered_courses( + courses, + active_only, + archived_only, + search_query, + order, + ) + return filter(filter_course, filtered_courses) + + +def _get_course_keys_for_org_scope(org_keys: set[str]): + """ + Convert a set of organization keys into specific course keys. + """ + + return CourseOverview.get_all_courses(orgs=org_keys).values_list('id', flat=True) + +def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]): + """ + Convert a set of Authz scopes into specific course keys. + """ + course_keys = set() + org_keys = set() + for access in authz_scopes: + if isinstance(access, CourseOverviewData) and access.course_key: + if core_toggles.enable_authz_course_authoring(access.course_key): + course_keys.add(access.course_key) + elif isinstance(access, OrgCourseOverviewGlobData) and access.org: + org_keys.add(access.org) + if org_keys: + course_keys.update( + key for key in _get_course_keys_for_org_scope(org_keys) + if core_toggles.enable_authz_course_authoring(key) + ) + return course_keys + +def _get_authz_accessible_courses_list(request): + """ + List all courses available to the logged in user by + evaluating Authz scopes for course access. + """ + user = request.user + authz_scopes = get_scopes_for_user_and_permission( + user.username, + COURSES_VIEW_COURSE.identifier + ) + + return _get_course_keys_from_scopes(authz_scopes) + +def _get_legacy_accessible_courses_list(request): + """ + List all courses available to the logged in user by + evaluating legacy Django group roles and organization-level access. + """ + user = request.user + instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role() + + with strict_role_checking(): + staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role() + + group_keys = set() + org_accesses = set() + legacy_accesses = instructor_courses | staff_courses + + for access in legacy_accesses: + if access.course_id is not None: + course_key = access.course_id + if not isinstance(course_key, CourseKey): + course_key = CourseKey.from_string(str(course_key)) + group_keys.add(course_key) + elif access.org: + org_accesses.add(access.org) + else: + # No course_id or org is associated with this access. + raise AccessListFallback + + if org_accesses: + # Getting courses from user global orgs + org_course_keys = CourseOverview.get_all_courses(orgs=org_accesses).values_list("id", flat=True) + group_keys.update(org_course_keys) + return group_keys + + +def _get_candidate_course_keys(request): + """ + Resolve accessible course keys by merging Authz scope evaluation with + legacy permission checks. + + Why merge Authz and legacy checks? + At the time of implementation, the system is in a transition phase where + both Authz scopes and legacy permission checks are required to determine + course access. Combining both approaches allows us to leverage the + efficiency of Authz scopes while still capturing access granted through + legacy mechanisms. + + This produces a comprehensive and performant set of candidate course keys, + combining: + + - Authz scopes: + Collects course keys from the user's scopes for the + `COURSES_VIEW_COURSE` permission. + + - Legacy access: + Collects course keys based on Django group roles + (`CourseInstructorRole`, `CourseStaffRole`) and + organization-level access. If the user has organization-level access, + all courses within those organizations are included. + """ + # Collecting all course keys from authz scopes + authz_keys = _get_authz_accessible_courses_list(request) + + # Collecting all course keys from django groups and org access + group_keys = _get_legacy_accessible_courses_list(request) + + return authz_keys | group_keys + @function_trace('get_courses_accessible_to_user') def get_courses_accessible_to_user(request): """ - Try to get all courses by first reversing django groups and fallback to old method if it fails - Note: overhead of pymongo reads will increase if getting courses from django groups fails + Return courses accessible to the user using a hybrid AuthZ + legacy approach. - Arguments: - request: the request object + Flow: + 1. Determine candidate course keys: + - Staff: all courses (full scan). + - Non-staff: derived from AuthZ scopes and legacy access. + + 2. Single-pass access evaluation: + - Use AuthZ or legacy checks per course (based on feature flags). + - Collect only accessible course keys. + + 3. Batch fetch courses: + - Retrieve all valid courses in one query (ordered by creation date). + + 4. Apply request-based filters. + + Returns: + tuple: + - list[CourseOverview]: Accessible courses. + - list: In-process course actions (staff only). """ - if GlobalStaff().has_user(request.user): - # user has global access so no need to get courses from django groups - courses, in_process_course_actions = _accessible_courses_summary_iter(request) + user = request.user + is_staff_user = GlobalStaff().has_user(user) or user.is_superuser + in_process_actions = [] + + # Step 1: Determine candidate keys + if is_staff_user: + # Unavoidable full scan + # however, we only fetch the course keys here for the access check, + # and defer fetching the full course objects until after filtering by access + candidate_keys = CourseOverview.get_all_courses().values_list("id", flat=True) + # Compute actions once for staff users since they have access to all courses + in_process_actions = get_in_process_course_actions(request) else: + # For non-staff users, we can get a more targeted list of candidate course keys + # by combining AuthZ scopes and legacy access. + # Why? Because non-staff users typically have access to a smaller subset of courses, + # so this can significantly reduce the number of courses we need to check for access + # in the next step. try: - courses, in_process_course_actions = _accessible_courses_list_from_groups(request) + candidate_keys = _get_candidate_course_keys(request) except AccessListFallback: - # user have some old groups or there was some error getting courses from django groups + # This exception is raised when we cannot determine candidate course keys from legacy access. + # User have some old groups or there was some error getting courses from django groups # so fallback to iterating through all courses - courses, in_process_course_actions = _accessible_courses_summary_iter(request) - return courses, in_process_course_actions + candidate_keys = CourseOverview.get_all_courses().values_list("id", flat=True) + in_process_actions = get_in_process_course_actions(request) + + # Step 2: Single-pass decision → collect valid keys + valid_course_keys = set(candidate_keys) + + if not valid_course_keys: + return [], in_process_actions + + # Step 3: Batch fetch valid courses with a single query, ordered by creation date + courses = CourseOverview.get_all_courses( + filter_={'id__in': list(valid_course_keys)} + ).order_by('created') # default ordering is by created date + + # Step 4: Apply filters (e.g. search, active/archived status, ordering) + courses = _apply_course_query_filters(request, courses) + + return courses, in_process_actions def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False): @@ -937,7 +1155,7 @@ def _create_or_rerun_course(request): return JsonResponse({ "ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format(name=display_name, err=str(error))} ) - except PermissionDenied as error: # pylint: disable=unused-variable + except PermissionDenied as error: # pylint: disable=unused-variable # noqa: F841 log.info( "User does not have the permission to create course in this organization" "or course creation is disabled." @@ -963,7 +1181,7 @@ def create_new_course(user, org, number, run, fields): try: org_data = ensure_organization(org) except InvalidOrganizationException: - raise ValidationError(_( # lint-amnesty, pylint: disable=raise-missing-from + raise ValidationError(_( # pylint: disable=raise-missing-from # noqa: B904 'You must link this course to an organization in order to continue. Organization ' 'you selected does not exist in the system, you will need to add it to the system' )) @@ -1072,7 +1290,7 @@ def course_info_handler(request, course_key_string): try: course_key = CourseKey.from_string(course_key_string) except InvalidKeyError: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 return redirect(get_updates_url(course_key)) @@ -1100,8 +1318,14 @@ def course_info_update_handler(request, course_key_string, provided_id=None): if provided_id == '': provided_id = None - # check that logged in user has permissions to this item (GET shouldn't require this level?) - if not has_studio_write_access(request.user, usage_key.course_key): + if request.method == 'GET': + authz_perm = COURSES_VIEW_COURSE_UPDATES.identifier + legacy_perm = LegacyAuthoringPermission.READ + else: + authz_perm = COURSES_MANAGE_COURSE_UPDATES.identifier + legacy_perm = LegacyAuthoringPermission.WRITE + + if not user_has_course_permission(request.user, authz_perm, usage_key.course_key, legacy_perm): raise PermissionDenied() if request.method == 'GET': @@ -1113,7 +1337,7 @@ def course_info_update_handler(request, course_key_string, provided_id=None): elif request.method == 'DELETE': try: return JsonResponse(delete_course_update(usage_key, request.json, provided_id, request.user)) - except: # lint-amnesty, pylint: disable=bare-except + except: # pylint: disable=bare-except return HttpResponseBadRequest( "Failed to delete", content_type="text/plain" @@ -1124,7 +1348,7 @@ def course_info_update_handler(request, course_key_string, provided_id=None): return JsonResponse(update_course_updates( usage_key, request.json, provided_id, request.user, request.method )) - except: # lint-amnesty, pylint: disable=bare-except + except: # pylint: disable=bare-except return HttpResponseBadRequest( "Failed to save", content_type="text/plain" @@ -1135,7 +1359,7 @@ def course_info_update_handler(request, course_key_string, provided_id=None): @ensure_csrf_cookie @require_http_methods(("GET", "PUT", "POST")) @expect_json -def settings_handler(request, course_key_string): # lint-amnesty, pylint: disable=too-many-statements +def settings_handler(request, course_key_string): # pylint: disable=too-many-statements """ Course settings for dates and about pages GET @@ -1149,10 +1373,7 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab with modulestore().bulk_operations(course_key): course_block = get_course_and_check_access(course_key, request.user) if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': - if use_new_schedule_details_page(course_key): - return redirect(get_schedule_details_url(course_key)) - settings_context = get_course_settings(request, course_key, course_block) - return render_to_response('settings.html', settings_context) + return redirect(get_schedule_details_url(course_key)) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): # pylint: disable=too-many-nested-blocks if request.method == 'GET': course_details = CourseDetails.fetch(course_key) @@ -1192,10 +1413,7 @@ def grading_handler(request, course_key_string, grader_index=None): raise PermissionDenied() if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': - if use_new_grading_page(course_key): - return redirect(get_grading_url(course_key)) - grading_context = get_course_grading(course_key) - return render_to_response('settings_graders.html', grading_context) + return redirect(get_grading_url(course_key)) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): if request.method == 'GET': if grader_index is None: @@ -1289,27 +1507,10 @@ def advanced_settings_handler(request, course_key_string): advanced_dict.get('mobile_available')['deprecated'] = True if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': - if use_new_advanced_settings_page(course_key): - return redirect(get_advanced_settings_url(course_key)) - publisher_enabled = configuration_helpers.get_value_for_org( - course_block.location.org, - 'ENABLE_PUBLISHER', - settings.FEATURES.get('ENABLE_PUBLISHER', False) - ) - # gather any errors in the currently stored proctoring settings. - proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user) - - return render_to_response('settings_advanced.html', { - 'context_course': course_block, - 'advanced_dict': advanced_dict, - 'advanced_settings_url': reverse_course_url('advanced_settings_handler', course_key), - 'publisher_enabled': publisher_enabled, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id), - 'proctoring_errors': proctoring_errors, - }) + return redirect(get_advanced_settings_url(course_key)) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): if request.method == 'GET': - return JsonResponse(CourseMetadata.fetch(course_block)) + return JsonResponse(advanced_dict) else: try: return JsonResponse( @@ -1319,7 +1520,7 @@ def advanced_settings_handler(request, course_key_string): return JsonResponseBadRequest(err.detail) -def update_course_advanced_settings(course_block: CourseBlock, data: Dict, user: User) -> Dict: +def update_course_advanced_settings(course_block: CourseBlock, data: Dict, user: User) -> Dict: # noqa: UP006 """ Helper function to update course advanced settings from API data. @@ -1371,7 +1572,7 @@ def update_course_advanced_settings(course_block: CourseBlock, data: Dict, user: class TextbookValidationError(Exception): "An error thrown when a textbook input is invalid" - pass # lint-amnesty, pylint: disable=unnecessary-pass + pass # pylint: disable=unnecessary-pass def validate_textbooks_json(text): @@ -1383,7 +1584,7 @@ def validate_textbooks_json(text): try: textbooks = json.loads(text) except ValueError: - raise TextbookValidationError("invalid JSON") # lint-amnesty, pylint: disable=raise-missing-from + raise TextbookValidationError("invalid JSON") # pylint: disable=raise-missing-from # noqa: B904 if not isinstance(textbooks, (list, tuple)): raise TextbookValidationError("must be JSON list") for textbook in textbooks: @@ -1406,7 +1607,7 @@ def validate_textbook_json(textbook): try: textbook = json.loads(textbook) except ValueError: - raise TextbookValidationError("invalid JSON") # lint-amnesty, pylint: disable=raise-missing-from + raise TextbookValidationError("invalid JSON") # pylint: disable=raise-missing-from # noqa: B904 if not isinstance(textbook, dict): raise TextbookValidationError("must be JSON object") if not textbook.get("tab_title"): @@ -1449,16 +1650,23 @@ def textbooks_list_handler(request, course_key_string): """ course_key = CourseKey.from_string(course_key_string) if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'): - # return HTML page - # We don't need to do an access check here because - # that is done when the endpoint for the actual content of the page. - # This is just to handle redirecting anyone that has bookmarked the old - # textbooks page. + # Legacy HTML bookmark redirect — no data is exposed here. + # Access is enforced when the MFE fetches data from the textbooks API. return redirect(get_textbooks_url(course_key)) + if request.method == 'GET': + authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.READ + else: + authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.WRITE + + if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm): + raise PermissionDenied() + store = modulestore() with store.bulk_operations(course_key): - course = get_course_and_check_access(course_key, request.user) + course = _get_course_block(course_key) # from here on down, we know the client has requested JSON if request.method == 'GET': @@ -1521,9 +1729,20 @@ def textbooks_detail_handler(request, course_key_string, textbook_id): json: remove textbook """ course_key = CourseKey.from_string(course_key_string) + + if request.method == 'GET': + authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.READ + else: + authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.WRITE + + if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm): + raise PermissionDenied() + store = modulestore() with store.bulk_operations(course_key): - course_block = get_course_and_check_access(course_key, request.user) + course_block = _get_course_block(course_key) matching_id = [tb for tb in course_block.pdf_textbooks if str(tb.get("id")) == str(textbook_id)] if matching_id: @@ -1619,13 +1838,10 @@ def group_configurations_list_handler(request, course_key_string): course_key = CourseKey.from_string(course_key_string) store = modulestore() with store.bulk_operations(course_key): - course = get_course_and_check_access(course_key, request.user) + course = get_course_and_check_manage_group_configurations_access(course_key, request.user) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): - if use_new_group_configurations_page(course_key): - return redirect(get_group_configurations_url(course_key)) - group_configurations_context = get_group_configurations_context(course, store) - return render_to_response('group_configurations.html', group_configurations_context) + return redirect(get_group_configurations_url(course_key)) elif "application/json" in request.META.get('HTTP_ACCEPT'): if request.method == 'POST': # create a new group configuration for the course @@ -1662,7 +1878,7 @@ def group_configurations_detail_handler(request, course_key_string, group_config course_key = CourseKey.from_string(course_key_string) store = modulestore() with store.bulk_operations(course_key): - course = get_course_and_check_access(course_key, request.user) + course = get_course_and_check_manage_group_configurations_access(course_key, request.user) matching_id = [p for p in course.user_partitions if str(p.id) == str(group_configuration_id)] if matching_id: @@ -1672,7 +1888,7 @@ def group_configurations_detail_handler(request, course_key_string, group_config if request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: - new_configuration = GroupConfiguration(request.body, course, group_configuration_id).get_user_partition() # lint-amnesty, pylint: disable=line-too-long + new_configuration = GroupConfiguration(request.body, course, group_configuration_id).get_user_partition() # pylint: disable=line-too-long except GroupConfigurationsValidationError as err: return JsonResponse({"error": str(err)}, status=400) @@ -1777,7 +1993,7 @@ def bulk_enable_disable_discussions(request, course_key_string): store.publish(vertical.location, user.id) changed += 1 return JsonResponse({"units_updated_and_republished": changed}) - except Exception as e: # lint-amnesty, pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except log.exception("Exception occurred while enabling/disabling discussion: %s", str(e)) return JsonResponseBadRequest({"error": str(e)}) diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py index 5d914366bd9e..caf35dbdc7db 100644 --- a/cms/djangoapps/contentstore/views/entrance_exam.py +++ b/cms/djangoapps/contentstore/views/entrance_exam.py @@ -15,17 +15,17 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey +from cms.djangoapps.contentstore.xblock_storage_handlers.create_xblock import create_xblock +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import delete_item from cms.djangoapps.models.settings.course_metadata import CourseMetadata from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.util import milestones_helpers from openedx.core import toggles as core_toggles from openedx.core.djangolib.js_utils import dump_js_escaped_json -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order from ..helpers import remove_entrance_exam_graders -from cms.djangoapps.contentstore.xblock_storage_handlers.create_xblock import create_xblock -from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import delete_item __all__ = ['entrance_exam', ] @@ -178,7 +178,7 @@ def _get_entrance_exam(request, course_key): return HttpResponse(status=404) try: exam_block = modulestore().get_item(exam_key) - return HttpResponse( # lint-amnesty, pylint: disable=http-response-with-content-type-json + return HttpResponse( # pylint: disable=http-response-with-content-type-json dump_js_escaped_json({'locator': str(exam_block.location)}), status=200, content_type='application/json') except ItemNotFoundError: @@ -235,7 +235,7 @@ def _delete_entrance_exam(request, course_key): return HttpResponse(status=204) -def add_entrance_exam_milestone(course_id, x_block): # lint-amnesty, pylint: disable=missing-function-docstring +def add_entrance_exam_milestone(course_id, x_block): # pylint: disable=missing-function-docstring # Add an entrance exam milestone if one does not already exist for given xBlock # As this is a standalone method for entrance exam, We should check that given xBlock should be an entrance exam. if x_block.is_entrance_exam: @@ -245,7 +245,7 @@ def add_entrance_exam_milestone(course_id, x_block): # lint-amnesty, pylint: di course_id ) milestones = milestones_helpers.get_milestones(milestone_namespace) - if len(milestones): # lint-amnesty, pylint: disable=len-as-condition + if len(milestones): # pylint: disable=len-as-condition milestone = milestones[0] else: description = f'Autogenerated during {str(course_id)} entrance exam creation.' diff --git a/cms/djangoapps/contentstore/views/error.py b/cms/djangoapps/contentstore/views/error.py index 5a9c23490ba6..8690fa9cda0c 100644 --- a/cms/djangoapps/contentstore/views/error.py +++ b/cms/djangoapps/contentstore/views/error.py @@ -1,4 +1,4 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring import functools from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError @@ -20,7 +20,7 @@ def outer(func): def inner(request, *args, **kwargs): if request.headers.get('x-requested-with') == 'XMLHttpRequest': content = dump_js_escaped_json({"error": message}) - return HttpResponse(content, content_type="application/json", # lint-amnesty, pylint: disable=http-response-with-content-type-json + return HttpResponse(content, content_type="application/json", # pylint: disable=http-response-with-content-type-json status=status) else: return func(request, *args, **kwargs) @@ -29,7 +29,7 @@ def inner(request, *args, **kwargs): @jsonable_error(404, "Resource not found") -def not_found(request, exception): # lint-amnesty, pylint: disable=unused-argument +def not_found(request, exception): # pylint: disable=unused-argument return render_to_response('error.html', {'error': '404'}) @@ -40,7 +40,7 @@ def server_error(request): @fix_crum_request @jsonable_error(404, "Resource not found") -def render_404(request, exception): # lint-amnesty, pylint: disable=unused-argument +def render_404(request, exception): # pylint: disable=unused-argument return HttpResponseNotFound(render_to_string('404.html', {}, request=request)) diff --git a/cms/djangoapps/contentstore/views/export_git.py b/cms/djangoapps/contentstore/views/export_git.py index 0da89f9fe74b..7fc7b6086944 100644 --- a/cms/djangoapps/contentstore/views/export_git.py +++ b/cms/djangoapps/contentstore/views/export_git.py @@ -15,7 +15,7 @@ import cms.djangoapps.contentstore.git_export_utils as git_export_utils from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 22310faa1ae2..ab0e1574515a 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -27,28 +27,24 @@ from edx_django_utils.monitoring import set_custom_attribute, set_custom_attributes_for_course_key from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator +from openedx_authz.constants.permissions import COURSES_EXPORT_COURSE, COURSES_IMPORT_COURSE from path import Path as path from storages.backends.s3boto3 import S3Boto3Storage from user_tasks.conf import settings as user_tasks_settings from user_tasks.models import UserTaskArtifact, UserTaskStatus from common.djangoapps.edxmako.shortcuts import render_to_response -from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.util.json_request import JsonResponse from common.djangoapps.util.monitoring import monitor_import_failure from common.djangoapps.util.views import ensure_valid_course_key -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from ..storage import course_import_export_storage from ..tasks import CourseExportTask, CourseImportTask, export_olx, import_olx -from ..toggles import use_new_export_page, use_new_import_page -from ..utils import ( - reverse_course_url, - reverse_library_url, - get_export_url, - get_import_url, - IMPORTABLE_FILE_TYPES, -) +from ..utils import IMPORTABLE_FILE_TYPES, get_export_url, get_import_url, reverse_course_url, reverse_library_url + __all__ = [ 'import_handler', 'import_status_handler', 'export_handler', 'export_output_handler', 'export_status_handler', @@ -87,17 +83,22 @@ def import_handler(request, course_key_string): successful_url = reverse_course_url('course_handler', courselike_key) context_name = 'context_course' courselike_block = modulestore().get_course(courselike_key) - if not has_course_author_access(request.user, courselike_key): + if not user_has_course_permission( + user=request.user, + authz_permission=COURSES_IMPORT_COURSE.identifier, + course_key=courselike_key, + legacy_permission=LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): - if request.method == 'GET': # lint-amnesty, pylint: disable=no-else-raise + if request.method == 'GET': # pylint: disable=no-else-raise raise NotImplementedError('coming soon') else: return _write_chunk(request, courselike_key) elif request.method == 'GET': # assume html - if use_new_import_page(courselike_key) and not library: + if not library: return redirect(get_import_url(courselike_key)) status_url = reverse_course_url( "import_status_handler", courselike_key, kwargs={'filename': "fillerName"} @@ -124,7 +125,7 @@ def _save_request_status(request, key, status): request.session.save() -def _write_chunk(request, courselike_key): # lint-amnesty, pylint: disable=too-many-statements +def _write_chunk(request, courselike_key): # pylint: disable=too-many-statements """ Write the OLX file data chunk from the given request to the local filesystem. """ @@ -257,7 +258,12 @@ def import_status_handler(request, course_key_string, filename=None): """ course_key = CourseKey.from_string(course_key_string) - if not has_course_author_access(request.user, course_key): + if not user_has_course_permission( + user=request.user, + authz_permission=COURSES_IMPORT_COURSE.identifier, + course_key=course_key, + legacy_permission=LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() # The task status record is authoritative once it's been created @@ -294,7 +300,7 @@ def send_tarball(tarball, size): """ wrapper = FileWrapper(tarball, settings.COURSE_EXPORT_DOWNLOAD_CHUNK_SIZE) response = StreamingHttpResponse(wrapper, content_type='application/x-tgz') - response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(tarball.name) + response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(tarball.name) # noqa: UP031 response['Content-Length'] = size return response @@ -318,7 +324,12 @@ def export_handler(request, course_key_string): a link appearing on the page once it's ready. """ course_key = CourseKey.from_string(course_key_string) - if not has_course_author_access(request.user, course_key): + if not user_has_course_permission( + user=request.user, + authz_permission=COURSES_EXPORT_COURSE.identifier, + course_key=course_key, + legacy_permission=LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() library = isinstance(course_key, LibraryLocator) if library: @@ -346,7 +357,7 @@ def export_handler(request, course_key_string): export_olx.delay(request.user.id, course_key_string, request.LANGUAGE_CODE) return JsonResponse({'ExportStatus': 1}) elif 'text/html' in requested_format: - if use_new_export_page(course_key) and not library: + if not library: return redirect(get_export_url(course_key)) return render_to_response('export.html', context) else: @@ -373,7 +384,12 @@ def export_status_handler(request, course_key_string): returned. """ course_key = CourseKey.from_string(course_key_string) - if not has_course_author_access(request.user, course_key): + if not user_has_course_permission( + user=request.user, + authz_permission=COURSES_EXPORT_COURSE.identifier, + course_key=course_key, + legacy_permission=LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() # The task status record is authoritative once it's been created @@ -435,7 +451,12 @@ def export_output_handler(request, course_key_string): filesystem instead of an external service like S3. """ course_key = CourseKey.from_string(course_key_string) - if not has_course_author_access(request.user, course_key): + if not user_has_course_permission( + user=request.user, + authz_permission=COURSES_EXPORT_COURSE.identifier, + course_key=course_key, + legacy_permission=LegacyAuthoringPermission.WRITE + ): raise PermissionDenied() task_status = _latest_task_status(request, course_key_string, export_output_handler) @@ -446,7 +467,7 @@ def export_output_handler(request, course_key_string): tarball = course_import_export_storage.open(artifact.file.name) return send_tarball(tarball, artifact.file.storage.size(artifact.file.name)) except UserTaskArtifact.DoesNotExist: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 finally: if artifact: artifact.file.close() diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 92e4329c2f94..70ae0bce2c2a 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -19,10 +19,8 @@ from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import DuplicateCourseError +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from cms.djangoapps.course_creators.views import get_course_creator_status from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import ( @@ -40,11 +38,13 @@ UserBasedRole, ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import DuplicateCourseError -from ..utils import add_instructor, reverse_library_url from ..toggles import libraries_v1_enabled +from ..utils import add_instructor, reverse_library_url from .component import CONTAINER_TEMPLATES, get_component_templates -from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from .user import user_with_role __all__ = ['library_handler', 'manage_library_users'] @@ -73,24 +73,23 @@ def _user_can_create_library_for_org(user, org=None): elif user.is_staff: return True elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): - org_filter_params = {} - if org: - org_filter_params['org'] = org is_course_creator = get_course_creator_status(user) == 'granted' - has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists() - has_course_staff_role = ( - UserBasedRole(user=user, role=CourseStaffRole.ROLE) - .courses_with_role() - .filter(**org_filter_params) - .exists() - ) - has_course_admin_role = ( - UserBasedRole(user=user, role=CourseInstructorRole.ROLE) - .courses_with_role() - .filter(**org_filter_params) - .exists() - ) - return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role + if is_course_creator: + return True + + has_org_staff_role = OrgStaffRole().has_org_for_user(user, org) + if has_org_staff_role: + return True + + has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).has_courses_with_role(org) + if has_course_staff_role: + return True + + has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).has_courses_with_role(org) + if has_course_admin_role: + return True + + return False else: # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present. disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None) @@ -228,7 +227,7 @@ def _create_library(request): ) # Give the user admin ("Instructor") role for this library: add_instructor(new_lib.location.library_key, request.user, request.user) - except PermissionDenied as error: # pylint: disable=unused-variable + except PermissionDenied as error: # pylint: disable=unused-variable # noqa: F841 log.info( "User does not have the permission to create LIBRARY in this organization." "User: '%s' Org: '%s' LIBRARY #: '%s'.", diff --git a/cms/djangoapps/contentstore/views/organization.py b/cms/djangoapps/contentstore/views/organization.py index 7ece98201ac1..511ef611aa82 100644 --- a/cms/djangoapps/contentstore/views/organization.py +++ b/cms/djangoapps/contentstore/views/organization.py @@ -18,8 +18,8 @@ class OrganizationListView(View): """ @method_decorator(login_required) - def get(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + def get(self, request, *args, **kwargs): # pylint: disable=unused-argument """Returns organization list as json.""" organizations = get_organizations() org_names_list = [(org["short_name"]) for org in organizations] - return HttpResponse(dump_js_escaped_json(org_names_list), content_type='application/json; charset=utf-8') # lint-amnesty, pylint: disable=http-response-with-content-type-json + return HttpResponse(dump_js_escaped_json(org_names_list), content_type='application/json; charset=utf-8') # pylint: disable=http-response-with-content-type-json diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index b83158e35c76..5e5892fef46b 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -1,11 +1,11 @@ -# lint-amnesty, pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring import logging from functools import partial from django.conf import settings -from django.core.cache import cache from django.contrib.auth.decorators import login_required +from django.core.cache import cache from django.http import Http404, HttpResponseBadRequest from django.urls import reverse from django.utils.translation import gettext as _ @@ -18,35 +18,31 @@ from xblock.exceptions import NoSuchHandlerError, NotFoundError, ProcessingError from xblock.runtime import KvsFieldData -from openedx.core.djangoapps.video_config.services import VideoConfigService -from xmodule.contentstore.django import contentstore -from xmodule.exceptions import NotFoundError as XModuleNotFoundError -from xmodule.modulestore.django import XBlockI18nService, modulestore -from xmodule.partitions.partitions_service import PartitionService -from xmodule.services import SettingsService, TeamsConfigurationService -from xmodule.studio_editable import has_author_view -from xmodule.util.sandboxing import SandboxService -from xmodule.util.builtin_assets import add_webpack_js_to_fragment -from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin -from cms.djangoapps.xblock_config.models import StudioConfig from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id +from cms.djangoapps.xblock_config.models import StudioConfig from cms.lib.xblock.field_data import CmsFieldData from cms.lib.xblock.upstream_sync import UpstreamLink +from common.djangoapps.edxmako.services import MakoService +from common.djangoapps.edxmako.shortcuts import render_to_string from common.djangoapps.static_replace.services import ReplaceURLService from common.djangoapps.static_replace.wrapper import replace_urls_wrapper from common.djangoapps.student.models import anonymous_id_for_user -from common.djangoapps.edxmako.shortcuts import render_to_string -from common.djangoapps.edxmako.services import MakoService from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from lms.djangoapps.lms_xblock.field_data import LmsFieldData -from openedx.core.lib.license import wrap_with_license +from openedx.core.djangoapps.discussions.services import DiscussionConfigService +from openedx.core.djangoapps.video_config.services import VideoConfigService from openedx.core.lib.cache_utils import CacheService -from openedx.core.lib.xblock_utils import ( - request_token, - wrap_fragment, - wrap_xblock, - wrap_xblock_aside -) +from openedx.core.lib.license import wrap_with_license +from openedx.core.lib.xblock_utils import request_token, wrap_fragment, wrap_xblock, wrap_xblock_aside +from xmodule.contentstore.django import contentstore +from xmodule.exceptions import NotFoundError as XModuleNotFoundError +from xmodule.modulestore.django import XBlockI18nService, modulestore +from xmodule.partitions.partitions_service import PartitionService +from xmodule.services import SettingsService, TeamsConfigurationService, XQueueService +from xmodule.studio_editable import has_author_view +from xmodule.util.builtin_assets import add_webpack_js_to_fragment +from xmodule.util.sandboxing import SandboxService +from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin from ..utils import StudioPermissionsService, get_visibility_partition_info from .access import get_user_role @@ -79,11 +75,11 @@ def preview_handler(request, usage_key_string, handler, suffix=''): except NoSuchHandlerError: log.exception("XBlock %s attempted to access missing handler %r", instance, handler) - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 except (XModuleNotFoundError, NotFoundError): log.exception("Module indicating to user that request doesn't exist") - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # pylint: disable=raise-missing-from # noqa: B904 except ProcessingError: log.warning("Module raised an error while processing AJAX request", @@ -97,7 +93,7 @@ def preview_handler(request, usage_key_string, handler, suffix=''): return webob_to_django_response(resp) -def handler_url(block, handler_name, suffix='', query='', thirdparty=False): # lint-amnesty, pylint: disable=unused-argument +def handler_url(block, handler_name, suffix='', query='', thirdparty=False): # pylint: disable=unused-argument """ Handler URL function for Preview """ @@ -228,6 +224,8 @@ def _prepare_runtime_for_preview(request, block): "cache": CacheService(cache), 'replace_urls': ReplaceURLService, 'video_config': VideoConfigService(), + 'discussion_config_service': DiscussionConfigService(), + 'xqueue': XQueueService(block), } block.runtime.get_block_for_descriptor = partial(_load_preview_block, request) @@ -314,7 +312,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): is_reorderable = _is_xblock_reorderable(xblock, context) selected_groups_label = get_visibility_partition_info(xblock)['selected_groups_label'] if selected_groups_label: - selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long + selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # pylint: disable=line-too-long course = modulestore().get_course(xblock.location.course_key) can_edit = context.get('can_edit', True) diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 0684d52022ae..f841d345b1af 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -8,13 +8,10 @@ from django.http.response import Http404 from django.shortcuts import redirect -from common.djangoapps.edxmako.shortcuts import render_to_response - from ..config.waffle import ENABLE_ACCESSIBILITY_POLICY_PAGE -from ..toggles import use_legacy_logged_out_home __all__ = [ - 'register_redirect_to_lms', 'login_redirect_to_lms', 'howitworks', 'accessibility', + 'register_redirect_to_lms', 'login_redirect_to_lms', 'accessibility', 'redirect_to_lms_login_for_admin', ] @@ -24,7 +21,7 @@ def register_redirect_to_lms(request): This view redirects to the LMS register view. It is used to temporarily keep the old Studio signup url alive. """ - register_url = '{register_url}{params}'.format( + register_url = '{register_url}{params}'.format( # noqa: UP032 register_url=settings.FRONTEND_REGISTER_URL, params=_build_next_param(request), ) @@ -36,7 +33,7 @@ def login_redirect_to_lms(request): This view redirects to the LMS login view. It is used for Django's LOGIN_URL setting, which is where unauthenticated requests to protected endpoints are redirected. """ - login_url = '{login_url}{params}'.format( + login_url = '{login_url}{params}'.format( # noqa: UP032 login_url=settings.FRONTEND_LOGIN_URL, params=_build_next_param(request), ) @@ -62,15 +59,6 @@ def _build_next_param(request): return '' -def howitworks(request): - """ - Deprecated logged-out home page. New behavior is just login w/ redirect to studio course list. - """ - if use_legacy_logged_out_home() and not request.user.is_authenticated: - return render_to_response('howitworks.html', {}) - return redirect('/home/') - - def accessibility(request): """ Display the accessibility accommodation form. diff --git a/cms/djangoapps/contentstore/views/serializers.py b/cms/djangoapps/contentstore/views/serializers.py index 71608fb0deab..6f49cc95c728 100644 --- a/cms/djangoapps/contentstore/views/serializers.py +++ b/cms/djangoapps/contentstore/views/serializers.py @@ -5,12 +5,13 @@ Add new serializers here as needed for API endpoints in this module. """ -from rest_framework import serializers from django.core.exceptions import PermissionDenied +from rest_framework import serializers from cms.djangoapps.contentstore.views.certificate_manager import ( CERTIFICATE_SCHEMA_VERSION, - CertificateManager, Certificate, + Certificate, + CertificateManager, ) from common.djangoapps.student.roles import GlobalStaff diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py index e39753c7258d..8bda3abd52a0 100644 --- a/cms/djangoapps/contentstore/views/session_kv_store.py +++ b/cms/djangoapps/contentstore/views/session_kv_store.py @@ -10,8 +10,8 @@ def stringify(key): return repr(tuple(key)) -class SessionKeyValueStore(KeyValueStore): # lint-amnesty, pylint: disable=missing-class-docstring - def __init__(self, request): # lint-amnesty, pylint: disable=super-init-not-called +class SessionKeyValueStore(KeyValueStore): # pylint: disable=missing-class-docstring + def __init__(self, request): # pylint: disable=super-init-not-called self._session = request.session def get(self, key): diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index 8fa9d024458d..a10e87c2c27e 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -1,7 +1,7 @@ """ Views related to course tabs """ -from typing import Dict, Iterable, List, Optional, Union +from typing import Dict, Iterable, List, Optional, Union # noqa: UP035 from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required @@ -12,14 +12,15 @@ from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework.exceptions import ValidationError + +from common.djangoapps.student.auth import has_course_author_access +from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json from xmodule.course_block import CourseBlock from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException, StaticTab -from common.djangoapps.student.auth import has_course_author_access -from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json -from ..utils import get_pages_and_resources_url, get_custom_pages_url +from ..utils import get_custom_pages_url, get_pages_and_resources_url __all__ = ["tabs_handler", "update_tabs_handler"] @@ -51,7 +52,7 @@ def tabs_handler(request, course_key_string): course_item = modulestore().get_course(course_key) if "application/json" in request.META.get("HTTP_ACCEPT", "application/json"): - if request.method == "GET": # lint-amnesty, pylint: disable=no-else-raise + if request.method == "GET": # pylint: disable=no-else-raise raise NotImplementedError("coming soon") else: try: @@ -91,7 +92,7 @@ def get_course_tabs(course_item: CourseBlock, user: User) -> Iterable[CourseTab] yield tab -def update_tabs_handler(course_item: CourseBlock, tabs_data: Dict, user: User) -> None: +def update_tabs_handler(course_item: CourseBlock, tabs_data: Dict, user: User) -> None: # noqa: UP006 """ Helper to handle updates to course tabs based on API data. @@ -154,7 +155,7 @@ def create_new_list(tab_locators, old_tab_list): return sorted(new_tab_list, key=lambda item: item.priority or float('inf')) -def edit_tab_handler(course_item: CourseBlock, tabs_data: Dict, user: User): +def edit_tab_handler(course_item: CourseBlock, tabs_data: Dict, user: User): # noqa: UP006 """ Helper function for handling requests to edit settings of a single tab """ @@ -178,7 +179,7 @@ def edit_tab_handler(course_item: CourseBlock, tabs_data: Dict, user: User): raise NotImplementedError(f"Unsupported request to edit tab: {tabs_data}") -def get_tab_by_tab_id_locator(tab_list: List[CourseTab], tab_id_locator: Dict[str, str]) -> Optional[CourseTab]: +def get_tab_by_tab_id_locator(tab_list: List[CourseTab], tab_id_locator: Dict[str, str]) -> Optional[CourseTab]: # noqa: UP006, UP045 # pylint: disable=line-too-long """ Look for a tab with the specified tab_id or locator. Returns the first matching tab. """ @@ -190,7 +191,7 @@ def get_tab_by_tab_id_locator(tab_list: List[CourseTab], tab_id_locator: Dict[st return tab -def get_tab_by_locator(tab_list: List[CourseTab], tab_location: Union[str, UsageKey]) -> Optional[CourseTab]: +def get_tab_by_locator(tab_list: List[CourseTab], tab_location: Union[str, UsageKey]) -> Optional[CourseTab]: # noqa: UP006, UP007, UP045 # pylint: disable=line-too-long """ Look for a tab with the specified locator. Returns the first matching tab. """ diff --git a/cms/djangoapps/contentstore/views/tests/test_access.py b/cms/djangoapps/contentstore/views/tests/test_access.py index b8c2404cf678..b1eacbab09c7 100644 --- a/cms/djangoapps/contentstore/views/tests/test_access.py +++ b/cms/djangoapps/contentstore/views/tests/test_access.py @@ -39,12 +39,12 @@ def test_get_user_role_instructor(self): Verifies if user is instructor. """ add_users(self.global_admin, CourseInstructorRole(self.course_key), self.instructor) - self.assertEqual( + self.assertEqual( # noqa: PT009 'instructor', get_user_role(self.instructor, self.course_key) ) add_users(self.global_admin, CourseStaffRole(self.course_key), self.staff) - self.assertEqual( + self.assertEqual( # noqa: PT009 'instructor', get_user_role(self.instructor, self.course_key) ) @@ -54,7 +54,7 @@ def test_get_user_role_staff(self): Verifies if user is staff. """ add_users(self.global_admin, CourseStaffRole(self.course_key), self.staff) - self.assertEqual( + self.assertEqual( # noqa: PT009 'staff', get_user_role(self.staff, self.course_key) ) diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index e7dbcfe9f55a..513a39fe82ab 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -14,21 +14,25 @@ from django.test.utils import override_settings from opaque_keys.edx.keys import AssetKey from opaque_keys.edx.locator import CourseLocator +from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR from PIL import Image from pytz import UTC +from rest_framework.test import APIClient from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url from cms.djangoapps.contentstore.views import assets from common.djangoapps.static_replace import replace_static_urls -from xmodule.assetstore import AssetMetadata # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin +from xmodule.assetstore import AssetMetadata # pylint: disable=wrong-import-order +from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.xml_importer import import_course_from_xml # pylint: disable=wrong-import-order TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -87,14 +91,14 @@ class BasicAssetsTestCase(AssetsTestCase): def test_basic(self): resp = self.client.get(self.url, HTTP_ACCEPT='text/html') - self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.status_code, 302) # noqa: PT009 def test_static_url_generation(self): course_key = CourseLocator('org', 'class', 'run') location = course_key.make_asset_key('asset', 'my_file_name.jpg') path = StaticContent.get_static_path_from_location(location) - self.assertEqual(path, '/static/my_file_name.jpg') + self.assertEqual(path, '/static/my_file_name.jpg') # noqa: PT009 def test_pdf_asset(self): module_store = modulestore() @@ -118,7 +122,7 @@ def test_pdf_asset(self): # Check after import textbook.pdf has valid contentType ('application/pdf') # Note: Actual contentType for textbook.pdf in asset.json is 'text/pdf' - self.assertEqual(content.content_type, 'application/pdf') + self.assertEqual(content.content_type, 'application/pdf') # noqa: PT009 def test_relative_url_for_split_course(self): """ @@ -144,9 +148,9 @@ def test_relative_url_for_split_course(self): url = asset_url.replace('"', '') base_url = url.replace(filename, '') - self.assertIn(f"/{filename}", url) + self.assertIn(f"/{filename}", url) # noqa: PT009 resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # simulation of html page where base_url is up-to asset's main directory # and relative_path is dom element with its src @@ -154,9 +158,9 @@ def test_relative_url_for_split_course(self): # browser append relative_path with base_url absolute_path = base_url + relative_path - self.assertIn(f"/{relative_path}", absolute_path) + self.assertIn(f"/{relative_path}", absolute_path) # noqa: PT009 resp = self.client.get(absolute_path) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 class PaginationTestCase(AssetsTestCase): @@ -260,9 +264,9 @@ def assert_correct_asset_response(self, url, expected_start, expected_length, ex resp = self.client.get(url, HTTP_ACCEPT='application/json') json_response = json.loads(resp.content.decode('utf-8')) assets_response = json_response['assets'] - self.assertEqual(json_response['start'], expected_start) - self.assertEqual(len(assets_response), expected_length) - self.assertEqual(json_response['totalCount'], expected_total) + self.assertEqual(json_response['start'], expected_start) # noqa: PT009 + self.assertEqual(len(assets_response), expected_length) # noqa: PT009 + self.assertEqual(json_response['totalCount'], expected_total) # noqa: PT009 def assert_correct_sort_response(self, url, sort, direction): """ @@ -272,17 +276,17 @@ def assert_correct_sort_response(self, url, sort, direction): url + '?sort=' + sort + '&direction=' + direction, HTTP_ACCEPT='application/json') json_response = json.loads(resp.content.decode('utf-8')) assets_response = json_response['assets'] - self.assertEqual(sort, json_response['sort']) - self.assertEqual(direction, json_response['direction']) + self.assertEqual(sort, json_response['sort']) # noqa: PT009 + self.assertEqual(direction, json_response['direction']) # noqa: PT009 name1 = assets_response[0][sort] name2 = assets_response[1][sort] name3 = assets_response[2][sort] if direction == 'asc': - self.assertLessEqual(name1, name2) - self.assertLessEqual(name2, name3) + self.assertLessEqual(name1, name2) # noqa: PT009 + self.assertLessEqual(name2, name3) # noqa: PT009 else: - self.assertGreaterEqual(name1, name2) - self.assertGreaterEqual(name2, name3) + self.assertGreaterEqual(name1, name2) # noqa: PT009 + self.assertGreaterEqual(name2, name3) # noqa: PT009 def assert_correct_filter_response(self, url, filter_type, filter_value): """ @@ -308,7 +312,7 @@ def assert_correct_filter_response(self, url, filter_type, filter_value): url + '?' + filter_type + '=' + filter_value, HTTP_ACCEPT='application/json') json_response = json.loads(resp.content.decode('utf-8')) assets_response = json_response['assets'] - self.assertEqual(filter_value_split, json_response['assetTypes']) + self.assertEqual(filter_value_split, json_response['assetTypes']) # noqa: PT009 if filter_value != '': content_types = [asset['content_type'].lower() @@ -317,12 +321,12 @@ def assert_correct_filter_response(self, url, filter_type, filter_value): for content_type in content_types: # content_type is either not any defined type (i.e. OTHER) or is a defined type (if multiple # parameters including OTHER are used) - self.assertTrue( + self.assertTrue( # noqa: PT009 content_type in requested_file_extensions or content_type not in all_file_extensions ) else: for content_type in content_types: - self.assertIn(content_type, requested_file_extensions) + self.assertIn(content_type, requested_file_extensions) # noqa: PT009 def assert_invalid_parameters_error(self, url, filter_type, filter_value): """ @@ -330,7 +334,7 @@ def assert_invalid_parameters_error(self, url, filter_type, filter_value): """ resp = self.client.get( url + '?' + filter_type + '=' + filter_value, HTTP_ACCEPT='application/json') - self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.status_code, 400) # noqa: PT009 def assert_correct_text_search_response(self, url, text_search, number_matches): """ @@ -340,14 +344,14 @@ def assert_correct_text_search_response(self, url, text_search, number_matches): url + '?text_search=' + text_search, HTTP_ACCEPT='application/json') json_response = json.loads(resp.content.decode('utf-8')) assets_response = json_response['assets'] - self.assertEqual(text_search, json_response['textSearch']) - self.assertEqual(len(assets_response), number_matches) + self.assertEqual(text_search, json_response['textSearch']) # noqa: PT009 + self.assertEqual(len(assets_response), number_matches) # noqa: PT009 text_search_tokens = text_search.split() for asset_response in assets_response: for token in text_search_tokens: - self.assertIn(token.lower(), asset_response['display_name'].lower()) + self.assertIn(token.lower(), asset_response['display_name'].lower()) # noqa: PT009 @ddt @@ -361,11 +365,11 @@ def setUp(self): def test_happy_path(self): resp = self.upload_asset() - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def test_upload_image(self): resp = self.upload_asset("test_image", asset_type="image") - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 @data( (int(MAX_FILE_SIZE / 2.0), "small.file.test", 200), @@ -383,7 +387,7 @@ def test_file_size(self, case, get_file_size): "name": name, "file": f }) - self.assertEqual(resp.status_code, status_code) + self.assertEqual(resp.status_code, status_code) # noqa: PT009 class DownloadTestCase(AssetsTestCase): @@ -396,19 +400,19 @@ def setUp(self): # First, upload something. self.asset_name = 'download_test' resp = self.upload_asset(self.asset_name) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 self.uploaded_url = json.loads(resp.content.decode('utf-8'))['asset']['url'] def test_download(self): # Now, download it. resp = self.client.get(self.uploaded_url, HTTP_ACCEPT='text/html') - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 self.assertContains(resp, 'This file is generated by python unit test') def test_download_not_found_throw(self): url = self.uploaded_url.replace(self.asset_name, 'not_the_asset_name') resp = self.client.get(url, HTTP_ACCEPT='text/html') - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 404) # noqa: PT009 @patch('xmodule.modulestore.mixed.MixedModuleStore.find_asset_metadata') def test_pickling_calls(self, patched_find_asset_metadata): @@ -416,7 +420,7 @@ def test_pickling_calls(self, patched_find_asset_metadata): """ patched_find_asset_metadata.return_value = None self.client.get(self.uploaded_url, HTTP_ACCEPT='text/html') - self.assertFalse(patched_find_asset_metadata.called) + self.assertFalse(patched_find_asset_metadata.called) # noqa: PT009 class AssetToJsonTestCase(AssetsTestCase): @@ -436,20 +440,20 @@ def test_basic(self): output = assets._get_asset_json("my_file", content_type, upload_date, location, thumbnail_location, True, course_key) - self.assertEqual(output["display_name"], "my_file") - self.assertEqual(output["date_added"], "Jun 01, 2013 at 10:30 UTC") - self.assertEqual(output["url"], "/asset-v1:org+class+run+type@asset+block@my_file_name.jpg") - self.assertEqual( + self.assertEqual(output["display_name"], "my_file") # noqa: PT009 + self.assertEqual(output["date_added"], "Jun 01, 2013 at 10:30 UTC") # noqa: PT009 + self.assertEqual(output["url"], "/asset-v1:org+class+run+type@asset+block@my_file_name.jpg") # noqa: PT009 + self.assertEqual( # noqa: PT009 output["external_url"], "https://lms_root_url/asset-v1:org+class+run+type@asset+block@my_file_name.jpg" ) - self.assertEqual(output["portable_url"], "/static/my_file_name.jpg") - self.assertEqual(output["thumbnail"], "/asset-v1:org+class+run+type@thumbnail+block@my_file_name_thumb.jpg") - self.assertEqual(output["id"], str(location)) - self.assertEqual(output['locked'], True) - self.assertEqual(output['static_full_url'], '/asset-v1:org+class+run+type@asset+block@my_file_name.jpg') + self.assertEqual(output["portable_url"], "/static/my_file_name.jpg") # noqa: PT009 + self.assertEqual(output["thumbnail"], "/asset-v1:org+class+run+type@thumbnail+block@my_file_name_thumb.jpg") # noqa: PT009 # pylint: disable=line-too-long + self.assertEqual(output["id"], str(location)) # noqa: PT009 + self.assertEqual(output['locked'], True) # noqa: PT009 + self.assertEqual(output['static_full_url'], '/asset-v1:org+class+run+type@asset+block@my_file_name.jpg') # noqa: PT009 # pylint: disable=line-too-long output = assets._get_asset_json("name", content_type, upload_date, location, None, False, course_key) - self.assertIsNone(output["thumbnail"]) + self.assertIsNone(output["thumbnail"]) # noqa: PT009 class LockAssetTestCase(AssetsTestCase): @@ -466,7 +470,7 @@ def verify_asset_locked_state(locked): asset_location = StaticContent.get_location_from_path( 'asset-v1:edX+toy+2012_Fall+type@asset+block@sample_static.html') content = contentstore().find(asset_location) - self.assertEqual(content.locked, locked) + self.assertEqual(content.locked, locked) # noqa: PT009 def post_asset_update(lock, course): """ Helper method for posting asset update. """ @@ -486,7 +490,7 @@ def post_asset_update(lock, course): "application/json" ) - self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.status_code, 201) # noqa: PT009 return json.loads(resp.content.decode('utf-8')) # Load the toy course. @@ -505,12 +509,12 @@ def post_asset_update(lock, course): # Lock the asset resp_asset = post_asset_update(True, course) - self.assertTrue(resp_asset['locked']) + self.assertTrue(resp_asset['locked']) # noqa: PT009 verify_asset_locked_state(True) # Unlock the asset resp_asset = post_asset_update(False, course) - self.assertFalse(resp_asset['locked']) + self.assertFalse(resp_asset['locked']) # noqa: PT009 verify_asset_locked_state(False) @@ -527,7 +531,7 @@ def setUp(self): self.asset = self.get_sample_asset(self.asset_name) response = self.client.post(self.url, {"name": self.asset_name, "file": self.asset}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 self.uploaded_id = json.loads(response.content.decode('utf-8'))['asset']['id'] self.asset_location = AssetKey.from_string(self.uploaded_id) @@ -538,7 +542,7 @@ def test_delete_asset(self): test_url = reverse_course_url( 'assets_handler', self.course.id, kwargs={'asset_key_string': self.uploaded_id}) resp = self.client.delete(test_url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 def test_delete_image_type_asset(self): """ Tests deletion of image type asset """ @@ -547,12 +551,12 @@ def test_delete_image_type_asset(self): # upload image response = self.client.post(self.url, {"name": "delete_image_test", "file": image_asset}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 uploaded_image_url = json.loads(response.content.decode('utf-8'))['asset']['id'] # upload image thumbnail response = self.client.post(self.url, {"name": "delete_image_thumb_test", "file": thumbnail_image_asset}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 thumbnail_url = json.loads(response.content.decode('utf-8'))['asset']['url'] thumbnail_location = StaticContent.get_location_from_path(thumbnail_url) @@ -567,7 +571,7 @@ def test_delete_image_type_asset(self): test_url = reverse_course_url( 'assets_handler', self.course.id, kwargs={'asset_key_string': str(uploaded_image_url)}) resp = self.client.delete(test_url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 def test_delete_asset_with_invalid_asset(self): """ Tests the sad path :( """ @@ -576,7 +580,7 @@ def test_delete_asset_with_invalid_asset(self): self.course.id, kwargs={'asset_key_string': "asset-v1:edX+toy+2012_Fall+type@asset+block@invalid.pdf"} ) resp = self.client.delete(test_url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.status_code, 404) # noqa: PT009 def test_delete_asset_with_invalid_thumbnail(self): """ Tests the sad path :( """ @@ -586,4 +590,154 @@ def test_delete_asset_with_invalid_thumbnail(self): '/asset-v1:edX+toy+2012_Fall+type@asset+block@invalid.pdf') contentstore().save(self.content) resp = self.client.delete(test_url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 + + +class AssetsEndpointsAuthzTestCase(CourseAuthzTestMixin, AssetsTestCase): + """ + Unit tests for validating authorization on Assets endpoints when AuthZ is enabled. + """ + + authz_roles_to_assign = [] + + @property + def course_key(self): + return self.course.id + + def setUp(self): + super().setUp() + + # Upload a file for GET/PUT/DELETE tests + r = self.upload_asset('authz_test_file') + + if r.status_code == 200: + sample_asset_key = json.loads(r.content.decode('utf-8'))['asset']['id'] + self.asset_url = reverse_course_url( + 'assets_handler', + self.course.id, + kwargs={'asset_key_string': sample_asset_key} + ) + self.asset_usage_url = reverse_course_url( + 'asset_usage_path_handler', + self.course.id, + kwargs={'asset_key_string': sample_asset_key} + ) + + def _put_asset(self, client): + # Simulate a PUT (edit) request with a JSON body + return client.put( + self.asset_url, + data=json.dumps({"locked": True}), + content_type="application/json" + ) + + def _delete_asset(self, client): + return client.delete(self.asset_url, HTTP_ACCEPT="application/json") + + def test_auditor_permissions(self): + """Auditor: read allowed, write/edit/delete forbidden.""" + user = UserFactory(password=self.user_password) + self.add_user_to_role(user, COURSE_AUDITOR.external_key) + + client = APIClient() + client.login(username=user.username, password=self.user_password) + + # GET assets_handler allowed + resp = client.get(self.asset_url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # GET asset_usage_path_handler allowed + resp = client.get(self.asset_usage_url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # POST assets_handler forbidden + resp = client.post(self.url, {"name": "file", "file": self.get_sample_asset('file')}) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # PUT assets_handler forbidden + resp = self._put_asset(client) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # DELETE assets_handler forbidden + resp = self._delete_asset(client) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + def test_editor_permissions(self): + """Editor: read/create/edit allowed, delete forbidden.""" + user = UserFactory(password=self.user_password) + self.add_user_to_role(user, COURSE_EDITOR.external_key) + client = APIClient() + client.login(username=user.username, password=self.user_password) + + # GET assets_handler allowed + resp = client.get(self.url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # GET asset_usage_path_handler allowed + resp = client.get(self.asset_usage_url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # POST assets_handler allowed + resp = client.post(self.url, {"name": "file", "file": self.get_sample_asset('file')}) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # PUT assets_handler allowed + resp = self._put_asset(client) + self.assertIn(resp.status_code, [200, 201]) # noqa: PT009 + + # DELETE assets_handler forbidden + resp = self._delete_asset(client) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + def test_admin_permissions(self): + """Admin: full access.""" + user = UserFactory(password=self.user_password) + self.add_user_to_role(user, COURSE_ADMIN.external_key) + client = APIClient() + client.login(username=user.username, password=self.user_password) + + # GET assets_handler allowed + resp = client.get(self.url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # GET asset_usage_path_handler allowed + resp = client.get(self.asset_usage_url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # POST assets_handler allowed + resp = client.post(self.url, {"name": "file", "file": self.get_sample_asset('file')}) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + # PUT assets_handler allowed + resp = self._put_asset(client) + self.assertIn(resp.status_code, [200, 201]) # noqa: PT009 + + # DELETE assets_handler allowed + resp = self._delete_asset(client) + self.assertEqual(resp.status_code, 204) # noqa: PT009 + + def test_no_role_permissions(self): + """No role: all forbidden.""" + user = UserFactory(password=self.user_password) + client = APIClient() + client.login(username=user.username, password=self.user_password) + + # GET assets_handler forbidden + resp = client.get(self.url) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # GET asset_usage_path_handler forbidden + resp = client.get(self.asset_usage_url) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # POST assets_handler forbidden + resp = client.post(self.url, {"name": "file", "file": self.get_sample_asset('file')}) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # PUT assets_handler forbidden + resp = self._put_asset(client) + self.assertEqual(resp.status_code, 403) # noqa: PT009 + + # DELETE assets_handler forbidden + resp = self._delete_asset(client) + self.assertEqual(resp.status_code, 403) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 846c62d2436a..d5441ac8bca7 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -7,23 +7,23 @@ from unittest.mock import Mock, PropertyMock, patch import ddt +from bs4 import BeautifulSoup from django.conf import settings from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory -from django.urls import reverse from django.test.utils import override_settings -from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE -from openedx_events.content_authoring.data import DuplicatedXBlockData -from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED -from openedx_events.tests.utils import OpenEdxEventsTestMixin +from django.urls import reverse from edx_proctoring.exceptions import ProctoredExamNotFoundException from opaque_keys import InvalidKeyError from opaque_keys.edx.asides import AsideUsageKeyV2 from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF +from openedx_events.content_authoring.data import DuplicatedXBlockData +from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED +from openedx_events.testing import OpenEdxEventsTestMixin from pytz import UTC -from bs4 import BeautifulSoup from web_fragments.fragment import Fragment from webob import Response from xblock.core import XBlockAside @@ -32,37 +32,20 @@ from xblock.runtime import DictKeyValueStore, KvsFieldData from xblock.test.tools import TestRuntime from xblock.validation import ValidationMessage -from xmodule.course_block import DEFAULT_START_DATE -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_SPLIT_MODULESTORE, - ModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import ( - CourseFactory, - BlockFactory, - LibraryFactory, - check_mongo_calls, -) -from xmodule.partitions.partitions import ( - ENROLLMENT_TRACK_PARTITION_ID, - MINIMUM_UNUSED_PARTITION_ID, - Group, - UserPartition, -) -from xmodule.partitions.tests.test_partitions import MockPartitionService -from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.utils import ( - reverse_course_url, - reverse_usage_url, - duplicate_block, - update_from_source, -) +from cms.djangoapps.contentstore.utils import duplicate_block, reverse_course_url, reverse_usage_url, update_from_source from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as item_module +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( + ALWAYS, + VisibilityState, + _get_metadata_with_problem_defaults, + _get_source_index, + _xblock_type_and_display_name, + add_container_page_publishing_info, + create_xblock_info, + get_block_info, +) from common.djangoapps.student.tests.factories import StaffFactory, UserFactory from common.djangoapps.xblock_django.models import ( XBlockConfiguration, @@ -70,21 +53,28 @@ XBlockStudioConfigurationFlag, ) from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService +from common.test.utils import assert_dict_contains_subset from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_tagging import api as tagging_api - -from ..component import component_handler, DEFAULT_ADVANCED_MODULES, get_component_templates -from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( - ALWAYS, - VisibilityState, - get_block_info, - _get_source_index, - _xblock_type_and_display_name, - add_container_page_publishing_info, - create_xblock_info, +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE +from xmodule.course_block import DEFAULT_START_DATE +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, LibraryFactory, check_mongo_calls +from xmodule.partitions.partitions import ( + ENROLLMENT_TRACK_PARTITION_ID, + MINIMUM_UNUSED_PARTITION_ID, + Group, + UserPartition, ) -from common.test.utils import assert_dict_contains_subset +from xmodule.partitions.tests.test_partitions import MockPartitionService +from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW + +from ..component import DEFAULT_ADVANCED_MODULES, component_handler, get_component_templates class AsideTest(XBlockAside): @@ -126,7 +116,7 @@ def response_usage_key(self, response): :param response: """ parsed = json.loads(response.content.decode("utf-8")) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 key = UsageKey.from_string(parsed["locator"]) if key.course_key.run is None: key = key.map_into_course(CourseKey.from_string(parsed["courseKey"])) @@ -134,7 +124,7 @@ def response_usage_key(self, response): def create_xblock( self, parent_usage_key=None, display_name=None, category=None, boilerplate=None - ): # lint-amnesty, pylint: disable=missing-function-docstring + ): # pylint: disable=missing-function-docstring data = { "parent_locator": str(self.usage_key) if parent_usage_key is None @@ -154,7 +144,7 @@ def _create_vertical(self, parent_usage_key=None): resp = self.create_xblock( category="vertical", parent_usage_key=parent_usage_key ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 return self.response_usage_key(resp) @@ -176,12 +166,12 @@ def _get_container_preview(self, usage_key, data=None): Returns the HTML and resources required for the xblock at the specified UsageKey """ resp = self._get_preview(usage_key, data) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 resp_content = json.loads(resp.content.decode("utf-8")) html = resp_content["html"] - self.assertTrue(html) + self.assertTrue(html) # noqa: PT009 resources = resp_content["resources"] - self.assertIsNotNone(resources) + self.assertIsNotNone(resources) # noqa: PT009 return html, resources def _get_container_preview_with_error( @@ -189,7 +179,7 @@ def _get_container_preview_with_error( ): """Make request and asserts on response code and response contents""" resp = self._get_preview(usage_key, data) - self.assertEqual(resp.status_code, expected_code) + self.assertEqual(resp.status_code, expected_code) # noqa: PT009 if content_contains: self.assertContains(resp, content_contains, status_code=expected_code) return resp @@ -201,20 +191,20 @@ def test_get_vertical(self): # Retrieve it resp = self.client.get(reverse_usage_url("xblock_handler", usage_key)) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def test_get_empty_container_fragment(self): root_usage_key = self._create_vertical() html, __ = self._get_container_preview(root_usage_key) # XBlock messages are added by the Studio wrapper. - self.assertIn("wrapper-xblock-message", html) + self.assertIn("wrapper-xblock-message", html) # noqa: PT009 # Make sure that "wrapper-xblock" does not appear by itself (without -message at end). - self.assertNotRegex(html, r"wrapper-xblock[^-]+") + self.assertNotRegex(html, r"wrapper-xblock[^-]+") # noqa: PT009 # Verify that the header and article tags are still added - self.assertIn('
', html) - self.assertIn('
', html) + self.assertIn('
', html) # noqa: PT009 + self.assertIn('
', html) # noqa: PT009 def test_get_container_fragment(self): root_usage_key = self._create_vertical() @@ -227,18 +217,18 @@ def test_get_container_fragment(self): parent_usage_key=child_vertical_usage_key, category="problem", ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Get the preview HTML html, __ = self._get_container_preview(root_usage_key) # Verify that the Studio nesting wrapper has been added - self.assertIn("level-nesting", html) - self.assertIn('
', html) - self.assertIn('
', html) + self.assertIn("level-nesting", html) # noqa: PT009 + self.assertIn('
', html) # noqa: PT009 + self.assertIn('
', html) # noqa: PT009 # Verify that the Studio element wrapper has been added - self.assertIn("level-element", html) + self.assertIn("level-element", html) # noqa: PT009 def test_get_container_nested_container_fragment(self): """ @@ -248,23 +238,23 @@ def test_get_container_nested_container_fragment(self): root_usage_key = self._create_vertical() resp = self.create_xblock(parent_usage_key=root_usage_key, category="wrapper") - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 wrapper_usage_key = self.response_usage_key(resp) resp = self.create_xblock( parent_usage_key=wrapper_usage_key, category="problem", ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Get the preview HTML and verify the View -> link is present. html, __ = self._get_container_preview(root_usage_key) - self.assertIn("wrapper-xblock", html) - self.assertRegex( + self.assertIn("wrapper-xblock", html) # noqa: PT009 + self.assertRegex( # noqa: PT009 html, # The instance of the wrapper class will have an auto-generated ID. Allow any # characters after wrapper. - ( + ( # noqa: UP032 '"/container/{}" class="action-button xblock-view-action-button">' '\\s*View' ).format(re.escape(str(wrapper_usage_key))), @@ -282,7 +272,7 @@ def test_tag_count_in_container_fragment(self, mock_get_object_tag_counts): parent_usage_key=child_vertical_usage_key, category="problem", ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 usage_key = self.response_usage_key(resp) # Get the preview HTML with tags @@ -290,8 +280,8 @@ def test_tag_count_in_container_fragment(self, mock_get_object_tag_counts): str(usage_key): 13, } html, __ = self._get_container_preview(root_usage_key) - self.assertIn("wrapper-xblock", html) - self.assertIn('data-testid="tag-count-button"', html) + self.assertIn("wrapper-xblock", html) # noqa: PT009 + self.assertIn('data-testid="tag-count-button"', html) # noqa: PT009 def test_split_test(self): """ @@ -306,12 +296,12 @@ def test_split_test(self): parent_usage_key=split_test_usage_key, category="html", ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 resp = self.create_xblock( parent_usage_key=split_test_usage_key, category="html", ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def test_split_test_edited(self): """ @@ -336,8 +326,8 @@ def test_split_test_edited(self): data={"metadata": {"user_partition_id": str(0)}}, ) html, __ = self._get_container_preview(split_test_usage_key) - self.assertIn("alpha", html) - self.assertIn("beta", html) + self.assertIn("alpha", html) # noqa: PT009 + self.assertIn("beta", html) # noqa: PT009 # Rename groups in group configuration GROUP_CONFIGURATION_JSON = { @@ -363,12 +353,12 @@ def test_split_test_edited(self): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 201) # noqa: PT009 html, __ = self._get_container_preview(split_test_usage_key) - self.assertNotIn("alpha", html) - self.assertNotIn("beta", html) - self.assertIn("New_NAME_A", html) - self.assertIn("New_NAME_B", html) + self.assertNotIn("alpha", html) # noqa: PT009 + self.assertNotIn("beta", html) # noqa: PT009 + self.assertIn("New_NAME_A", html) # noqa: PT009 + self.assertIn("New_NAME_B", html) # noqa: PT009 def test_valid_paging(self): """ @@ -389,8 +379,8 @@ def test_valid_paging(self): ) call_args = patched_get_preview_fragment.call_args[0] _, _, context = call_args - self.assertIn("paging", context) - self.assertEqual({"page_number": 0, "page_size": 2}, context["paging"]) + self.assertIn("paging", context) # noqa: PT009 + self.assertEqual({"page_number": 0, "page_size": 2}, context["paging"]) # noqa: PT009 @ddt.data([1, "invalid"], ["invalid", 2]) @ddt.unpack @@ -435,11 +425,11 @@ def test_get_user_partitions_and_groups(self): resp = self.create_xblock(category="vertical") usage_key = self.response_usage_key(resp) resp = self.client.get(reverse_usage_url("xblock_handler", usage_key)) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Check that the partition and group information was returned result = json.loads(resp.content.decode("utf-8")) - self.assertEqual( + self.assertEqual( # noqa: PT009 result["user_partitions"], [ { @@ -476,7 +466,7 @@ def test_get_user_partitions_and_groups(self): }, ], ) - self.assertEqual(result["group_access"], {}) + self.assertEqual(result["group_access"], {}) # noqa: PT009 @ddt.data("ancestorInfo", "") def test_ancestor_info(self, field_type): @@ -526,9 +516,9 @@ def assert_xblock_info(xblock, xblock_info): xblock (XBlock): An XBlock item. xblock_info (dict): A dict containing xblock information. """ - self.assertEqual(str(xblock.location), xblock_info["id"]) - self.assertEqual(xblock.display_name, xblock_info["display_name"]) - self.assertEqual(xblock.category, xblock_info["category"]) + self.assertEqual(str(xblock.location), xblock_info["id"]) # noqa: PT009 + self.assertEqual(xblock.display_name, xblock_info["display_name"]) # noqa: PT009 + self.assertEqual(xblock.category, xblock_info["category"]) # noqa: PT009 for usage_key in ( problem_usage_key, @@ -541,18 +531,18 @@ def assert_xblock_info(xblock, xblock_info): reverse_usage_url("xblock_handler", usage_key) + f"?fields={field_type}" ) response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) if field_type == "ancestorInfo": - self.assertIn("ancestors", response) + self.assertIn("ancestors", response) # noqa: PT009 for ancestor_info in response["ancestors"]: parent_xblock = xblock.get_parent() assert_xblock_info(parent_xblock, ancestor_info) xblock = parent_xblock else: - self.assertNotIn("ancestors", response) + self.assertNotIn("ancestors", response) # noqa: PT009 xblock_info = get_block_info(xblock) - self.assertEqual(xblock_info, response) + self.assertEqual(xblock_info, response) # noqa: PT009 @ddt.ddt @@ -569,7 +559,7 @@ def test_delete_static_page(self): # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). resp = self.client.delete(reverse_usage_url("xblock_handler", usage_key)) - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 class TestCreateItem(ItemTest): @@ -588,14 +578,14 @@ def test_create_nicely(self): # get the new item and check its category and display_name chap_usage_key = self.response_usage_key(resp) new_obj = self.get_item_from_modulestore(chap_usage_key) - self.assertEqual(new_obj.scope_ids.block_type, "chapter") - self.assertEqual(new_obj.display_name, display_name) - self.assertEqual(new_obj.location.org, self.course.location.org) - self.assertEqual(new_obj.location.course, self.course.location.course) + self.assertEqual(new_obj.scope_ids.block_type, "chapter") # noqa: PT009 + self.assertEqual(new_obj.display_name, display_name) # noqa: PT009 + self.assertEqual(new_obj.location.org, self.course.location.org) # noqa: PT009 + self.assertEqual(new_obj.location.course, self.course.location.course) # noqa: PT009 # get the course and ensure it now points to this one course = self.get_item_from_modulestore(self.usage_key) - self.assertIn(chap_usage_key, course.children) + self.assertIn(chap_usage_key, course.children) # noqa: PT009 def test_create_block_negative(self): """ @@ -603,14 +593,14 @@ def test_create_block_negative(self): """ # non-existent boilerplate: creates a default resp = self.create_xblock(category="problem") - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 def test_create_with_future_date(self): - self.assertEqual(self.course.start, datetime(2030, 1, 1, tzinfo=UTC)) + self.assertEqual(self.course.start, DEFAULT_START_DATE) # noqa: PT009 resp = self.create_xblock(category="chapter") usage_key = self.response_usage_key(resp) obj = self.get_item_from_modulestore(usage_key) - self.assertEqual(obj.start, datetime(2030, 1, 1, tzinfo=UTC)) + self.assertEqual(obj.start, DEFAULT_START_DATE) # noqa: PT009 def test_static_tabs_initialization(self): """ @@ -622,7 +612,7 @@ def test_static_tabs_initialization(self): # Check that its name is not None new_tab = self.get_item_from_modulestore(usage_key) - self.assertEqual(new_tab.display_name, "Empty") + self.assertEqual(new_tab.display_name, "Empty") # noqa: PT009 class DuplicateHelper: @@ -636,7 +626,7 @@ def _duplicate_and_verify( """Duplicates the source, parenting to supplied parent. Then does equality check.""" usage_key = self._duplicate_item(parent_usage_key, source_usage_key) # pylint: disable=no-member - self.assertTrue( + self.assertTrue( # noqa: PT009 self._check_equality( source_usage_key, usage_key, parent_usage_key, check_asides=check_asides ), @@ -664,16 +654,16 @@ def _check_equality( if check_asides: original_asides = original_item.runtime.get_asides(original_item) duplicated_asides = duplicated_item.runtime.get_asides(duplicated_item) - self.assertEqual(len(original_asides), 1) - self.assertEqual(len(duplicated_asides), 1) - self.assertEqual(original_asides[0].field11, duplicated_asides[0].field11) - self.assertEqual(original_asides[0].field12, duplicated_asides[0].field12) - self.assertNotEqual( + self.assertEqual(len(original_asides), 1) # noqa: PT009 + self.assertEqual(len(duplicated_asides), 1) # noqa: PT009 + self.assertEqual(original_asides[0].field11, duplicated_asides[0].field11) # noqa: PT009 + self.assertEqual(original_asides[0].field12, duplicated_asides[0].field12) # noqa: PT009 + self.assertNotEqual( # noqa: PT009 original_asides[0].field13, duplicated_asides[0].field13 ) - self.assertEqual(duplicated_asides[0].field13, "aside1_default_value3") + self.assertEqual(duplicated_asides[0].field13, "aside1_default_value3") # noqa: PT009 - self.assertNotEqual( + self.assertNotEqual( # noqa: PT009 str(original_item.location), str(duplicated_item.location), "Location of duplicate should be different from original", @@ -682,13 +672,13 @@ def _check_equality( # Parent will only be equal for root of duplicated structure, in the case # where an item is duplicated in-place. if parent_usage_key and str(original_item.parent) == str(parent_usage_key): - self.assertEqual( + self.assertEqual( # noqa: PT009 str(parent_usage_key), str(duplicated_item.parent), "Parent of duplicate should equal parent of source for root xblock when duplicated in-place", ) else: - self.assertNotEqual( + self.assertNotEqual( # noqa: PT009 str(original_item.parent), str(duplicated_item.parent), "Parent duplicate should be different from source", @@ -702,7 +692,7 @@ def _check_equality( # Children will also be duplicated, so for the purposes of testing equality, we will set # the children to the original after recursively checking the children. if original_item.has_children: - self.assertEqual( + self.assertEqual( # noqa: PT009 len(original_item.children), len(duplicated_item.children), "Duplicated item differs in number of children", @@ -732,11 +722,11 @@ def _verify_duplicate_display_name( if original_item.display_name is not None: return ( duplicated_item.display_name - == "Duplicate of '{display_name}'".format( + == "Duplicate of '{display_name}'".format( # noqa: UP032 display_name=original_item.display_name ) ) - return duplicated_item.display_name == "Duplicate of {display_name}".format( + return duplicated_item.display_name == "Duplicate of {display_name}".format( # noqa: UP032 display_name=original_item.category ) @@ -756,7 +746,7 @@ def _duplicate_item(self, parent_usage_key, source_usage_key, display_name=None) return self.response_usage_key(resp) -class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin): +class TestDuplicateItem(OpenEdxEventsTestMixin, ItemTest, DuplicateHelper): """ Test the duplicate method. """ @@ -765,22 +755,6 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin): "org.openedx.content_authoring.xblock.duplicated.v1", ] - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - This method starts manually events isolation. Explanation here: - openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44 - """ - super().setUpClass() - cls.start_events_isolation() - - @classmethod - def tearDownClass(cls): - """ Don't let our event isolation affect other test cases """ - super().tearDownClass() - cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. - def setUp(self): """Creates the test course structure and a few components to 'duplicate'.""" super().setUp() @@ -858,21 +832,21 @@ def verify_order(source_usage_key, parent_usage_key, source_position=None): parent = self.get_item_from_modulestore(parent_usage_key) children = parent.children if source_position is None: - self.assertNotIn( + self.assertNotIn( # noqa: PT009 source_usage_key, children, "source item not expected in children array", ) - self.assertEqual( + self.assertEqual( # noqa: PT009 children[len(children) - 1], usage_key, "duplicated item not at end" ) else: - self.assertEqual( + self.assertEqual( # noqa: PT009 children[source_position], source_usage_key, "source item at wrong position", ) - self.assertEqual( + self.assertEqual( # noqa: PT009 children[source_position + 1], usage_key, "duplicated item not ordered after source item", @@ -900,7 +874,7 @@ def verify_name( parent_usage_key, source_usage_key, display_name ) duplicated_item = self.get_item_from_modulestore(usage_key) - self.assertEqual(duplicated_item.display_name, expected_name) + self.assertEqual(duplicated_item.display_name, expected_name) # noqa: PT009 return usage_key # Uses default display_name of 'Text' from HTML component. @@ -931,7 +905,7 @@ def test_shallow_duplicate(self): BlockFactory(parent=source_chapter, category="html", display_name="Child") # Refresh. source_chapter = self.store.get_item(source_chapter.location) - self.assertEqual(len(source_chapter.get_children()), 1) + self.assertEqual(len(source_chapter.get_children()), 1) # noqa: PT009 destination_course = CourseFactory() destination_location = duplicate_block( parent_usage_key=destination_course.location, @@ -942,8 +916,8 @@ def test_shallow_duplicate(self): ) # Refresh here, too, just to be sure. destination_chapter = self.store.get_item(destination_location) - self.assertEqual(len(destination_chapter.get_children()), 0) - self.assertEqual(destination_chapter.display_name, "Source Chapter") + self.assertEqual(len(destination_chapter.get_children()), 0) # noqa: PT009 + self.assertEqual(destination_chapter.display_name, "Source Chapter") # noqa: PT009 def test_duplicate_library_content_block(self): # pylint: disable=too-many-statements """ @@ -1109,7 +1083,7 @@ def test_duplicate_tags(self): user=user, ) dupe_chapter = self.store.get_item(dupe_location) - self.assertEqual(len(dupe_chapter.get_children()), 1) + self.assertEqual(len(dupe_chapter.get_children()), 1) # noqa: PT009 dupe_block = dupe_chapter.get_children()[0] # Check that the duplicated blocks also duplicated tags @@ -1237,8 +1211,8 @@ def setup_and_verify_content_experiment(self, partition_id): split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Initially, no user_partition_id is set, and the split_test has no children. - self.assertEqual(split_test.user_partition_id, -1) - self.assertEqual(len(split_test.children), 0) + self.assertEqual(split_test.user_partition_id, -1) # noqa: PT009 + self.assertEqual(len(split_test.children), 0) # noqa: PT009 # Set group configuration self.client.ajax_post( @@ -1246,8 +1220,8 @@ def setup_and_verify_content_experiment(self, partition_id): data={"metadata": {"user_partition_id": str(partition_id)}}, ) split_test = self.get_item_from_modulestore(self.split_test_usage_key) - self.assertEqual(split_test.user_partition_id, partition_id) - self.assertEqual( + self.assertEqual(split_test.user_partition_id, partition_id) # noqa: PT009 + self.assertEqual( # noqa: PT009 len(split_test.children), len(self.course.user_partitions[partition_id].groups), ) @@ -1292,24 +1266,24 @@ def assert_move_item(self, source_usage_key, target_usage_key, target_index=None response = self._move_component( source_usage_key, target_usage_key, target_index ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response["move_source_locator"], str(source_usage_key)) - self.assertEqual(response["parent_locator"], str(target_usage_key)) - self.assertEqual(response["source_index"], expected_index) + self.assertEqual(response["move_source_locator"], str(source_usage_key)) # noqa: PT009 + self.assertEqual(response["parent_locator"], str(target_usage_key)) # noqa: PT009 + self.assertEqual(response["source_index"], expected_index) # noqa: PT009 # Verify parent referance has been changed now. new_parent_loc = self.store.get_parent_location(source_usage_key) source_item = self.get_item_from_modulestore(source_usage_key) - self.assertEqual(source_item.parent, new_parent_loc) - self.assertEqual(new_parent_loc, target_usage_key) - self.assertNotEqual(parent_loc, new_parent_loc) + self.assertEqual(source_item.parent, new_parent_loc) # noqa: PT009 + self.assertEqual(new_parent_loc, target_usage_key) # noqa: PT009 + self.assertNotEqual(parent_loc, new_parent_loc) # noqa: PT009 # Assert item is present in children list of target parent and not source parent target_parent = self.get_item_from_modulestore(target_usage_key) source_parent = self.get_item_from_modulestore(parent_loc) - self.assertIn(source_usage_key, target_parent.children) - self.assertNotIn(source_usage_key, source_parent.children) + self.assertIn(source_usage_key, target_parent.children) # noqa: PT009 + self.assertNotIn(source_usage_key, source_parent.children) # noqa: PT009 def test_move_component(self): """ @@ -1329,7 +1303,7 @@ def test_move_source_index(self): """ parent = self.get_item_from_modulestore(self.vert_usage_key) children = parent.get_children() - self.assertEqual(len(children), 3) + self.assertEqual(len(children), 3) # noqa: PT009 # Create a component within vert2. resp = self.create_xblock( @@ -1341,8 +1315,8 @@ def test_move_source_index(self): self.assert_move_item(html2_usage_key, self.vert_usage_key, 1) parent = self.get_item_from_modulestore(self.vert_usage_key) children = parent.get_children() - self.assertEqual(len(children), 4) - self.assertEqual(children[1].location, html2_usage_key) + self.assertEqual(len(children), 4) # noqa: PT009 + self.assertEqual(children[1].location, html2_usage_key) # noqa: PT009 def test_move_undo(self): """ @@ -1355,22 +1329,22 @@ def test_move_undo(self): # Move component and verify that response contains initial index response = self._move_component(self.html_usage_key, self.vert2_usage_key) response = json.loads(response.content.decode("utf-8")) - self.assertEqual(original_index, response["source_index"]) + self.assertEqual(original_index, response["source_index"]) # noqa: PT009 # Verify that new parent has the moved component at the last index. parent = self.get_item_from_modulestore(self.vert2_usage_key) - self.assertEqual(self.html_usage_key, parent.children[-1]) + self.assertEqual(self.html_usage_key, parent.children[-1]) # noqa: PT009 # Verify original and new index is different now. source_index = _get_source_index(self.html_usage_key, parent) - self.assertNotEqual(original_index, source_index) + self.assertNotEqual(original_index, source_index) # noqa: PT009 # Undo Move to the original index, use the source index fetched from the response. response = self._move_component( self.html_usage_key, self.vert_usage_key, response["source_index"] ) response = json.loads(response.content.decode("utf-8")) - self.assertEqual(original_index, response["source_index"]) + self.assertEqual(original_index, response["source_index"]) # noqa: PT009 def test_move_large_target_index(self): """ @@ -1381,17 +1355,17 @@ def test_move_large_target_index(self): response = self._move_component( self.html_usage_key, self.vert2_usage_key, parent_children_length + 10 ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) expected_error = ( - "You can not move {usage_key} at an invalid index ({target_index}).".format( + "You can not move {usage_key} at an invalid index ({target_index}).".format( # noqa: UP032 usage_key=self.html_usage_key, target_index=parent_children_length + 10 ) ) - self.assertEqual(expected_error, response["error"]) + self.assertEqual(expected_error, response["error"]) # noqa: PT009 new_parent_loc = self.store.get_parent_location(self.html_usage_key) - self.assertEqual(new_parent_loc, self.vert_usage_key) + self.assertEqual(new_parent_loc, self.vert_usage_key) # noqa: PT009 def test_invalid_move(self): """ @@ -1399,31 +1373,31 @@ def test_invalid_move(self): """ parent_loc = self.store.get_parent_location(self.html_usage_key) response = self._move_component(self.html_usage_key, self.seq_usage_key) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - expected_error = "You can not move {source_type} into {target_type}.".format( + expected_error = "You can not move {source_type} into {target_type}.".format( # noqa: UP032 source_type=self.html_usage_key.block_type, target_type=self.seq_usage_key.block_type, ) - self.assertEqual(expected_error, response["error"]) + self.assertEqual(expected_error, response["error"]) # noqa: PT009 new_parent_loc = self.store.get_parent_location(self.html_usage_key) - self.assertEqual(new_parent_loc, parent_loc) + self.assertEqual(new_parent_loc, parent_loc) # noqa: PT009 def test_move_current_parent(self): """ Test that a component can not be moved to it's current parent. """ parent_loc = self.store.get_parent_location(self.html_usage_key) - self.assertEqual(parent_loc, self.vert_usage_key) + self.assertEqual(parent_loc, self.vert_usage_key) # noqa: PT009 response = self._move_component(self.html_usage_key, self.vert_usage_key) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual( + self.assertEqual( # noqa: PT009 response["error"], "Item is already present in target location." ) - self.assertEqual( + self.assertEqual( # noqa: PT009 self.store.get_parent_location(self.html_usage_key), parent_loc ) @@ -1438,15 +1412,15 @@ def test_can_not_move_into_itself(self): ) library_content_usage_key = self.response_usage_key(library_content) parent_loc = self.store.get_parent_location(library_content_usage_key) - self.assertEqual(parent_loc, self.vert_usage_key) + self.assertEqual(parent_loc, self.vert_usage_key) # noqa: PT009 response = self._move_component( library_content_usage_key, library_content_usage_key ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response["error"], "You can not move an item into itself.") - self.assertEqual( + self.assertEqual(response["error"], "You can not move an item into itself.") # noqa: PT009 + self.assertEqual( # noqa: PT009 self.store.get_parent_location(self.html_usage_key), parent_loc ) @@ -1461,7 +1435,7 @@ def test_move_library_content(self): ) library_content_usage_key = self.response_usage_key(library_content) parent_loc = self.store.get_parent_location(library_content_usage_key) - self.assertEqual(parent_loc, self.vert_usage_key) + self.assertEqual(parent_loc, self.vert_usage_key) # noqa: PT009 self.assert_move_item(library_content_usage_key, self.vert2_usage_key) def test_move_into_library_content(self): @@ -1515,14 +1489,14 @@ def test_can_not_move_into_content_experiment_level(self): """ self.setup_and_verify_content_experiment(0) response = self._move_component(self.html_usage_key, self.split_test_usage_key) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual( + self.assertEqual( # noqa: PT009 response["error"], "You can not move an item directly into content experiment.", ) - self.assertEqual( + self.assertEqual( # noqa: PT009 self.store.get_parent_location(self.html_usage_key), self.vert_usage_key ) @@ -1537,13 +1511,13 @@ def test_can_not_move_content_experiment_into_its_children(self): response = self._move_component( self.split_test_usage_key, child_vert_usage_key ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual( + self.assertEqual( # noqa: PT009 response["error"], "You can not move an item into it's child." ) - self.assertEqual( + self.assertEqual( # noqa: PT009 self.store.get_parent_location(self.split_test_usage_key), self.vert_usage_key, ) @@ -1563,11 +1537,11 @@ def test_can_not_move_content_experiment_into_its_children(self): response = self._move_component( self.split_test_usage_key, child_split_test.children[0] ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response["error"], "You can not move an item into it's child.") - self.assertEqual( + self.assertEqual(response["error"], "You can not move an item into it's child.") # noqa: PT009 + self.assertEqual( # noqa: PT009 self.store.get_parent_location(self.split_test_usage_key), self.vert_usage_key, ) @@ -1581,20 +1555,20 @@ def test_move_invalid_source_index(self): response = self._move_component( self.html_usage_key, self.vert2_usage_key, target_index ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) error = f"You must provide target_index ({target_index}) as an integer." - self.assertEqual(response["error"], error) + self.assertEqual(response["error"], error) # noqa: PT009 new_parent_loc = self.store.get_parent_location(self.html_usage_key) - self.assertEqual(new_parent_loc, parent_loc) + self.assertEqual(new_parent_loc, parent_loc) # noqa: PT009 def test_move_no_target_locator(self): """ Test move an item without specifying the target location. """ data = {"move_source_locator": str(self.html_usage_key)} - with self.assertRaises(InvalidKeyError): + with self.assertRaises(InvalidKeyError): # noqa: PT027 self.client.patch( reverse("xblock_handler"), json.dumps(data), @@ -1606,9 +1580,9 @@ def test_no_move_source_locator(self): Test patch request without providing a move source locator. """ response = self.client.patch(reverse("xblock_handler")) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 response = json.loads(response.content.decode("utf-8")) - self.assertEqual( + self.assertEqual( # noqa: PT009 response["error"], "Patch request did not recognise any parameters to handle.", ) @@ -1619,8 +1593,8 @@ def _verify_validation_message( """ Verify that the validation message has the expected validation message and type. """ - self.assertEqual(message.text, expected_message) - self.assertEqual(message.type, expected_message_type) + self.assertEqual(message.text, expected_message) # noqa: PT009 + self.assertEqual(message.type, expected_message_type) # noqa: PT009 def test_move_component_nonsensical_access_restriction_validation(self): """ @@ -1643,7 +1617,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): self.course, course_id=self.course.id, ) - html.runtime._services["partitions"] = partitions_service # lint-amnesty, pylint: disable=protected-access + html.runtime._services["partitions"] = partitions_service # pylint: disable=protected-access # Set access settings so html will contradict vert2 when moved into that unit vert1.group_access = {self.course.user_partitions[0].id: [group2.id]} @@ -1655,14 +1629,14 @@ def test_move_component_nonsensical_access_restriction_validation(self): # Verify that there is no warning when html is in a non contradicting unit validation = html.validate() - self.assertEqual(len(validation.messages), 0) + self.assertEqual(len(validation.messages), 0) # noqa: PT009 # Now move it and confirm that the html component has been moved into vertical 2 self.assert_move_item(self.html_usage_key, self.vert2_usage_key) html.parent = self.vert2_usage_key html = self.store.update_item(html, self.user.id) validation = html.validate() - self.assertEqual(len(validation.messages), 1) + self.assertEqual(len(validation.messages), 1) # noqa: PT009 self._verify_validation_message( validation.messages[0], NONSENSICAL_ACCESS_RESTRICTION, @@ -1674,7 +1648,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): html.parent = self.vert_usage_key html = self.store.update_item(html, self.user.id) validation = html.validate() - self.assertEqual(len(validation.messages), 0) + self.assertEqual(len(validation.messages), 0) # noqa: PT009 @patch("cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.log") def test_move_logging(self, mock_logger): @@ -1704,7 +1678,7 @@ def test_move_and_discard_changes(self): old_parent_loc = self.store.get_parent_location(self.html_usage_key) # Check that old_parent_loc is not yet published. - self.assertFalse( + self.assertFalse( # noqa: PT009 self.store.has_item( old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only ) @@ -1717,18 +1691,18 @@ def test_move_and_discard_changes(self): ) # Check that old_parent_loc is now published. - self.assertTrue( + self.assertTrue( # noqa: PT009 self.store.has_item( old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only ) ) - self.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc))) + self.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc))) # noqa: PT009 # Move component html_usage_key in vert2_usage_key self.assert_move_item(self.html_usage_key, self.vert2_usage_key) # Check old_parent_loc becomes in draft mode now. - self.assertTrue(self.store.has_changes(self.store.get_item(old_parent_loc))) + self.assertTrue(self.store.has_changes(self.store.get_item(old_parent_loc))) # noqa: PT009 # Now discard changes in old_parent_loc self.client.ajax_post( @@ -1737,25 +1711,25 @@ def test_move_and_discard_changes(self): ) # Check that old_parent_loc now is reverted to publish. Changes discarded, html_usage_key moved back. - self.assertTrue( + self.assertTrue( # noqa: PT009 self.store.has_item( old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only ) ) - self.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc))) + self.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc))) # noqa: PT009 # Now source item should be back in the old parent. source_item = self.get_item_from_modulestore(self.html_usage_key) - self.assertEqual(source_item.parent, old_parent_loc) - self.assertEqual( + self.assertEqual(source_item.parent, old_parent_loc) # noqa: PT009 + self.assertEqual( # noqa: PT009 self.store.get_parent_location(self.html_usage_key), source_item.parent ) # Also, check that item is not present in target parent but in source parent target_parent = self.get_item_from_modulestore(self.vert2_usage_key) source_parent = self.get_item_from_modulestore(old_parent_loc) - self.assertIn(self.html_usage_key, source_parent.children) - self.assertNotIn(self.html_usage_key, target_parent.children) + self.assertIn(self.html_usage_key, source_parent.children) # noqa: PT009 + self.assertNotIn(self.html_usage_key, target_parent.children) # noqa: PT009 def test_move_item_not_found(self): """ @@ -1769,7 +1743,7 @@ def test_move_item_not_found(self): ), "parent_locator": str(self.vert2_usage_key), } - with self.assertRaises(ItemNotFoundError): + with self.assertRaises(ItemNotFoundError): # noqa: PT027 self.client.patch( reverse("xblock_handler"), json.dumps(data), @@ -1834,9 +1808,9 @@ def create_aside(usage_key, block_type): scope_ids=ScopeIds("user", block_type, def_id, usage_id), runtime=runtime, ) - aside.field11 = "%s_new_value11" % block_type - aside.field12 = "%s_new_value12" % block_type - aside.field13 = "%s_new_value13" % block_type + aside.field11 = "%s_new_value11" % block_type # noqa: UP031 + aside.field12 = "%s_new_value12" % block_type # noqa: UP031 + aside.field13 = "%s_new_value13" % block_type # noqa: UP031 self.store.update_item(item, self.user.id, asides=[aside]) @@ -1909,30 +1883,30 @@ def test_delete_field(self): self.problem_update_url, data={"metadata": {"rerandomize": "onreset"}} ) problem = self.get_item_from_modulestore(self.problem_usage_key) - self.assertEqual(problem.rerandomize, 'onreset') + self.assertEqual(problem.rerandomize, 'onreset') # noqa: PT009 self.client.ajax_post( self.problem_update_url, data={"metadata": {"rerandomize": None}} ) problem = self.get_item_from_modulestore(self.problem_usage_key) - self.assertEqual(problem.rerandomize, 'never') + self.assertEqual(problem.rerandomize, 'never') # noqa: PT009 def test_date_fields(self): """ Test setting due & start dates on sequential """ sequential = self.get_item_from_modulestore(self.seq_usage_key) - self.assertIsNone(sequential.due) + self.assertIsNone(sequential.due) # noqa: PT009 self.client.ajax_post( self.seq_update_url, data={"metadata": {"due": "2010-11-22T04:00Z"}} ) sequential = self.get_item_from_modulestore(self.seq_usage_key) - self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) + self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) # noqa: PT009 self.client.ajax_post( self.seq_update_url, data={"metadata": {"start": "2010-09-12T14:00Z"}} ) sequential = self.get_item_from_modulestore(self.seq_usage_key) - self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) - self.assertEqual(sequential.start, datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) + self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) # noqa: PT009 + self.assertEqual(sequential.start, datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) # noqa: PT009 @ddt.data( "1000-01-01T00:00Z", @@ -1954,8 +1928,8 @@ def test_xblock_due_date_validity(self, date): user=self.user, ) # Both display and actual value should be None - self.assertEqual(xblock_info["due_date"], "") - self.assertIsNone(xblock_info["due"]) + self.assertEqual(xblock_info["due_date"], "") # noqa: PT009 + self.assertIsNone(xblock_info["due"]) # noqa: PT009 def test_update_generic_fields(self): new_display_name = "New Display Name" @@ -1970,8 +1944,8 @@ def test_update_generic_fields(self): }, ) problem = self.get_item_from_modulestore(self.problem_usage_key) - self.assertEqual(problem.display_name, new_display_name) - self.assertEqual(problem.max_attempts, new_max_attempts) + self.assertEqual(problem.display_name, new_display_name) # noqa: PT009 + self.assertEqual(problem.max_attempts, new_max_attempts) # noqa: PT009 def test_delete_child(self): """ @@ -1984,19 +1958,19 @@ def test_delete_child(self): chapter2_usage_key = self.response_usage_key(resp_2) course = self.get_item_from_modulestore(self.usage_key) - self.assertIn(chapter1_usage_key, course.children) - self.assertIn(chapter2_usage_key, course.children) + self.assertIn(chapter1_usage_key, course.children) # noqa: PT009 + self.assertIn(chapter2_usage_key, course.children) # noqa: PT009 # Remove one child from the course. resp = self.client.delete( reverse_usage_url("xblock_handler", chapter1_usage_key) ) - self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.status_code, 204) # noqa: PT009 # Verify that the child is removed. course = self.get_item_from_modulestore(self.usage_key) - self.assertNotIn(chapter1_usage_key, course.children) - self.assertIn(chapter2_usage_key, course.children) + self.assertNotIn(chapter1_usage_key, course.children) # noqa: PT009 + self.assertIn(chapter2_usage_key, course.children) # noqa: PT009 def test_reorder_children(self): """ @@ -2017,8 +1991,8 @@ def test_reorder_children(self): # Children must be on the sequential to reproduce the original bug, # as it is important that the parent (sequential) NOT be in the draft store. children = self.get_item_from_modulestore(self.seq_usage_key).children - self.assertEqual(unit1_usage_key, children[1]) - self.assertEqual(unit2_usage_key, children[2]) + self.assertEqual(unit1_usage_key, children[1]) # noqa: PT009 + self.assertEqual(unit2_usage_key, children[2]) # noqa: PT009 resp = self.client.ajax_post( self.seq_update_url, @@ -2030,12 +2004,12 @@ def test_reorder_children(self): ] }, ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 children = self.get_item_from_modulestore(self.seq_usage_key).children - self.assertEqual(self.problem_usage_key, children[0]) - self.assertEqual(unit1_usage_key, children[2]) - self.assertEqual(unit2_usage_key, children[1]) + self.assertEqual(self.problem_usage_key, children[0]) # noqa: PT009 + self.assertEqual(unit1_usage_key, children[2]) # noqa: PT009 + self.assertEqual(unit2_usage_key, children[1]) # noqa: PT009 def test_move_parented_child(self): """ @@ -2060,14 +2034,14 @@ def test_move_parented_child(self): resp = self.client.ajax_post( self.seq2_update_url, data={"children": [str(unit_1_key), str(unit_2_key)]} ) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # verify children - self.assertListEqual( + self.assertListEqual( # noqa: PT009 self.get_item_from_modulestore(self.seq2_usage_key).children, [unit_1_key, unit_2_key], ) - self.assertListEqual( + self.assertListEqual( # noqa: PT009 self.get_item_from_modulestore(self.seq_usage_key).children, [self.problem_usage_key], # problem child created in setUp ) @@ -2089,7 +2063,7 @@ def test_move_orphaned_child_error(self): ) # verify children - self.assertListEqual( + self.assertListEqual( # noqa: PT009 self.get_item_from_modulestore(self.seq2_usage_key).children, [] ) @@ -2121,7 +2095,7 @@ def test_move_child_creates_orphan_error(self): ) # verify children - self.assertListEqual( + self.assertListEqual( # noqa: PT009 self.get_item_from_modulestore(self.seq2_usage_key).children, [unit_1_key, unit_2_key], ) @@ -2138,20 +2112,20 @@ def _verify_published_with_no_draft(self, location): """ Verifies the item with given location has a published version and no draft (unpublished changes). """ - self.assertTrue(self._is_location_published(location)) - self.assertFalse(modulestore().has_changes(modulestore().get_item(location))) + self.assertTrue(self._is_location_published(location)) # noqa: PT009 + self.assertFalse(modulestore().has_changes(modulestore().get_item(location))) # noqa: PT009 def _verify_published_with_draft(self, location): """ Verifies the item with given location has a published version and also a draft version (unpublished changes). """ - self.assertTrue(self._is_location_published(location)) - self.assertTrue(modulestore().has_changes(modulestore().get_item(location))) + self.assertTrue(self._is_location_published(location)) # noqa: PT009 + self.assertTrue(modulestore().has_changes(modulestore().get_item(location))) # noqa: PT009 def test_make_public(self): """Test making a private problem public (publishing it).""" # When the problem is first created, it is only in draft (because of its category). - self.assertFalse(self._is_location_published(self.problem_usage_key)) + self.assertFalse(self._is_location_published(self.problem_usage_key)) # noqa: PT009 self.client.ajax_post(self.problem_update_url, data={"publish": "make_public"}) self._verify_published_with_no_draft(self.problem_usage_key) @@ -2170,14 +2144,14 @@ def test_revert_to_published(self): self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only, ) - self.assertIsNone(published.due) + self.assertIsNone(published.due) # noqa: PT009 def test_republish(self): """Test republishing an item.""" new_display_name = "New Display Name" # When the problem is first created, it is only in draft (because of its category). - self.assertFalse(self._is_location_published(self.problem_usage_key)) + self.assertFalse(self._is_location_published(self.problem_usage_key)) # noqa: PT009 # Republishing when only in draft will update the draft but not cause a public item to be created. self.client.ajax_post( @@ -2187,9 +2161,9 @@ def test_republish(self): "metadata": {"display_name": new_display_name}, }, ) - self.assertFalse(self._is_location_published(self.problem_usage_key)) + self.assertFalse(self._is_location_published(self.problem_usage_key)) # noqa: PT009 draft = self.get_item_from_modulestore(self.problem_usage_key) - self.assertEqual(draft.display_name, new_display_name) + self.assertEqual(draft.display_name, new_display_name) # noqa: PT009 # Publish the item self.client.ajax_post(self.problem_update_url, data={"publish": "make_public"}) @@ -2208,7 +2182,7 @@ def test_republish(self): self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only, ) - self.assertEqual(published.display_name, new_display_name_2) + self.assertEqual(published.display_name, new_display_name_2) # noqa: PT009 def test_direct_only_categories_not_republished(self): """Verify that republish is ignored for items in DIRECT_ONLY_CATEGORIES""" @@ -2240,20 +2214,20 @@ def _make_draft_content_different_from_published(self): published = modulestore().get_item( self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only, - ) # lint-amnesty, pylint: disable=line-too-long + ) # pylint: disable=line-too-long # Update the draft version and check that published is different. self.client.ajax_post( self.problem_update_url, data={"metadata": {"due": "2077-10-10T04:00Z"}} ) updated_draft = self.get_item_from_modulestore(self.problem_usage_key) - self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) - self.assertIsNone(published.due) + self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) # noqa: PT009 + self.assertIsNone(published.due) # noqa: PT009 # Fetch the published version again to make sure the due date is still unset. published = modulestore().get_item( published.location, revision=ModuleStoreEnum.RevisionOption.published_only ) - self.assertIsNone(published.due) + self.assertIsNone(published.due) # noqa: PT009 def test_make_public_with_update(self): """Update a problem and make it public at the same time.""" @@ -2262,7 +2236,7 @@ def test_make_public_with_update(self): data={"metadata": {"due": "2077-10-10T04:00Z"}, "publish": "make_public"}, ) published = self.get_item_from_modulestore(self.problem_usage_key) - self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) + self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) # noqa: PT009 def test_published_and_draft_contents_with_update(self): """Create a draft and publish it then modify the draft and check that published content is not modified""" @@ -2287,30 +2261,30 @@ def test_published_and_draft_contents_with_update(self): # Both published and draft content should be different draft = self.get_item_from_modulestore(self.problem_usage_key) - self.assertNotEqual(draft.data, published.data) + self.assertNotEqual(draft.data, published.data) # noqa: PT009 # Get problem by 'xblock_handler' view_url = reverse_usage_url( "xblock_view_handler", self.problem_usage_key, {"view_name": STUDENT_VIEW} ) resp = self.client.get(view_url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Activate the editing view view_url = reverse_usage_url( "xblock_view_handler", self.problem_usage_key, {"view_name": STUDIO_VIEW} ) resp = self.client.get(view_url, HTTP_ACCEPT="application/json") - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 # Both published and draft content should still be different draft = self.get_item_from_modulestore(self.problem_usage_key) - self.assertNotEqual(draft.data, published.data) + self.assertNotEqual(draft.data, published.data) # noqa: PT009 # Fetch the published version again to make sure the data is correct. published = modulestore().get_item( published.location, revision=ModuleStoreEnum.RevisionOption.published_only ) - self.assertNotEqual(draft.data, published.data) + self.assertNotEqual(draft.data, published.data) # noqa: PT009 def test_publish_states_of_nested_xblocks(self): """Test publishing of a unit page containing a nested xblock""" @@ -2328,12 +2302,12 @@ def test_publish_states_of_nested_xblocks(self): # The unit and its children should be private initially unit_update_url = reverse_usage_url("xblock_handler", unit_usage_key) - self.assertFalse(self._is_location_published(unit_usage_key)) - self.assertFalse(self._is_location_published(html_usage_key)) + self.assertFalse(self._is_location_published(unit_usage_key)) # noqa: PT009 + self.assertFalse(self._is_location_published(html_usage_key)) # noqa: PT009 # Make the unit public and verify that the problem is also made public resp = self.client.ajax_post(unit_update_url, data={"publish": "make_public"}) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 self._verify_published_with_no_draft(unit_usage_key) self._verify_published_with_no_draft(html_usage_key) @@ -2357,10 +2331,10 @@ def test_field_value_errors(self): }, }, ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 parsed = json.loads(response.content.decode("utf-8")) - self.assertIn("error", parsed) - self.assertIn( + self.assertIn("error", parsed) # noqa: PT009 + self.assertIn( # noqa: PT009 "Incorrect RelativeTime value", parsed["error"] ) # See xmodule/fields.py @@ -2441,7 +2415,7 @@ def _update_partition_id(self, partition_id): # Verify the partition_id was saved. split_test = self.get_item_from_modulestore(self.split_test_usage_key) - self.assertEqual(partition_id, split_test.user_partition_id) + self.assertEqual(partition_id, split_test.user_partition_id) # noqa: PT009 return split_test def _assert_children(self, expected_number): @@ -2449,7 +2423,7 @@ def _assert_children(self, expected_number): Verifies the number of children of the split_test instance. """ split_test = self.get_item_from_modulestore(self.split_test_usage_key) - self.assertEqual(expected_number, len(split_test.children)) + self.assertEqual(expected_number, len(split_test.children)) # noqa: PT009 return split_test def test_create_groups(self): @@ -2459,32 +2433,32 @@ def test_create_groups(self): """ split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Initially, no user_partition_id is set, and the split_test has no children. - self.assertEqual(-1, split_test.user_partition_id) - self.assertEqual(0, len(split_test.children)) + self.assertEqual(-1, split_test.user_partition_id) # noqa: PT009 + self.assertEqual(0, len(split_test.children)) # noqa: PT009 # Set the user_partition_id to match the first user_partition. split_test = self._update_partition_id(self.first_user_partition.id) # Verify that child verticals have been set to match the groups - self.assertEqual(2, len(split_test.children)) + self.assertEqual(2, len(split_test.children)) # noqa: PT009 vertical_0 = self.get_item_from_modulestore(split_test.children[0]) vertical_1 = self.get_item_from_modulestore(split_test.children[1]) - self.assertEqual("vertical", vertical_0.category) - self.assertEqual("vertical", vertical_1.category) - self.assertEqual( + self.assertEqual("vertical", vertical_0.category) # noqa: PT009 + self.assertEqual("vertical", vertical_1.category) # noqa: PT009 + self.assertEqual( # noqa: PT009 "Group ID " + str(MINIMUM_UNUSED_PARTITION_ID + 1), vertical_0.display_name ) - self.assertEqual( + self.assertEqual( # noqa: PT009 "Group ID " + str(MINIMUM_UNUSED_PARTITION_ID + 2), vertical_1.display_name ) # Verify that the group_id_to_child mapping is correct. - self.assertEqual(2, len(split_test.group_id_to_child)) - self.assertEqual( + self.assertEqual(2, len(split_test.group_id_to_child)) # noqa: PT009 + self.assertEqual( # noqa: PT009 vertical_0.location, split_test.group_id_to_child[str(self.first_user_partition_group_1.id)], ) - self.assertEqual( + self.assertEqual( # noqa: PT009 vertical_1.location, split_test.group_id_to_child[str(self.first_user_partition_group_2.id)], ) @@ -2495,12 +2469,12 @@ def test_split_xblock_info_group_name(self): """ split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Initially, no user_partition_id is set, and the split_test has no children. - self.assertEqual(split_test.user_partition_id, -1) - self.assertEqual(len(split_test.children), 0) + self.assertEqual(split_test.user_partition_id, -1) # noqa: PT009 + self.assertEqual(len(split_test.children), 0) # noqa: PT009 # Set the user_partition_id to match the first user_partition. split_test = self._update_partition_id(self.first_user_partition.id) # Verify that child verticals have been set to match the groups - self.assertEqual(len(split_test.children), 2) + self.assertEqual(len(split_test.children), 2) # noqa: PT009 # Get xblock outline xblock_info = create_xblock_info( @@ -2511,10 +2485,10 @@ def test_split_xblock_info_group_name(self): course=self.course, user=self.request.user, ) - self.assertEqual( + self.assertEqual( # noqa: PT009 xblock_info["child_info"]["children"][0]["display_name"], "alpha" ) - self.assertEqual( + self.assertEqual( # noqa: PT009 xblock_info["child_info"]["children"][1]["display_name"], "beta" ) @@ -2525,36 +2499,36 @@ def test_change_user_partition_id(self): """ # Set to first group configuration. split_test = self._update_partition_id(self.first_user_partition.id) - self.assertEqual(2, len(split_test.children)) + self.assertEqual(2, len(split_test.children)) # noqa: PT009 initial_vertical_0_location = split_test.children[0] initial_vertical_1_location = split_test.children[1] # Set to second group configuration split_test = self._update_partition_id(self.second_user_partition.id) # We don't remove existing children. - self.assertEqual(5, len(split_test.children)) - self.assertEqual(initial_vertical_0_location, split_test.children[0]) - self.assertEqual(initial_vertical_1_location, split_test.children[1]) + self.assertEqual(5, len(split_test.children)) # noqa: PT009 + self.assertEqual(initial_vertical_0_location, split_test.children[0]) # noqa: PT009 + self.assertEqual(initial_vertical_1_location, split_test.children[1]) # noqa: PT009 vertical_0 = self.get_item_from_modulestore(split_test.children[2]) vertical_1 = self.get_item_from_modulestore(split_test.children[3]) vertical_2 = self.get_item_from_modulestore(split_test.children[4]) # Verify that the group_id_to child mapping is correct. - self.assertEqual(3, len(split_test.group_id_to_child)) - self.assertEqual( + self.assertEqual(3, len(split_test.group_id_to_child)) # noqa: PT009 + self.assertEqual( # noqa: PT009 vertical_0.location, split_test.group_id_to_child[str(self.second_user_partition_group_1.id)], ) - self.assertEqual( + self.assertEqual( # noqa: PT009 vertical_1.location, split_test.group_id_to_child[str(self.second_user_partition_group_2.id)], ) - self.assertEqual( + self.assertEqual( # noqa: PT009 vertical_2.location, split_test.group_id_to_child[str(self.second_user_partition_group_3.id)], ) - self.assertNotEqual(initial_vertical_0_location, vertical_0.location) - self.assertNotEqual(initial_vertical_1_location, vertical_1.location) + self.assertNotEqual(initial_vertical_0_location, vertical_0.location) # noqa: PT009 + self.assertNotEqual(initial_vertical_1_location, vertical_1.location) # noqa: PT009 def test_change_same_user_partition_id(self): """ @@ -2562,13 +2536,13 @@ def test_change_same_user_partition_id(self): """ # Set to first group configuration. split_test = self._update_partition_id(self.first_user_partition.id) - self.assertEqual(2, len(split_test.children)) + self.assertEqual(2, len(split_test.children)) # noqa: PT009 initial_group_id_to_child = split_test.group_id_to_child # Set again to first group configuration. split_test = self._update_partition_id(self.first_user_partition.id) - self.assertEqual(2, len(split_test.children)) - self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child) + self.assertEqual(2, len(split_test.children)) # noqa: PT009 + self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child) # noqa: PT009 def test_change_non_existent_user_partition_id(self): """ @@ -2578,13 +2552,13 @@ def test_change_non_existent_user_partition_id(self): """ # Set to first group configuration. split_test = self._update_partition_id(self.first_user_partition.id) - self.assertEqual(2, len(split_test.children)) + self.assertEqual(2, len(split_test.children)) # noqa: PT009 initial_group_id_to_child = split_test.group_id_to_child # Set to an group configuration that doesn't exist. split_test = self._update_partition_id(-50) - self.assertEqual(2, len(split_test.children)) - self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child) + self.assertEqual(2, len(split_test.children)) # noqa: PT009 + self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child) # noqa: PT009 def test_add_groups(self): """ @@ -2615,7 +2589,7 @@ def test_add_groups(self): # group_id_to_child and children have not changed yet. split_test = self._assert_children(2) group_id_to_child = split_test.group_id_to_child.copy() - self.assertEqual(2, len(group_id_to_child)) + self.assertEqual(2, len(group_id_to_child)) # noqa: PT009 # SplitModuleStoreRuntime is used in tests. # SplitModuleStoreRuntime doesn't have user service, that's needed for @@ -2627,14 +2601,14 @@ def test_add_groups(self): # Call add_missing_groups method to add the missing group. split_test.add_missing_groups(self.request) split_test = self._assert_children(3) - self.assertNotEqual(group_id_to_child, split_test.group_id_to_child) + self.assertNotEqual(group_id_to_child, split_test.group_id_to_child) # noqa: PT009 group_id_to_child = split_test.group_id_to_child - self.assertEqual(split_test.children[2], group_id_to_child[new_group_id]) + self.assertEqual(split_test.children[2], group_id_to_child[new_group_id]) # noqa: PT009 # Call add_missing_groups again -- it should be a no-op. split_test.add_missing_groups(self.request) split_test = self._assert_children(3) - self.assertEqual(group_id_to_child, split_test.group_id_to_child) + self.assertEqual(group_id_to_child, split_test.group_id_to_child) # noqa: PT009 @ddt.ddt @@ -2670,15 +2644,15 @@ def setUp(self): def test_invalid_handler(self): self.block.handle.side_effect = NoSuchHandlerError - with self.assertRaises(Http404): + with self.assertRaises(Http404): # noqa: PT027 component_handler(self.request, self.usage_key_string, "invalid_handler") @ddt.data("GET", "POST", "PUT", "DELETE") def test_request_method(self, method): def check_handler( handler, request, suffix - ): # lint-amnesty, pylint: disable=unused-argument - self.assertEqual(request.method, method) + ): # pylint: disable=unused-argument + self.assertEqual(request.method, method) # noqa: PT009 return Response() self.block.handle = check_handler @@ -2693,12 +2667,12 @@ def check_handler( def test_response_code(self, status_code): def create_response( handler, request, suffix - ): # lint-amnesty, pylint: disable=unused-argument + ): # pylint: disable=unused-argument return Response(status_code=status_code) self.block.handle = create_response - self.assertEqual( + self.assertEqual( # noqa: PT009 component_handler( self.request, self.usage_key_string, "dummy_handler" ).status_code, @@ -2716,7 +2690,7 @@ def test_submit_studio_edits_checks_author_permission(self, mock_logger): def create_response( handler, request, suffix - ): # lint-amnesty, pylint: disable=unused-argument + ): # pylint: disable=unused-argument """create dummy response""" return Response(status_code=200) @@ -2752,7 +2726,7 @@ def test_aside(self, is_xblock_aside, is_get_aside_called): def create_response( handler, request, suffix - ): # lint-amnesty, pylint: disable=unused-argument + ): # pylint: disable=unused-argument """create dummy response""" return Response(status_code=200) @@ -2888,19 +2862,19 @@ def test_basic_components(self): self._verify_basic_component_display_name("discussion", "Discussion") self._verify_basic_component_display_name("video", "Video") self._verify_basic_component_display_name("openassessment", "Open Response") - self.assertGreater(len(self.get_templates_of_type("library")), 0) - self.assertGreater(len(self.get_templates_of_type("html")), 0) - self.assertGreater(len(self.get_templates_of_type("problem")), 0) + self.assertGreater(len(self.get_templates_of_type("library")), 0) # noqa: PT009 + self.assertGreater(len(self.get_templates_of_type("html")), 0) # noqa: PT009 + self.assertGreater(len(self.get_templates_of_type("problem")), 0) # noqa: PT009 # Check for default advanced modules advanced_templates = self.get_templates_of_type("advanced") advanced_module_keys = [t['category'] for t in advanced_templates] - self.assertCountEqual(advanced_module_keys, DEFAULT_ADVANCED_MODULES) + self.assertCountEqual(advanced_module_keys, DEFAULT_ADVANCED_MODULES) # noqa: PT009 # Now fully disable video through XBlockConfiguration XBlockConfiguration.objects.create(name="video", enabled=False) self.templates = get_component_templates(self.course) - self.assertIsNone(self.get_templates_of_type("video")) + self.assertIsNone(self.get_templates_of_type("video")) # noqa: PT009 def test_basic_components_support_levels(self): """ @@ -2909,7 +2883,7 @@ def test_basic_components_support_levels(self): XBlockStudioConfigurationFlag.objects.create(enabled=True) self.templates = get_component_templates(self.course) self._verify_basic_component("discussion", "Discussion", "ps") - self.assertEqual([], self.get_templates_of_type("video")) + self.assertEqual([], self.get_templates_of_type("video")) # noqa: PT009 supported_problem_templates = [ { "boilerplate_name": None, @@ -2920,7 +2894,7 @@ def test_basic_components_support_levels(self): "tab": "advanced", } ] - self.assertEqual( + self.assertEqual( # noqa: PT009 supported_problem_templates, self.get_templates_of_type("problem") ) @@ -2931,7 +2905,7 @@ def test_basic_components_support_levels(self): # Now fully disable video through XBlockConfiguration XBlockConfiguration.objects.create(name="video", enabled=False) self.templates = get_component_templates(self.course) - self.assertIsNone(self.get_templates_of_type("video")) + self.assertIsNone(self.get_templates_of_type("video")) # noqa: PT009 def test_advanced_components(self): """ @@ -2941,11 +2915,11 @@ def test_advanced_components(self): EXPECTED_ADVANCED_MODULES_LENGTH = len(DEFAULT_ADVANCED_MODULES) + 1 self.templates = get_component_templates(self.course) advanced_templates = self.get_templates_of_type("advanced") - self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH) + self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH) # noqa: PT009 done_template = advanced_templates[0] - self.assertEqual(done_template.get("category"), "done") - self.assertEqual(done_template.get("display_name"), "Completion") - self.assertIsNone(done_template.get("boilerplate_name", None)) + self.assertEqual(done_template.get("category"), "done") # noqa: PT009 + self.assertEqual(done_template.get("display_name"), "Completion") # noqa: PT009 + self.assertIsNone(done_template.get("boilerplate_name", None)) # noqa: PT009 # Verify that components are not added twice self.course.advanced_modules.append("video") @@ -2957,18 +2931,18 @@ def test_advanced_components(self): self.templates = get_component_templates(self.course) advanced_templates = self.get_templates_of_type("advanced") - self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH) + self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH) # noqa: PT009 only_template = advanced_templates[0] - self.assertNotEqual(only_template.get("category"), "video") - self.assertNotEqual(only_template.get("category"), "drag-and-drop-v2") - self.assertNotEqual(only_template.get("category"), "poll") - self.assertNotEqual(only_template.get("category"), "google-document") - self.assertNotEqual(only_template.get("category"), "survey") + self.assertNotEqual(only_template.get("category"), "video") # noqa: PT009 + self.assertNotEqual(only_template.get("category"), "drag-and-drop-v2") # noqa: PT009 + self.assertNotEqual(only_template.get("category"), "poll") # noqa: PT009 + self.assertNotEqual(only_template.get("category"), "google-document") # noqa: PT009 + self.assertNotEqual(only_template.get("category"), "survey") # noqa: PT009 # Now fully disable done through XBlockConfiguration XBlockConfiguration.objects.create(name="done", enabled=False) self.templates = get_component_templates(self.course) - self.assertTrue((not any(item.get("category") == "done" for item in self.get_templates_of_type("advanced")))) + self.assertTrue((not any(item.get("category") == "done" for item in self.get_templates_of_type("advanced")))) # noqa: PT009, UP034 # pylint: disable=line-too-long def test_deprecated_no_advance_component_button(self): """ @@ -2986,7 +2960,7 @@ def test_deprecated_no_advance_component_button(self): self.course.advanced_modules.extend(["poll", "survey"]) templates = get_component_templates(self.course) button_names = [template["display_name"] for template in templates] - self.assertNotIn("Advanced", button_names) + self.assertNotIn("Advanced", button_names) # noqa: PT009 def test_cannot_create_deprecated_problems(self): """ @@ -3037,18 +3011,18 @@ def verify_staffgradedxblock_present(support_level): Helper method to verify that staffgradedxblock template is present """ sgp = get_xblock_problem("Staff Graded Points") - self.assertIsNotNone(sgp) - self.assertEqual(sgp.get("category"), "staffgradedxblock") - self.assertEqual(sgp.get("support_level"), support_level) + self.assertIsNotNone(sgp) # noqa: PT009 + self.assertEqual(sgp.get("category"), "staffgradedxblock") # noqa: PT009 + self.assertEqual(sgp.get("support_level"), support_level) # noqa: PT009 def verify_dndv2_present(support_level): """ Helper method to verify that DnDv2 template is present """ dndv2 = get_xblock_problem("Drag and Drop") - self.assertIsNotNone(dndv2) - self.assertEqual(dndv2.get("category"), "drag-and-drop-v2") - self.assertEqual(dndv2.get("support_level"), support_level) + self.assertIsNotNone(dndv2) # noqa: PT009 + self.assertEqual(dndv2.get("category"), "drag-and-drop-v2") # noqa: PT009 + self.assertEqual(dndv2.get("support_level"), support_level) # noqa: PT009 verify_dndv2_present(True) verify_staffgradedxblock_present(True) @@ -3056,8 +3030,8 @@ def verify_dndv2_present(support_level): # Now enable XBlockStudioConfigurationFlag. The staffgradedxblock block is marked # unsupported, so will no longer show up, but DnDv2 will continue to appear. XBlockStudioConfigurationFlag.objects.create(enabled=True) - self.assertIsNone(get_xblock_problem("Staff Graded Points")) - self.assertIsNotNone(get_xblock_problem("Drag and Drop")) + self.assertIsNone(get_xblock_problem("Staff Graded Points")) # noqa: PT009 + self.assertIsNotNone(get_xblock_problem("Drag and Drop")) # noqa: PT009 # Now allow unsupported components. self.course.allow_unsupported_xblocks = True @@ -3067,8 +3041,8 @@ def verify_dndv2_present(support_level): # Now disable the blocks completely through XBlockConfiguration XBlockConfiguration.objects.create(name="staffgradedxblock", enabled=False) XBlockConfiguration.objects.create(name="drag-and-drop-v2", enabled=False) - self.assertIsNone(get_xblock_problem("Staff Graded Points")) - self.assertIsNone(get_xblock_problem("Drag and Drop")) + self.assertIsNone(get_xblock_problem("Staff Graded Points")) # noqa: PT009 + self.assertIsNone(get_xblock_problem("Drag and Drop")) # noqa: PT009 def test_discussion_button_present_no_provider(self): """ @@ -3114,16 +3088,16 @@ def _verify_advanced_xblocks(self, expected_xblocks, expected_support_levels): """ templates = get_component_templates(self.course) button_names = [template["display_name"] for template in templates] - self.assertIn("Advanced", button_names) - self.assertEqual(len(templates[-1]["templates"]), len(expected_xblocks)) + self.assertIn("Advanced", button_names) # noqa: PT009 + self.assertEqual(len(templates[-1]["templates"]), len(expected_xblocks)) # noqa: PT009 template_display_names = [ template["display_name"] for template in templates[-1]["templates"] ] - self.assertEqual(template_display_names, expected_xblocks) + self.assertEqual(template_display_names, expected_xblocks) # noqa: PT009 template_support_levels = [ template["support_level"] for template in templates[-1]["templates"] ] - self.assertEqual(template_support_levels, expected_support_levels) + self.assertEqual(template_support_levels, expected_support_levels) # noqa: PT009 def _verify_basic_component( self, component_type, display_name, support_level=True, no_of_templates=1 @@ -3132,16 +3106,16 @@ def _verify_basic_component( Verify the display name and support level of basic components (that have no boilerplates). """ templates = self.get_templates_of_type(component_type) - self.assertEqual(no_of_templates, len(templates)) - self.assertEqual(display_name, templates[0]["display_name"]) - self.assertEqual(support_level, templates[0]["support_level"]) + self.assertEqual(no_of_templates, len(templates)) # noqa: PT009 + self.assertEqual(display_name, templates[0]["display_name"]) # noqa: PT009 + self.assertEqual(support_level, templates[0]["support_level"]) # noqa: PT009 def _verify_basic_component_display_name(self, component_type, display_name): """ Verify the display name of basic components. """ component_display_name = self.get_display_name_of_type(component_type) - self.assertEqual(display_name, component_display_name) + self.assertEqual(display_name, component_display_name) # noqa: PT009 @ddt.ddt @@ -3221,11 +3195,11 @@ def test_entrance_exam_chapter_xblock_info(self): ) # entrance exam chapter should not be deletable, draggable and childAddable. actions = xblock_info["actions"] - self.assertEqual(actions["deletable"], False) - self.assertEqual(actions["draggable"], False) - self.assertEqual(actions["childAddable"], False) - self.assertEqual(xblock_info["display_name"], "Entrance Exam") - self.assertIsNone(xblock_info.get("is_header_visible", None)) + self.assertEqual(actions["deletable"], False) # noqa: PT009 + self.assertEqual(actions["draggable"], False) # noqa: PT009 + self.assertEqual(actions["childAddable"], False) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], "Entrance Exam") # noqa: PT009 + self.assertIsNone(xblock_info.get("is_header_visible", None)) # noqa: PT009 def test_none_entrance_exam_chapter_xblock_info(self): chapter = BlockFactory.create( @@ -3243,11 +3217,11 @@ def test_none_entrance_exam_chapter_xblock_info(self): # chapter should be deletable, draggable and childAddable if not an entrance exam. actions = xblock_info["actions"] - self.assertEqual(actions["deletable"], True) - self.assertEqual(actions["draggable"], True) - self.assertEqual(actions["childAddable"], True) + self.assertEqual(actions["deletable"], True) # noqa: PT009 + self.assertEqual(actions["draggable"], True) # noqa: PT009 + self.assertEqual(actions["childAddable"], True) # noqa: PT009 # chapter xblock info should not contains the key of 'is_header_visible'. - self.assertIsNone(xblock_info.get("is_header_visible", None)) + self.assertIsNone(xblock_info.get("is_header_visible", None)) # noqa: PT009 def test_entrance_exam_sequential_xblock_info(self): chapter = BlockFactory.create( @@ -3271,8 +3245,8 @@ def test_entrance_exam_sequential_xblock_info(self): subsection, include_child_info=True, include_children_predicate=ALWAYS ) # in case of entrance exam subsection, header should be hidden. - self.assertEqual(xblock_info["is_header_visible"], False) - self.assertEqual(xblock_info["display_name"], "Subsection - Entrance Exam") + self.assertEqual(xblock_info["is_header_visible"], False) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], "Subsection - Entrance Exam") # noqa: PT009 def test_none_entrance_exam_sequential_xblock_info(self): subsection = BlockFactory.create( @@ -3289,7 +3263,7 @@ def test_none_entrance_exam_sequential_xblock_info(self): parent_xblock=self.chapter, ) # sequential xblock info should not contains the key of 'is_header_visible'. - self.assertIsNone(xblock_info.get("is_header_visible", None)) + self.assertIsNone(xblock_info.get("is_header_visible", None)) # noqa: PT009 def test_chapter_xblock_info(self): chapter = modulestore().get_item(self.chapter.location) @@ -3348,13 +3322,13 @@ def test_validate_start_date(self): user=self.user ) - self.assertEqual(xblock_info['start'], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ')) + self.assertEqual(xblock_info['start'], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ')) # noqa: PT009 def test_highlights_enabled(self): self.course.highlights_enabled_for_messaging = True self.store.update_item(self.course, None) course_xblock_info = create_xblock_info(self.course) - self.assertTrue(course_xblock_info["highlights_enabled_for_messaging"]) + self.assertTrue(course_xblock_info["highlights_enabled_for_messaging"]) # noqa: PT009 def test_xblock_public_video_sharing_enabled(self): """ @@ -3364,8 +3338,8 @@ def test_xblock_public_video_sharing_enabled(self): with patch.object(PUBLIC_VIDEO_SHARE, "is_enabled", return_value=True): self.store.update_item(self.course, None) course_xblock_info = create_xblock_info(self.course) - self.assertTrue(course_xblock_info["video_sharing_enabled"]) - self.assertEqual(course_xblock_info["video_sharing_options"], "all-on") + self.assertTrue(course_xblock_info["video_sharing_enabled"]) # noqa: PT009 + self.assertEqual(course_xblock_info["video_sharing_options"], "all-on") # noqa: PT009 def test_xblock_public_video_sharing_disabled(self): """ @@ -3375,8 +3349,8 @@ def test_xblock_public_video_sharing_disabled(self): with patch.object(PUBLIC_VIDEO_SHARE, "is_enabled", return_value=False): self.store.update_item(self.course, None) course_xblock_info = create_xblock_info(self.course) - self.assertNotIn("video_sharing_enabled", course_xblock_info) - self.assertNotIn("video_sharing_options", course_xblock_info) + self.assertNotIn("video_sharing_enabled", course_xblock_info) # noqa: PT009 + self.assertNotIn("video_sharing_options", course_xblock_info) # noqa: PT009 def validate_course_xblock_info( self, xblock_info, has_child_info=True, course_outline=False @@ -3384,11 +3358,11 @@ def validate_course_xblock_info( """ Validate that the xblock info is correct for the test course. """ - self.assertEqual(xblock_info["category"], "course") - self.assertEqual(xblock_info["id"], str(self.course.location)) - self.assertEqual(xblock_info["display_name"], self.course.display_name) - self.assertTrue(xblock_info["published"]) - self.assertFalse(xblock_info["highlights_enabled_for_messaging"]) + self.assertEqual(xblock_info["category"], "course") # noqa: PT009 + self.assertEqual(xblock_info["id"], str(self.course.location)) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], self.course.display_name) # noqa: PT009 + self.assertTrue(xblock_info["published"]) # noqa: PT009 + self.assertFalse(xblock_info["highlights_enabled_for_messaging"]) # noqa: PT009 # Finally, validate the entire response for consistency self.validate_xblock_info_consistency( @@ -3399,21 +3373,21 @@ def validate_chapter_xblock_info(self, xblock_info, has_child_info=True): """ Validate that the xblock info is correct for the test chapter. """ - self.assertEqual(xblock_info["category"], "chapter") - self.assertEqual(xblock_info["id"], str(self.chapter.location)) - self.assertEqual(xblock_info["display_name"], "Week 1") - self.assertTrue(xblock_info["published"]) - self.assertIsNone(xblock_info.get("edited_by", None)) - self.assertEqual( + self.assertEqual(xblock_info["category"], "chapter") # noqa: PT009 + self.assertEqual(xblock_info["id"], str(self.chapter.location)) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], "Week 1") # noqa: PT009 + self.assertTrue(xblock_info["published"]) # noqa: PT009 + self.assertIsNone(xblock_info.get("edited_by", None)) # noqa: PT009 + self.assertEqual( # noqa: PT009 xblock_info["course_graders"], ["Homework", "Lab", "Midterm Exam", "Final Exam"], ) - self.assertEqual(xblock_info["start"], "2030-01-01T00:00:00Z") - self.assertEqual(xblock_info["graded"], False) - self.assertEqual(xblock_info["due"], None) - self.assertEqual(xblock_info["format"], None) - self.assertEqual(xblock_info["highlights"], self.chapter.highlights) - self.assertTrue(xblock_info["highlights_enabled"]) + self.assertEqual(xblock_info["start"], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ')) # noqa: PT009 + self.assertEqual(xblock_info["graded"], False) # noqa: PT009 + self.assertEqual(xblock_info["due"], None) # noqa: PT009 + self.assertEqual(xblock_info["format"], None) # noqa: PT009 + self.assertEqual(xblock_info["highlights"], self.chapter.highlights) # noqa: PT009 + self.assertTrue(xblock_info["highlights_enabled"]) # noqa: PT009 # Finally, validate the entire response for consistency self.validate_xblock_info_consistency( @@ -3424,11 +3398,11 @@ def validate_sequential_xblock_info(self, xblock_info, has_child_info=True): """ Validate that the xblock info is correct for the test sequential. """ - self.assertEqual(xblock_info["category"], "sequential") - self.assertEqual(xblock_info["id"], str(self.sequential.location)) - self.assertEqual(xblock_info["display_name"], "Lesson 1") - self.assertTrue(xblock_info["published"]) - self.assertIsNone(xblock_info.get("edited_by", None)) + self.assertEqual(xblock_info["category"], "sequential") # noqa: PT009 + self.assertEqual(xblock_info["id"], str(self.sequential.location)) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], "Lesson 1") # noqa: PT009 + self.assertTrue(xblock_info["published"]) # noqa: PT009 + self.assertIsNone(xblock_info.get("edited_by", None)) # noqa: PT009 # Finally, validate the entire response for consistency self.validate_xblock_info_consistency( @@ -3439,17 +3413,17 @@ def validate_vertical_xblock_info(self, xblock_info): """ Validate that the xblock info is correct for the test vertical. """ - self.assertEqual(xblock_info["category"], "vertical") - self.assertEqual(xblock_info["id"], str(self.vertical.location)) - self.assertEqual(xblock_info["display_name"], "Unit 1") - self.assertTrue(xblock_info["published"]) - self.assertEqual(xblock_info["edited_by"], "testuser") + self.assertEqual(xblock_info["category"], "vertical") # noqa: PT009 + self.assertEqual(xblock_info["id"], str(self.vertical.location)) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], "Unit 1") # noqa: PT009 + self.assertTrue(xblock_info["published"]) # noqa: PT009 + self.assertEqual(xblock_info["edited_by"], "testuser") # noqa: PT009 # Validate that the correct ancestor info has been included ancestor_info = xblock_info.get("ancestor_info", None) - self.assertIsNotNone(ancestor_info) + self.assertIsNotNone(ancestor_info) # noqa: PT009 ancestors = ancestor_info["ancestors"] - self.assertEqual(len(ancestors), 3) + self.assertEqual(len(ancestors), 3) # noqa: PT009 self.validate_sequential_xblock_info(ancestors[0], has_child_info=True) self.validate_chapter_xblock_info(ancestors[1], has_child_info=False) self.validate_course_xblock_info(ancestors[2], has_child_info=False) @@ -3463,11 +3437,11 @@ def validate_component_xblock_info(self, xblock_info): """ Validate that the xblock info is correct for the test component. """ - self.assertEqual(xblock_info["category"], "video") - self.assertEqual(xblock_info["id"], str(self.video.location)) - self.assertEqual(xblock_info["display_name"], "My Video") - self.assertTrue(xblock_info["published"]) - self.assertIsNone(xblock_info.get("edited_by", None)) + self.assertEqual(xblock_info["category"], "video") # noqa: PT009 + self.assertEqual(xblock_info["id"], str(self.video.location)) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], "My Video") # noqa: PT009 + self.assertTrue(xblock_info["published"]) # noqa: PT009 + self.assertIsNone(xblock_info.get("edited_by", None)) # noqa: PT009 # Finally, validate the entire response for consistency self.validate_xblock_info_consistency(xblock_info) @@ -3482,12 +3456,12 @@ def validate_xblock_info_consistency( """ Validate that the xblock info is internally consistent. """ - self.assertIsNotNone(xblock_info["display_name"]) - self.assertIsNotNone(xblock_info["id"]) - self.assertIsNotNone(xblock_info["category"]) - self.assertTrue(xblock_info["published"]) + self.assertIsNotNone(xblock_info["display_name"]) # noqa: PT009 + self.assertIsNotNone(xblock_info["id"]) # noqa: PT009 + self.assertIsNotNone(xblock_info["category"]) # noqa: PT009 + self.assertTrue(xblock_info["published"]) # noqa: PT009 if has_ancestor_info: - self.assertIsNotNone(xblock_info.get("ancestor_info", None)) + self.assertIsNotNone(xblock_info.get("ancestor_info", None)) # noqa: PT009 ancestors = xblock_info["ancestor_info"]["ancestors"] for ancestor in xblock_info["ancestor_info"]["ancestors"]: self.validate_xblock_info_consistency( @@ -3498,20 +3472,236 @@ def validate_xblock_info_consistency( course_outline=course_outline, ) else: - self.assertIsNone(xblock_info.get("ancestor_info", None)) + self.assertIsNone(xblock_info.get("ancestor_info", None)) # noqa: PT009 if has_child_info: - self.assertIsNotNone(xblock_info.get("child_info", None)) + self.assertIsNotNone(xblock_info.get("child_info", None)) # noqa: PT009 if xblock_info["child_info"].get("children", None): for child_response in xblock_info["child_info"]["children"]: self.validate_xblock_info_consistency( child_response, has_child_info=( - not child_response.get("child_info", None) is None + child_response.get("child_info", None) is not None ), course_outline=course_outline, ) else: - self.assertIsNone(xblock_info.get("child_info", None)) + self.assertIsNone(xblock_info.get("child_info", None)) # noqa: PT009 + + +@ddt.ddt +class TestXBlockOutlineHandlerAuthz(CourseAuthoringAuthzTestMixin, ItemTest): + """ + Unit tests for xblock_outline_handler authorization functionality. + """ + + def setUp(self): + super().setUp() + user_id = self.user.id + self.chapter = BlockFactory.create( + parent_location=self.course.location, + category="chapter", + display_name="Week 1", + user_id=user_id, + ) + self.sequential = BlockFactory.create( + parent_location=self.chapter.location, + category="sequential", + display_name="Lesson 1", + user_id=user_id, + ) + self.vertical = BlockFactory.create( + parent_location=self.sequential.location, + category="vertical", + display_name="Unit 1", + user_id=user_id, + ) + # Assign COURSE_STAFF role to authorized_user for the course + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_STAFF.external_key, + self.course.id + ) + + def test_authorized_user_gets_json_response(self): + """ + Test that authorized user gets JSON response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.authorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert "id" in json_response + assert "display_name" in json_response + assert "child_info" in json_response + + @ddt.data( + COURSE_ADMIN.external_key, + COURSE_AUDITOR.external_key, + COURSE_EDITOR.external_key, + ) + def test_other_course_roles_can_view_outline(self, role_key): + """ + Test that course_admin, course_auditor, and course_editor roles + can access the outline (all have COURSES_VIEW_COURSE). + """ + role_user = UserFactory(password=self.password) + self.add_user_to_role_in_course(role_user, role_key, self.course.id) + + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + self.client.login(username=role_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + + def test_unauthorized_user_gets_permission_denied(self): + """ + Test that unauthorized user gets 403 response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.unauthorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 403 + + def test_superuser_gets_json_response(self): + """ + Test that superuser gets JSON response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.super_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert "id" in json_response + assert "display_name" in json_response + assert "child_info" in json_response + + def test_staff_user_gets_json_response(self): + """ + Test that staff user gets JSON response from xblock_outline_handler. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + + self.client.login(username=self.staff_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert "id" in json_response + assert "display_name" in json_response + assert "child_info" in json_response + + def test_authorized_chapter_outline(self): + """ + Test that authorized user can access chapter-level outline. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location) + + self.client.login(username=self.authorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 200 + json_response = json.loads(resp.content.decode("utf-8")) + assert json_response["display_name"] == "Week 1" + assert "child_info" in json_response + # Verify that children are included (should have the sequential) + children = json_response["child_info"]["children"] + assert len(children) > 0 + assert children[0]["display_name"] == "Lesson 1" + + def test_unauthorized_chapter_outline(self): + """ + Test that unauthorized user cannot access chapter-level outline. + """ + outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location) + + self.client.login(username=self.unauthorized_user.username, password=self.password) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + + assert resp.status_code == 403 + + +class TestGetMetadataWithProblemDefaults(ModuleStoreTestCase): + """ + Unit tests for _get_metadata_with_problem_defaults. + + The helper must inject a ``weight`` value (derived from ``max_score()``) for + problem xblocks that have never had ``weight`` explicitly saved, while leaving + every other combination untouched. + """ + + def _make_problem(self, **kwargs): + """Create and return a problem xblock from the modulestore.""" + course = CourseFactory.create() + block = BlockFactory.create( + parent_location=course.location, + category='problem', + display_name='A Problem', + **kwargs, + ) + return modulestore().get_item(block.location) + + # ------------------------------------------------------------------ + # Problem blocks – weight absent from stored metadata + # ------------------------------------------------------------------ + + def test_problem_without_weight_adds_weight_from_max_score(self): + """ + When weight is absent and max_score() > 0, it is injected into metadata. + """ + xblock = self._make_problem() + with patch.object(xblock, 'max_score', return_value=3.0): + metadata = _get_metadata_with_problem_defaults(xblock) + assert metadata.get('weight') == 3.0 + + def test_problem_without_weight_max_score_zero_does_not_inject(self): + """ + A zero max_score will not inject a weight. + """ + xblock = self._make_problem() + with patch.object(xblock, 'max_score', return_value=0): + metadata = _get_metadata_with_problem_defaults(xblock) + assert 'weight' not in metadata + + # ------------------------------------------------------------------ + # Problem blocks – weight already present in stored metadata + # ------------------------------------------------------------------ + + def test_problem_with_explicit_weight_is_preserved(self): + """ + When weight is already explicitly set, it will not be overwritten. + """ + xblock = self._make_problem(weight=5.0) + with patch.object(xblock, 'max_score', return_value=2.0): + metadata = _get_metadata_with_problem_defaults(xblock) + assert metadata.get('weight') == 5.0 + + # ------------------------------------------------------------------ + # Non-problem blocks + # ------------------------------------------------------------------ + + def test_non_problem_block_is_unmodified(self): + """ + Non-problem blocks must pass through untouched even if a max_score + method is available on them. + """ + course = CourseFactory.create() + video = BlockFactory.create( + parent_location=course.location, + category='video', + display_name='A Video', + ) + xblock = modulestore().get_item(video.location) + metadata_before = dict(get_block_info(xblock).get('metadata', {})) + metadata_result = _get_metadata_with_problem_defaults(xblock) + assert metadata_result == metadata_before + assert 'weight' not in metadata_result @patch.dict("django.conf.settings.FEATURES", {"ENABLE_SPECIAL_EXAMS": True}) @@ -3570,7 +3760,7 @@ def test_proctoring_is_enabled_for_course(self): def test_special_exam_xblock_info( self, mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding, + _mock_does_backend_support_onboarding, # noqa: PT019 mock_get_exam_configuration_dashboard_url, ): sequential = BlockFactory.create( @@ -3615,7 +3805,7 @@ def test_special_exam_xblock_info( def test_show_review_rules_xblock_info( self, mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding, + _mock_does_backend_support_onboarding, # noqa: PT019 mock_get_exam_configuration_dashboard_url, ): # Set course.proctoring_provider to test_proctoring_provider @@ -3655,7 +3845,7 @@ def test_proctoring_values_correct_depending_on_lti_external( expected_proctoring_link, mock_get_exam_by_content_id, mock_does_backend_support_onboarding, - _mock_get_exam_configuration_dashboard_url, + _mock_get_exam_configuration_dashboard_url, # noqa: PT019 ): sequential = BlockFactory.create( parent_location=self.chapter.location, @@ -3697,8 +3887,8 @@ def test_xblock_was_ever_linked_to_external_exam( external_id, expected_value, mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding_patch, - _mock_get_exam_configuration_dashboard_url, + _mock_does_backend_support_onboarding_patch, # noqa: PT019 + _mock_get_exam_configuration_dashboard_url, # noqa: PT019 ): sequential = BlockFactory.create( parent_location=self.chapter.location, @@ -3725,8 +3915,8 @@ def test_xblock_was_ever_linked_to_external_exam( def test_xblock_was_never_linked_to_external_exam( self, mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding_patch, - _mock_get_exam_configuration_dashboard_url, + _mock_does_backend_support_onboarding_patch, # noqa: PT019 + _mock_get_exam_configuration_dashboard_url, # noqa: PT019 ): sequential = BlockFactory.create( parent_location=self.chapter.location, @@ -3752,7 +3942,7 @@ def test_xblock_was_never_linked_to_external_exam( def test_special_exam_xblock_info_get_dashboard_error( self, mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding, + _mock_does_backend_support_onboarding, # noqa: PT019 mock_get_exam_configuration_dashboard_url, ): sequential = BlockFactory.create( @@ -3817,7 +4007,7 @@ def test_lib_xblock_info(self): html_block = modulestore().get_item(self.top_level_html.location) xblock_info = create_xblock_info(html_block) self.validate_component_xblock_info(xblock_info, html_block) - self.assertIsNone(xblock_info.get("child_info", None)) + self.assertIsNone(xblock_info.get("child_info", None)) # noqa: PT009 def test_lib_child_xblock_info(self): html_block = modulestore().get_item(self.child_html.location) @@ -3825,24 +4015,24 @@ def test_lib_child_xblock_info(self): html_block, include_ancestor_info=True, include_child_info=True ) self.validate_component_xblock_info(xblock_info, html_block) - self.assertIsNone(xblock_info.get("child_info", None)) + self.assertIsNone(xblock_info.get("child_info", None)) # noqa: PT009 ancestors = xblock_info["ancestor_info"]["ancestors"] - self.assertEqual(len(ancestors), 2) - self.assertEqual(ancestors[0]["category"], "vertical") - self.assertEqual(ancestors[0]["id"], str(self.vertical.location)) - self.assertEqual(ancestors[1]["category"], "library") + self.assertEqual(len(ancestors), 2) # noqa: PT009 + self.assertEqual(ancestors[0]["category"], "vertical") # noqa: PT009 + self.assertEqual(ancestors[0]["id"], str(self.vertical.location)) # noqa: PT009 + self.assertEqual(ancestors[1]["category"], "library") # noqa: PT009 def validate_component_xblock_info(self, xblock_info, original_block): """ Validate that the xblock info is correct for the test component. """ - self.assertEqual(xblock_info["category"], original_block.category) - self.assertEqual(xblock_info["id"], str(original_block.location)) - self.assertEqual(xblock_info["display_name"], original_block.display_name) - self.assertIsNone(xblock_info.get("has_changes", None)) - self.assertIsNone(xblock_info.get("published", None)) - self.assertIsNone(xblock_info.get("published_on", None)) - self.assertIsNone(xblock_info.get("graders", None)) + self.assertEqual(xblock_info["category"], original_block.category) # noqa: PT009 + self.assertEqual(xblock_info["id"], str(original_block.location)) # noqa: PT009 + self.assertEqual(xblock_info["display_name"], original_block.display_name) # noqa: PT009 + self.assertIsNone(xblock_info.get("has_changes", None)) # noqa: PT009 + self.assertIsNone(xblock_info.get("published", None)) # noqa: PT009 + self.assertIsNone(xblock_info.get("published_on", None)) # noqa: PT009 + self.assertIsNone(xblock_info.get("graders", None)) # noqa: PT009 class TestLibraryXBlockCreation(ItemTest): @@ -3859,9 +4049,9 @@ def test_add_xblock(self): parent_usage_key=lib.location, display_name="Test", category="html" ) lib = self.store.get_library(lib.location.library_key) - self.assertTrue(lib.children) + self.assertTrue(lib.children) # noqa: PT009 xblock_locator = lib.children[0] - self.assertEqual(self.store.get_item(xblock_locator).display_name, "Test") + self.assertEqual(self.store.get_item(xblock_locator).display_name, "Test") # noqa: PT009 def test_no_add_discussion(self): """ @@ -3871,9 +4061,9 @@ def test_no_add_discussion(self): response = self.create_xblock( parent_usage_key=lib.location, display_name="Test", category="discussion" ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 lib = self.store.get_library(lib.location.library_key) - self.assertFalse(lib.children) + self.assertFalse(lib.children) # noqa: PT009 def test_no_add_advanced(self): lib = LibraryFactory.create() @@ -3882,9 +4072,9 @@ def test_no_add_advanced(self): response = self.create_xblock( parent_usage_key=lib.location, display_name="Test", category="lti" ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400) # noqa: PT009 lib = self.store.get_library(lib.location.library_key) - self.assertFalse(lib.children) + self.assertFalse(lib.children) # noqa: PT009 @ddt.ddt @@ -3920,7 +4110,7 @@ def _get_child_xblock_info(self, xblock_info, index): Returns the child xblock info at the specified index. """ children = xblock_info["child_info"]["children"] - self.assertGreater(len(children), index) + self.assertGreater(len(children), index) # noqa: PT009 return children[index] def _get_xblock_info(self, location): @@ -3993,9 +4183,9 @@ def _verify_xblock_info_state( ) else: if should_equal: - self.assertEqual(xblock_info[xblock_info_field], expected_state) + self.assertEqual(xblock_info[xblock_info_field], expected_state) # noqa: PT009 else: - self.assertNotEqual(xblock_info[xblock_info_field], expected_state) + self.assertNotEqual(xblock_info[xblock_info_field], expected_state) # noqa: PT009 def _verify_has_staff_only_message(self, xblock_info, expected_state, path=None): """ @@ -4175,7 +4365,7 @@ def test_staff_only_section(self): vertical_info = self._get_xblock_info(vertical.location) add_container_page_publishing_info(vertical, vertical_info) - self.assertEqual( + self.assertEqual( # noqa: PT009 _xblock_type_and_display_name(chapter), vertical_info["staff_lock_from"] ) @@ -4226,7 +4416,7 @@ def test_staff_only_subsection(self): vertical_info = self._get_xblock_info(vertical.location) add_container_page_publishing_info(vertical, vertical_info) - self.assertEqual( + self.assertEqual( # noqa: PT009 _xblock_type_and_display_name(sequential), vertical_info["staff_lock_from"] ) @@ -4278,7 +4468,7 @@ def test_staff_only_unit(self): vertical_info = self._get_xblock_info(vertical.location) add_container_page_publishing_info(vertical, vertical_info) - self.assertEqual( + self.assertEqual( # noqa: PT009 _xblock_type_and_display_name(vertical), vertical_info["staff_lock_from"] ) @@ -4372,12 +4562,12 @@ def test_self_paced_item_visibility_state(self): # Check that chapter has scheduled state xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.ready) - self.assertFalse(course.self_paced) + self.assertFalse(course.self_paced) # noqa: PT009 # Change course pacing to self paced course.self_paced = True self.store.update_item(course, self.user.id) - self.assertTrue(course.self_paced) + self.assertTrue(course.self_paced) # noqa: PT009 # Check that in self paced course content has live state now xblock_info = self._get_xblock_info(chapter.location) @@ -4430,13 +4620,13 @@ def create_source_block(self, course): # quick sanity checks source_block = self.store.get_item(source_block.location) - self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) - self.assertEqual(source_block.display_name, "Source Block") - self.assertEqual( + self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) # noqa: PT009 + self.assertEqual(source_block.display_name, "Source Block") # noqa: PT009 + self.assertEqual( # noqa: PT009 source_block.runtime.get_asides(source_block)[0].field11, "html_new_value1" ) - self.assertEqual(source_block.data, "
test
") - self.assertEqual(source_block.items, ["test", "beep"]) + self.assertEqual(source_block.data, "
test
") # noqa: PT009 + self.assertEqual(source_block.items, ["test", "beep"]) # noqa: PT009 return source_block @@ -4445,12 +4635,12 @@ def check_updated(self, source_block, destination_key): Check that the destination block has been updated to match our source block. """ revised = self.store.get_item(destination_key) - self.assertEqual(source_block.display_name, revised.display_name) - self.assertEqual(source_block.due, revised.due) - self.assertEqual(revised.data, source_block.data) - self.assertEqual(revised.items, source_block.items) + self.assertEqual(source_block.display_name, revised.display_name) # noqa: PT009 + self.assertEqual(source_block.due, revised.due) # noqa: PT009 + self.assertEqual(revised.data, source_block.data) # noqa: PT009 + self.assertEqual(revised.items, source_block.items) # noqa: PT009 - self.assertEqual( + self.assertEqual( # noqa: PT009 revised.runtime.get_asides(revised)[0].field11, source_block.runtime.get_asides(source_block)[0].field11, ) @@ -4548,15 +4738,15 @@ def _create_block(self, parent, category, display_name, **kwargs): def test_xblock_edit_view(self): url = reverse_usage_url("xblock_edit_handler", self.video.location) resp = self.client.get_html(url) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 html_content = resp.content.decode(resp.charset) - self.assertIn("var decodedActionName = 'edit';", html_content) + self.assertIn("var decodedActionName = 'edit';", html_content) # noqa: PT009 def test_xblock_edit_view_contains_resources(self): url = reverse_usage_url("xblock_edit_handler", self.video.location) resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) # noqa: PT009 html_content = resp.content.decode(resp.charset) soup = BeautifulSoup(html_content, "html.parser") @@ -4564,5 +4754,22 @@ def test_xblock_edit_view_contains_resources(self): resource_links = [link["href"] for link in soup.find_all("link", {"rel": "stylesheet"})] script_sources = [script["src"] for script in soup.find_all("script") if script.get("src")] - self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}") - self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}") + self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}") # noqa: PT009 # pylint: disable=line-too-long + self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}") # noqa: PT009 # pylint: disable=line-too-long + + def test_xblock_edit_view_contains_page_notification(self): + """ + The page-notification element is required for XBlock runtime error + notifications (e.g. ORA validation errors) to be visible to the user. + """ + url = reverse_usage_url("xblock_edit_handler", self.video.location) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + + html_content = resp.content.decode(resp.charset) + soup = BeautifulSoup(html_content, "html.parser") + self.assertIsNotNone( # noqa: PT009 + soup.find(id="page-notification"), + "container_editor.html must include a #page-notification element " + "so that XBlock runtime error notifications are rendered.", + ) diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index dfbad798f6f4..0da1596b3986 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -5,15 +5,12 @@ import itertools import json -from unittest import mock import ddt from django.conf import settings from django.test.utils import override_settings -from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import AssetKey -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import get_lms_link_for_certificate_web_view, reverse_course_url from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -21,9 +18,9 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin -from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order +from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order +from xmodule.exceptions import NotFoundError # pylint: disable=wrong-import-order from ..certificate_manager import CERTIFICATE_SCHEMA_VERSION, CertificateManager @@ -143,10 +140,10 @@ def test_required_fields_are_absent(self): HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) - self.assertEqual(response.status_code, 400) - self.assertNotIn("Location", response) + self.assertEqual(response.status_code, 400) # noqa: PT009 + self.assertNotIn("Location", response) # noqa: PT009 content = json.loads(response.content.decode('utf-8')) - self.assertIn("error", content) + self.assertIn("error", content) # noqa: PT009 def test_invalid_json(self): """ @@ -164,10 +161,10 @@ def test_invalid_json(self): HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) - self.assertEqual(response.status_code, 400) - self.assertNotIn("Location", response) + self.assertEqual(response.status_code, 400) # noqa: PT009 + self.assertNotIn("Location", response) # noqa: PT009 content = json.loads(response.content.decode('utf-8')) - self.assertTrue("error" in content or "detail" in content) + self.assertTrue("error" in content or "detail" in content) # noqa: PT009 def test_certificate_data_validation(self): #Test certificate schema version @@ -177,10 +174,10 @@ def test_certificate_data_validation(self): 'description': 'Test description' } - with self.assertRaises(Exception) as context: + with self.assertRaises(Exception) as context: # noqa: PT027 CertificateManager.validate(json_data_1) - self.assertIn( + self.assertIn( # noqa: PT009 "Unsupported certificate schema version: 100. Expected version: 1.", str(context.exception) ) @@ -191,10 +188,10 @@ def test_certificate_data_validation(self): 'description': 'Test description' } - with self.assertRaises(Exception) as context: + with self.assertRaises(Exception) as context: # noqa: PT027 CertificateManager.validate(json_data_2) - self.assertIn('must have name of the certificate', str(context.exception)) + self.assertIn('must have name of the certificate', str(context.exception)) # noqa: PT009 @ddt.ddt @@ -206,7 +203,7 @@ class CertificatesListHandlerTestCase( Test cases for certificates_list_handler. """ - def setUp(self): # lint-amnesty, pylint: disable=arguments-differ + def setUp(self): # pylint: disable=arguments-differ """ Set up CertificatesListHandlerTestCase. """ @@ -235,11 +232,11 @@ def test_can_create_certificate(self): data=CERTIFICATE_JSON ) - self.assertEqual(response.status_code, 201) - self.assertIn("Location", response) + self.assertEqual(response.status_code, 201) # noqa: PT009 + self.assertIn("Location", response) # noqa: PT009 content = json.loads(response.content.decode('utf-8')) certificate_id = content.pop("id") - self.assertEqual(content, expected) + self.assertEqual(content, expected) # noqa: PT009 self.assert_event_emitted( 'edx.certificate.configuration.created', course_id=str(self.course.id), @@ -257,7 +254,7 @@ def test_cannot_create_certificate_if_user_has_no_write_permissions(self): data=CERTIFICATE_JSON ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 @override_settings(LMS_BASE=None) def test_no_lms_base_for_certificate_web_view_link(self): @@ -265,7 +262,7 @@ def test_no_lms_base_for_certificate_web_view_link(self): course_key=self.course.id, mode='honor' ) - self.assertEqual(test_link, None) + self.assertEqual(test_link, None) # noqa: PT009 @override_settings(LMS_BASE="lms_base_url") def test_lms_link_for_certificate_web_view(self): @@ -275,13 +272,11 @@ def test_lms_link_for_certificate_web_view(self): course_key=self.course.id, mode='honor' ) - self.assertEqual(link, test_url) + self.assertEqual(link, test_url) # noqa: PT009 - @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) - @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) def test_certificate_info_in_response(self): """ - Test that certificate has been created and rendered properly with non-audit course mode. + Test that a created certificate is returned in the JSON GET response. """ CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') response = self.client.ajax_post( @@ -289,37 +284,14 @@ def test_certificate_info_in_response(self): data=CERTIFICATE_JSON_WITH_SIGNATORIES ) - self.assertEqual(response.status_code, 201) - - # in html response - result = self.client.get_html(self._url()) - self.assertContains(result, 'Test certificate') - self.assertContains(result, 'Test description') + self.assertEqual(response.status_code, 201) # noqa: PT009 - # in JSON response response = self.client.get_json(self._url()) data = json.loads(response.content.decode('utf-8')) - self.assertEqual(len(data), 1) - self.assertEqual(data[0]['name'], 'Test certificate') - self.assertEqual(data[0]['description'], 'Test description') - self.assertEqual(data[0]['version'], CERTIFICATE_SCHEMA_VERSION) - - @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) - @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) - def test_certificate_info_not_in_response(self): - """ - Test that certificate has not been rendered audit only course mode. - """ - response = self.client.ajax_post( - self._url(), - data=CERTIFICATE_JSON_WITH_SIGNATORIES - ) - - self.assertEqual(response.status_code, 201) - - # in html response - result = self.client.get_html(self._url()) - self.assertNotContains(result, 'Test certificate') + self.assertEqual(len(data), 1) # noqa: PT009 + self.assertEqual(data[0]['name'], 'Test certificate') # noqa: PT009 + self.assertEqual(data[0]['description'], 'Test description') # noqa: PT009 + self.assertEqual(data[0]['version'], CERTIFICATE_SCHEMA_VERSION) # noqa: PT009 def test_unsupported_http_accept_header(self): """ @@ -329,14 +301,14 @@ def test_unsupported_http_accept_header(self): self._url(), HTTP_ACCEPT="text/plain", ) - self.assertEqual(response.status_code, 406) + self.assertEqual(response.status_code, 406) # noqa: PT009 def test_certificate_unsupported_method(self): """ Unit Test: test_certificate_unsupported_method """ resp = self.client.put(self._url()) - self.assertEqual(resp.status_code, 405) + self.assertEqual(resp.status_code, 405) # noqa: PT009 def test_not_permitted(self): """ @@ -350,55 +322,6 @@ def test_not_permitted(self): ) self.assertContains(response, "error", status_code=403) - @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) - def test_audit_course_mode_is_skipped(self): - """ - Tests audit course mode is skipped when rendering certificates page. - """ - CourseModeFactory.create(course_id=self.course.id) - CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') - response = self.client.get_html( - self._url(), - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'verified') - self.assertNotContains(response, 'audit') - - @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) - def test_audit_only_disables_cert(self): - """ - Tests audit course mode is skipped when rendering certificates page. - """ - CourseModeFactory.create(course_id=self.course.id, mode_slug='audit') - response = self.client.get_html( - self._url(), - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'This course does not use a mode that offers certificates.') - self.assertNotContains(response, 'This module is not enabled.') - self.assertNotContains(response, 'Loading') - - @ddt.data( - ['audit', 'verified'], - ['verified'], - ['audit', 'verified', 'credit'], - ['verified', 'credit'], - ['professional'] - ) - @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) - def test_non_audit_enables_cert(self, slugs): - """ - Tests audit course mode is skipped when rendering certificates page. - """ - for slug in slugs: - CourseModeFactory.create(course_id=self.course.id, mode_slug=slug) - response = self.client.get_html( - self._url(), - ) - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, 'This course does not use a mode that offers certificates.') - self.assertNotContains(response, 'This module is not enabled.') - self.assertContains(response, 'Loading') def test_assign_unique_identifier_to_certificates(self): """ @@ -423,7 +346,7 @@ def test_assign_unique_identifier_to_certificates(self): new_certificate = json.loads(response.content.decode('utf-8')) for prev_certificate in self.course.certificates['certificates']: - self.assertNotEqual(new_certificate.get('id'), prev_certificate.get('id')) + self.assertNotEqual(new_certificate.get('id'), prev_certificate.get('id')) # noqa: PT009 @ddt.ddt @@ -476,7 +399,7 @@ def test_can_create_new_certificate_if_it_does_not_exist(self): HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) content = json.loads(response.content.decode('utf-8')) - self.assertEqual(content, expected) + self.assertEqual(content, expected) # noqa: PT009 self.assert_event_emitted( 'edx.certificate.configuration.created', course_id=str(self.course.id), @@ -508,7 +431,7 @@ def test_can_edit_certificate(self): HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) content = json.loads(response.content.decode('utf-8')) - self.assertEqual(content, expected) + self.assertEqual(content, expected) # noqa: PT009 self.assert_event_emitted( 'edx.certificate.configuration.modified', course_id=str(self.course.id), @@ -518,9 +441,9 @@ def test_can_edit_certificate(self): # Verify that certificate is properly updated in the course. course_certificates = self.course.certificates['certificates'] - self.assertEqual(len(course_certificates), 2) - self.assertEqual(course_certificates[1].get('name'), 'New test certificate') - self.assertEqual(course_certificates[1].get('description'), 'New test description') + self.assertEqual(len(course_certificates), 2) # noqa: PT009 + self.assertEqual(course_certificates[1].get('name'), 'New test certificate') # noqa: PT009 + self.assertEqual(course_certificates[1].get('description'), 'New test description') # noqa: PT009 def test_can_edit_certificate_without_is_active(self): """ @@ -557,9 +480,9 @@ def test_can_edit_certificate_without_is_active(self): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 201) # noqa: PT009 content = json.loads(response.content.decode('utf-8')) - self.assertEqual(content, expected) + self.assertEqual(content, expected) # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_can_delete_certificate_with_signatories(self, signatory_path): @@ -573,7 +496,7 @@ def test_can_delete_certificate_with_signatories(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 self.assert_event_emitted( 'edx.certificate.configuration.deleted', course_id=str(self.course.id), @@ -582,9 +505,9 @@ def test_can_delete_certificate_with_signatories(self, signatory_path): self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] - self.assertEqual(len(certificates), 1) - self.assertEqual(certificates[0].get('name'), 'Name 0') - self.assertEqual(certificates[0].get('description'), 'Description 0') + self.assertEqual(len(certificates), 1) # noqa: PT009 + self.assertEqual(certificates[0].get('name'), 'Name 0') # noqa: PT009 + self.assertEqual(certificates[0].get('description'), 'Description 0') # noqa: PT009 def test_can_delete_certificate_with_slash_prefix_signatory(self): """ @@ -597,7 +520,7 @@ def test_can_delete_certificate_with_slash_prefix_signatory(self): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 self.assert_event_emitted( 'edx.certificate.configuration.deleted', course_id=str(self.course.id), @@ -606,9 +529,9 @@ def test_can_delete_certificate_with_slash_prefix_signatory(self): self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] - self.assertEqual(len(certificates), 1) - self.assertEqual(certificates[0].get('name'), 'Name 0') - self.assertEqual(certificates[0].get('description'), 'Description 0') + self.assertEqual(len(certificates), 1) # noqa: PT009 + self.assertEqual(certificates[0].get('name'), 'Name 0') # noqa: PT009 + self.assertEqual(certificates[0].get('description'), 'Description 0') # noqa: PT009 @ddt.data("not_a_valid_asset_key{}.png", "/not_a_valid_asset_key{}.png") def test_can_delete_certificate_with_invalid_signatory(self, signatory_path): @@ -622,7 +545,7 @@ def test_can_delete_certificate_with_invalid_signatory(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 self.assert_event_emitted( 'edx.certificate.configuration.deleted', course_id=str(self.course.id), @@ -631,9 +554,9 @@ def test_can_delete_certificate_with_invalid_signatory(self, signatory_path): self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] - self.assertEqual(len(certificates), 1) - self.assertEqual(certificates[0].get('name'), 'Name 0') - self.assertEqual(certificates[0].get('description'), 'Description 0') + self.assertEqual(len(certificates), 1) # noqa: PT009 + self.assertEqual(certificates[0].get('name'), 'Name 0') # noqa: PT009 + self.assertEqual(certificates[0].get('description'), 'Description 0') # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_delete_certificate_without_write_permissions(self, signatory_path): @@ -649,7 +572,7 @@ def test_delete_certificate_without_write_permissions(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_delete_certificate_without_global_staff_permissions(self, signatory_path): @@ -667,7 +590,7 @@ def test_delete_certificate_without_global_staff_permissions(self, signatory_pat HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_update_active_certificate_without_global_staff_permissions(self, signatory_path): @@ -696,7 +619,7 @@ def test_update_active_certificate_without_global_staff_permissions(self, signat HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 def test_delete_non_existing_certificate(self): """ @@ -709,7 +632,7 @@ def test_delete_non_existing_certificate(self): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 404) # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_can_delete_signatory(self, signatory_path): @@ -721,7 +644,7 @@ def test_can_delete_signatory(self, signatory_path): signatory = certificates[1].get("signatories")[1] image_asset_location = AssetKey.from_string(signatory['signature_image_path']) content = contentstore().find(image_asset_location) - self.assertIsNotNone(content) + self.assertIsNotNone(content) # noqa: PT009 test_url = f'{self._url(cid=1)}/signatories/1' response = self.client.delete( test_url, @@ -729,14 +652,14 @@ def test_can_delete_signatory(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 self.reload_course() # Verify that certificates are properly updated in the course. certificates = self.course.certificates['certificates'] - self.assertEqual(len(certificates[1].get("signatories")), 2) + self.assertEqual(len(certificates[1].get("signatories")), 2) # noqa: PT009 # make sure signatory signature image is deleted too - self.assertRaises(NotFoundError, contentstore().find, image_asset_location) + self.assertRaises(NotFoundError, contentstore().find, image_asset_location) # noqa: PT027 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_deleting_signatory_without_signature(self, signatory_path): @@ -751,7 +674,7 @@ def test_deleting_signatory_without_signature(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 204) # noqa: PT009 def test_delete_signatory_non_existing_certificate(self): """ @@ -765,7 +688,7 @@ def test_delete_signatory_non_existing_certificate(self): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 404) # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_certificate_activation_success(self, signatory_path): @@ -786,10 +709,10 @@ def test_certificate_activation_success(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200) # noqa: PT009 course = self.store.get_course(self.course.id) certificates = course.certificates['certificates'] - self.assertEqual(certificates[0].get('is_active'), is_active) + self.assertEqual(certificates[0].get('is_active'), is_active) # noqa: PT009 cert_event_type = 'activated' if is_active else 'deactivated' self.assert_event_emitted( '.'.join(['edx.certificate.configuration', cert_event_type]), @@ -814,7 +737,7 @@ def test_certificate_activation_without_write_permissions(self, activate, signat HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 @ddt.data(C4X_SIGNATORY_PATH, SIGNATORY_PATH) def test_certificate_activation_failure(self, signatory_path): @@ -833,7 +756,7 @@ def test_certificate_activation_failure(self, signatory_path): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 403) # noqa: PT009 course = self.store.get_course(self.course.id) certificates = course.certificates['certificates'] - self.assertEqual(certificates[0].get('is_active'), False) + self.assertEqual(certificates[0].get('is_active'), False) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 2ca03ccf892b..8bed7e6d1031 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -5,23 +5,24 @@ """ import ddt from opaque_keys.edx.keys import UsageKey -from rest_framework.test import APIClient +from openedx_content.api import signals as content_signals from openedx_events.content_authoring.signals import ( LIBRARY_BLOCK_DELETED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED, ) -from openedx_events.tests.utils import OpenEdxEventsTestMixin -from openedx_tagging.core.tagging.models import Tag +from openedx_events.testing import OpenEdxEventsTestMixin +from openedx_tagging.models import Tag from organizations.models import Organization -from xmodule.modulestore.django import contentstore, modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course, ImmediateOnCommitMixin -from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory +from rest_framework.test import APIClient from cms.djangoapps.contentstore.utils import reverse_usage_url from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangoapps.content_tagging import api as tagging_api +from xmodule.modulestore.django import contentstore, modulestore +from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, ModuleStoreTestCase, upload_file_to_course +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, LibraryFactory, ToyCourseFactory CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" XBLOCK_ENDPOINT = "/xblock/" @@ -405,6 +406,7 @@ class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ImmediateOnCom Test Clipboard Paste functionality with a "new" (as of Sumac) library """ ENABLED_OPENEDX_EVENTS = [ + content_signals.ENTITIES_DRAFT_CHANGED.event_type, # Required for library events to work LIBRARY_BLOCK_DELETED.event_type, XBLOCK_CREATED.event_type, XBLOCK_DELETED.event_type, @@ -491,7 +493,8 @@ def test_paste_from_library_read_only_tags(self): assert object_tag.is_copied # If we delete the upstream library block... - library_api.delete_library_block(self.lib_block_key) + with self.captureOnCommitCallbacks(execute=True): # make event handlers fire now, within TestCase transaction + library_api.delete_library_block(self.lib_block_key) # ...the copied tags remain, but should no longer be marked as "copied" object_tags = tagging_api.get_object_tags(new_block_key) @@ -622,7 +625,7 @@ def setup_library(cls): Creates and returns a legacy content library with 1 problem """ library = LibraryFactory.create(display_name='Library') - lib_block = BlockFactory.create( + lib_block = BlockFactory.create( # noqa: F841 parent_location=library.usage_key, category="problem", display_name="MCQ", diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index e6b58257b69d..fbb14634820c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -6,20 +6,23 @@ import datetime import re from unittest.mock import Mock, patch +from urllib.parse import quote from django.http import Http404 from django.test.client import RequestFactory from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag from pytz import UTC -from urllib.parse import quote import cms.djangoapps.contentstore.views.component as views from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ModuleStoreEnum # pylint: disable=wrong-import-order +from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( # pylint: disable=wrong-import-order + BlockFactory, + CourseFactory, +) from .utils import StudioPageTestCase @@ -66,7 +69,7 @@ def test_container_html(self): self._test_html_content( self.child_container, expected_section_tag=( - '
+ diff --git a/cms/templates/js/certificate-details.underscore b/cms/templates/js/certificate-details.underscore deleted file mode 100644 index a09a3baf897c..000000000000 --- a/cms/templates/js/certificate-details.underscore +++ /dev/null @@ -1,71 +0,0 @@ -
-
-

- <%- name %> -

-
- -
    - <% if (!_.isUndefined(id)) { %> -
  1. - <%- gettext('ID') %>: - <%- id %> -
  2. - <% } %> - <% if (showDetails) { %> -
    -
    -

    <%- gettext("Certificate Details") %>

    -
    -
    -
    -

    - <%- gettext('Course Title') %>: - <%- course.get('name') %> -

    - <% if (course_title) { %> -

    - <%- gettext('Course Title Override') %>: - <%- course_title %> -

    - <% } %> -
    - -
    -

    - <%- gettext('Course Number') %>: - <%- course.get('num') %> -

    - - <% if (course.get('display_course_number')) { %> -

    - <%- gettext('Course Number Override') %>: - <%- course.get('display_course_number') %> -

    - <% } %> -
    -
    -
    - - -
    -
    -

    <%- gettext("Certificate Signatories") %>

    -
    -

    <%- gettext("It is strongly recommended that you include four or fewer signatories. If you include additional signatories, preview the certificate in Print View to ensure the certificate will print correctly on one page.") %>

    -
    -
    - <% } %> -
- -
    - <% if (CMS.User.isGlobalStaff || !is_active) { %> -
  • - -
  • -
  • - -
  • - <% } %> -
-
diff --git a/cms/templates/js/certificate-editor.underscore b/cms/templates/js/certificate-editor.underscore deleted file mode 100644 index 513113b80500..000000000000 --- a/cms/templates/js/certificate-editor.underscore +++ /dev/null @@ -1,54 +0,0 @@ -
-
- <% if (error && error.message) { %> -
- <%- gettext(error.message) %> -
- <% } %> -
-
-
- <%- gettext("Certificate Information") %> -
- - " value="<%- name %>" aria-describedby="certificate-name-<%-uniqueId %>-tip" /> - <%- gettext("Name of the certificate") %> -
-
- - - <%- gettext("Description of the certificate") %> -
-
-

<%- gettext("Certificate Details") %>

-
-
- <%- gettext("Course Title") %>: - <%- course.get('name') %> -
-
- - " value="<%- course_title %>" aria-describedby="certificate-course-title-<%-uniqueId %>-tip" /> - <%- gettext("Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.") %> -
-
-
-

<%- gettext("Certificate Signatories") %>

-
-

<%- gettext("It is strongly recommended that you include four or fewer signatories. If you include additional signatories, preview the certificate in Print View to ensure the certificate will print correctly on one page.") %>

-
- - - <%- gettext("(Add signatories for a certificate)") %> - -
-
- - - <% if (!isNew && (CMS.User.isGlobalStaff || !is_active)) { %> - - <%- gettext("Delete") %> - - <% } %> -
-
diff --git a/cms/templates/js/certificate-web-preview.underscore b/cms/templates/js/certificate-web-preview.underscore deleted file mode 100644 index 566a1544f81d..000000000000 --- a/cms/templates/js/certificate-web-preview.underscore +++ /dev/null @@ -1,17 +0,0 @@ - - - class="button preview-certificate-link" rel="noopener" target="_blank"> - <%- gettext("Preview Certificate") %> - - diff --git a/cms/templates/js/content-group-editor.underscore b/cms/templates/js/content-group-editor.underscore deleted file mode 100644 index 038b725b84cc..000000000000 --- a/cms/templates/js/content-group-editor.underscore +++ /dev/null @@ -1,46 +0,0 @@ -
- <% if (error && error.message) { %> -
- <%- gettext(error.message) %> -
- <% } %> -
-
-
- <% - if (!_.isUndefined(id) && !_.isEmpty(id)) { - %> - <%- gettext('Content Group ID') %> - <%- id %> - <% - } - %> - "> -
-
- <% if (!_.isEmpty(usage)) { %> -
- -

- <%- gettext('This content group is used in one or more units.') %> -

-
- <% } %> -
-
- - - - <% if (!isNew) { %> - <% if (_.isEmpty(usage)) { %> - "> - <%- gettext("Delete") %> - - <% } else { %> - - <%- gettext("Delete") %> - - <% } %> - <% } %> -
-
diff --git a/cms/templates/js/course-instructor-details.underscore b/cms/templates/js/course-instructor-details.underscore deleted file mode 100644 index f437e6963c2f..000000000000 --- a/cms/templates/js/course-instructor-details.underscore +++ /dev/null @@ -1,43 +0,0 @@ -
  • -
    - - data-field="name" placeholder="<%- gettext('Instructor Name') %>" /> - <%- gettext("Please add the instructor's name")%> -
    - -
    - - data-field="title" placeholder="<%- gettext('Instructor Title') %>" /> - <%- gettext("Please add the instructor's title")%> -
    - -
    - - data-field="organization" placeholder="<%- gettext('Organization Name') %>" /> - <%- gettext("Please add the institute where the instructor is associated")%> -
    - -
    - - - <%- gettext("Please add the instructor's biography")%> -
    - -
    - - - <%- gettext('Instructor Photo') %> - -
    -
    - - <%- gettext("Please add a photo of the instructor (Note: only JPEG or PNG format supported)")%> -
    - -
    -
    - -
    - -
    -
  • diff --git a/cms/templates/js/course-settings-learning-fields.underscore b/cms/templates/js/course-settings-learning-fields.underscore deleted file mode 100644 index 86ea67983744..000000000000 --- a/cms/templates/js/course-settings-learning-fields.underscore +++ /dev/null @@ -1,5 +0,0 @@ -
    - - - -
    diff --git a/cms/templates/js/course-video-settings-update-org-credentials-footer.underscore b/cms/templates/js/course-video-settings-update-org-credentials-footer.underscore deleted file mode 100644 index 9de3376527ff..000000000000 --- a/cms/templates/js/course-video-settings-update-org-credentials-footer.underscore +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/cms/templates/js/course-video-settings-update-settings-footer.underscore b/cms/templates/js/course-video-settings-update-settings-footer.underscore deleted file mode 100644 index 77fd90e9d9ef..000000000000 --- a/cms/templates/js/course-video-settings-update-settings-footer.underscore +++ /dev/null @@ -1,13 +0,0 @@ - - - -<%if (dateModified) { %> - <%- gettext('Last updated')%> <%- dateModified %> -<% } %> - diff --git a/cms/templates/js/course-video-settings.underscore b/cms/templates/js/course-video-settings.underscore deleted file mode 100644 index 80012a293a7e..000000000000 --- a/cms/templates/js/course-video-settings.underscore +++ /dev/null @@ -1,18 +0,0 @@ -
    -
    -
    - -
    -
    -
    -
    - <%- gettext('Course Video Settings') %> -
    -
    - -
    -
    diff --git a/cms/templates/js/course-video-transcript-preferences.underscore b/cms/templates/js/course-video-transcript-preferences.underscore deleted file mode 100644 index 3bad3dc5975a..000000000000 --- a/cms/templates/js/course-video-transcript-preferences.underscore +++ /dev/null @@ -1,31 +0,0 @@ -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - <%- gettext('Transcript Languages') %> - -
    -
    -
    - -
    - -
    - -
    -
    -
    -
    diff --git a/cms/templates/js/course-video-transcript-provider-empty.underscore b/cms/templates/js/course-video-transcript-provider-empty.underscore deleted file mode 100644 index dc8335ea5cb8..000000000000 --- a/cms/templates/js/course-video-transcript-provider-empty.underscore +++ /dev/null @@ -1,7 +0,0 @@ - -
    - <% for (var i = 0; i < providers.length; i++) { %> - > - - <% } %> -
    diff --git a/cms/templates/js/course-video-transcript-provider-selected.underscore b/cms/templates/js/course-video-transcript-provider-selected.underscore deleted file mode 100644 index d5e9eee2691d..000000000000 --- a/cms/templates/js/course-video-transcript-provider-selected.underscore +++ /dev/null @@ -1,8 +0,0 @@ - -
    - <%- selectedProvider %> - -
    diff --git a/cms/templates/js/course_grade_cutoff.underscore b/cms/templates/js/course_grade_cutoff.underscore deleted file mode 100644 index 1a85dd30f721..000000000000 --- a/cms/templates/js/course_grade_cutoff.underscore +++ /dev/null @@ -1,5 +0,0 @@ -
  • - <%- descriptor %> - - <% if (removable) {%>remove<% ;} %> -
  • diff --git a/cms/templates/js/course_grade_policy.underscore b/cms/templates/js/course_grade_policy.underscore deleted file mode 100644 index a787978a5a03..000000000000 --- a/cms/templates/js/course_grade_policy.underscore +++ /dev/null @@ -1,82 +0,0 @@ -
  • -
    - - <% // xss-lint: disable=underscore-not-escaped %> - <%- gettext("The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.") %> -
    - -
    - - <% // xss-lint: disable=underscore-not-escaped %> - <%- gettext("This short name for the assignment type (for example, HW or Midterm) appears next to assignments on a learner's Progress page.") %> -
    - -
    - - <% // xss-lint: disable=underscore-not-escaped %> - <%- gettext("The weight of all assignments of this type as a percentage of the total grade, for example, 40. Do not include the percent symbol.") %> -
    - -
    - - <% // xss-lint: disable=underscore-not-escaped %> - <%- gettext("The number of subsections in the course that contain problems of this assignment type.") %> -
    - -
    - - <% // xss-lint: disable=underscore-not-escaped %> - <%- gettext("The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.") %> -
    - - <% if (model.get('type') !== '') { %> - <% if (assignmentList.length !== model.get('min_count')){ %> -
    -
    - - <%- gettext("Warning: ") %> - <%- - edx.StringUtils.interpolate( - gettext("The number of {type} assignments defined here does not match the current number of {type} assignments in the course:"), - {type: model.get('type')}, - ) - %> -
    -
    - <% if (assignmentList.length == 0){ %> -
    <%- gettext("There are no assignments of this type in the course.") %>
    - <% } else { %> - <%- - edx.StringUtils.interpolate( - gettext("{assignment_count} {type} assignment(s) found:"), - { - assignment_count: assignmentList.length, - type: model.get('type') - }, - ) - %> -
      - <% _.each(assignmentList, function (qualifiedSubsectionName){ %> -
    1. <%- qualifiedSubsectionName %>
    2. - <% }) %> -
    - <% } %> -
    -
    - <% } else { %> -
    - - <%- - edx.StringUtils.interpolate( - gettext("The number of {type} assignments in the course matches the number defined here."), - {type: model.get('type')}, - ) - %> -
    - <% } %> - <% } %> - - -
  • diff --git a/cms/templates/js/group-configuration-details.underscore b/cms/templates/js/group-configuration-details.underscore deleted file mode 100644 index f3fd77971827..000000000000 --- a/cms/templates/js/group-configuration-details.underscore +++ /dev/null @@ -1,95 +0,0 @@ -
    -
    -

    - - - <%- name %> - -

    -
    - -
      - <% if (!_.isUndefined(id)) { %> -
    1. <%- gettext('ID') %>: <%- id %>
    2. - <% } %> - <% if (showGroups) { %> -
    3. - <%- description %> -
    4. - <% } else { %> -
    5. - <%- groupsCountMessage %> -
    6. -
    7. - <%- usageCountMessage %> -
    8. - <% } %> -
    - - <% if(showGroups) { %> - <% allocation = Math.floor(100 / groups.length) %> -
      - <% groups.each(function(group, groupIndex) { %> -
    1. - <%- group.get('name') %> - <%- allocation %>% -
    2. - <% }) %> -
    - <% } %> -
      -
    • - -
    • - <% if (_.isEmpty(usage)) { %> -
    • - -
    • - <% } else { %> -
    • - -
    • - <% } %> -
    -
    -<% if(showGroups) { %> -
    - <% if (!_.isEmpty(usage)) { %> -

    <%- gettext('This Group Configuration is used in:') %>

    -
      - <% _.each(usage, function(unit) { %> -
    1. -

      ><%- unit.label %>

      - <% if (unit.validation) { %> -

      - <% if (unit.validation.type === 'warning') { %> - - <% } else if (unit.validation.type === 'error') { %> - - <% } %> - - <%- unit.validation.text %> - -

      - <% } %> -
    2. - <% }) %> -
    - <% } else { %> -

    - <%= HtmlUtils.interpolateHtml( - gettext('This Group Configuration is not in use. Start by adding a content experiment to any Unit via the {linkStart}Course Outline{linkEnd}.'), - { - linkStart: HtmlUtils.interpolateHtml( - HtmlUtils.HTML(''), - { courseOutlineUrl: courseOutlineUrl, courseOutlineTitle: gettext('Course Outline')}), - linkEnd: HtmlUtils.HTML('') - }) - %> -

    - <% } %> -
    -<% } %> diff --git a/cms/templates/js/group-configuration-editor.underscore b/cms/templates/js/group-configuration-editor.underscore deleted file mode 100644 index 12a3edfbe34d..000000000000 --- a/cms/templates/js/group-configuration-editor.underscore +++ /dev/null @@ -1,59 +0,0 @@ -
    - <% if (error && error.message) { %> -
    - <%- gettext(error.message) %> -
    - <% } %> -
    -
    - <%- gettext("Group Configuration information") %> -
    - <% - if (!_.isUndefined(id)) { - %> - <%- gettext('Group Configuration ID') %> - <%- id %> - <% - } - %> - " value="<%- name %>"> - <%- gettext("Name or short description of the configuration") %> -
    -
    - - - <%- gettext("Optional long description") %> -
    -
    -
    - <%- gettext("Group information") %> - - <%- gettext("Name of the groups that students will be assigned to, for example, Control, Video, Problems. You must have two or more groups.") %> -
      - -
      - <% if (!_.isEmpty(usage)) { %> -
      - -

      - <%- gettext('This configuration is currently used in content experiments. If you make changes to the groups, you may need to edit those experiments.') %> -

      -
      - <% } %> -
      -
      - - - <% if (!isNew) { %> - <% if (_.isEmpty(usage)) { %> - - <%- gettext("Delete") %> - - <% } else { %> - - <%- gettext("Delete") %> - - <% } %> - <% } %> -
      -
      diff --git a/cms/templates/js/group-edit.underscore b/cms/templates/js/group-edit.underscore deleted file mode 100644 index e2021df69972..000000000000 --- a/cms/templates/js/group-edit.underscore +++ /dev/null @@ -1,4 +0,0 @@ -
      -
      <%- allocation %>%
      - <%- gettext("delete group") %> diff --git a/cms/templates/js/mock/mock-group-configuration-page.underscore b/cms/templates/js/mock/mock-group-configuration-page.underscore deleted file mode 100644 index 62da5501d8eb..000000000000 --- a/cms/templates/js/mock/mock-group-configuration-page.underscore +++ /dev/null @@ -1,28 +0,0 @@ -
      -
      -
      -

      - Settings - > Group Configurations" -

      -
      -
      - -
      -
      -
      -
      -
      -

      Loading

      -
      -
      -
      -
      -

      Loading

      -
      -
      -
      - -
      -
      -
      diff --git a/cms/templates/js/partition-group-details.underscore b/cms/templates/js/partition-group-details.underscore deleted file mode 100644 index e2d228bce904..000000000000 --- a/cms/templates/js/partition-group-details.underscore +++ /dev/null @@ -1,73 +0,0 @@ -
      -
      -

      - - - <%- name %> - -

      -
      - -
        - <% if (!_.isUndefined(id)) { %> -
      1. <%- gettext('ID') %>: <%- id %>
      2. - <% } %> - <% if (!showContentGroupUsages) { %> -
      3. - <%- usageCountMessage %> -
      4. - <% } %> -
      - -
        - <% if (!restrictEditing) { %> -
      • - -
      • - <% if (_.isEmpty(usage)) { %> -
      • - -
      • - <% } else { %> -
      • - -
      • - <% } %> - <% } %> -
      -
      - -<% if (showContentGroupUsages) { %> -
      - <% if (!_.isEmpty(usage)) { %> -

      - <%- gettext('This group controls access to:') %> -

      -
        - <% _.each(usage, function(unit) { %> -
      1. -

        ><%- unit.label %>

        -
      2. - <% }) %> -
      - <% } else { %> -

      - <%= HtmlUtils.interpolateHtml( - gettext("In the {linkStart}Course Outline{linkEnd}, use this group to control access to a component."), - { - linkStart: HtmlUtils.interpolateHtml( - HtmlUtils.HTML(''), - {courseOutlineUrl: courseOutlineUrl, courseOutlineTitle: gettext('Course Outline')} - ), - linkEnd: HtmlUtils.HTML('') - } - ) - %> - -

      - <% } %> -
      -<% } %> diff --git a/cms/templates/js/previous-video-upload-list.underscore b/cms/templates/js/previous-video-upload-list.underscore deleted file mode 100644 index 5adb3e211984..000000000000 --- a/cms/templates/js/previous-video-upload-list.underscore +++ /dev/null @@ -1,24 +0,0 @@ -
      -

      <%- gettext("Previous Uploads") %>

      - -
      -
      -
      - <% if (videoImageUploadEnabled) { %> -
      <%- gettext("Thumbnail") %>
      - <% } %> -
      <%- gettext("Name") %>
      -
      <%- gettext("Date Added") %>
      -
      <%- gettext("Video ID") %>
      -
      <%- gettext("Transcripts") %>
      -
      <%- gettext("Video Status") %>
      -
      <%- gettext("Action") %>
      -
      -
      -
      -
      -
      diff --git a/cms/templates/js/previous-video-upload.underscore b/cms/templates/js/previous-video-upload.underscore deleted file mode 100644 index a4cd993235a3..000000000000 --- a/cms/templates/js/previous-video-upload.underscore +++ /dev/null @@ -1,20 +0,0 @@ -
      - <% if (videoImageUploadEnabled) { %> -
      - <% } %> -
      <%- client_video_id %>
      -
      <%- created %>
      -
      <%- edx_video_id %>
      -
      -
      - -
      diff --git a/cms/templates/js/signatory-actions.underscore b/cms/templates/js/signatory-actions.underscore deleted file mode 100644 index 08c65d0ce103..000000000000 --- a/cms/templates/js/signatory-actions.underscore +++ /dev/null @@ -1,6 +0,0 @@ -
      -
      - - -
      -
      diff --git a/cms/templates/js/signatory-details.underscore b/cms/templates/js/signatory-details.underscore deleted file mode 100644 index 92ed4f9bc6d1..000000000000 --- a/cms/templates/js/signatory-details.underscore +++ /dev/null @@ -1,33 +0,0 @@ -
      - <% if (CMS.User.isGlobalStaff || !certificate.get('is_active')) { %> - - <% } %> -
      <%- gettext("Signatory") %> <%- signatory_number %> 
      -
      -
      -
      - <%- gettext("Name") %>:  - <%- name %> -
      -
      - <%- gettext("Title") %>:  - <%= _.escape(title).replace(new RegExp('\r?\n','g'), '
      ') %>
      -
      -
      - <%- gettext("Organization") %>:  - <%- organization %> -
      -
      -
      - <% if (signature_image_path != "") { %> -
      - <%- gettext('Signature Image') %> -
      - <% } %> -
      -
      -
      diff --git a/cms/templates/js/signatory-editor.underscore b/cms/templates/js/signatory-editor.underscore deleted file mode 100644 index e26525108340..000000000000 --- a/cms/templates/js/signatory-editor.underscore +++ /dev/null @@ -1,51 +0,0 @@ -
      - <% if (is_editing_all_collections && signatories_count > 1 && (total_saved_signatories > 1 || isNew) ) { %> - - - <%- gettext("Delete") %> - - <% } %> -
      <%- gettext("Signatory") %> <%- signatory_number %>
      -
      -
      - <%- gettext("Certificate Signatory Configuration") %> -
      - - " value="<%- name %>" aria-describedby="signatory-name-<%- signatory_number %>-tip" /> - <%- gettext("The name of this signatory as it should appear on certificates.") %> - <% if(error && error.name) { %> - <%- error.name %> - <% } %> -
      -
      - - - <%- gettext("Titles more than 100 characters may prevent students from printing their certificate on a single page.") %> - <% if(error && error.title) { %> - <%- error.title %> - <% } %> -
      -
      - - " value="<%- organization %>" aria-describedby="signatory-organization-<%- signatory_number %>-tip" /> - <%- gettext("The organization that this signatory belongs to, as it should appear on certificates.") %> - <% if(error && error.organization) { %> - <%- error.organization %> - <% } %> -
      -
      - - <% if (signature_image_path != "") { %> -
      Signature Image
      - <% } %> -
      -
      - " value="<%- signature_image_path %>" aria-describedby="signatory-signature-<%- signatory_number %>-tip" readonly /> - <%- gettext("Image must be in PNG format") %> -
      - -
      -
      -
      -
      -
      diff --git a/cms/templates/js/validation-error-modal.underscore b/cms/templates/js/validation-error-modal.underscore deleted file mode 100644 index ca661f4fcf75..000000000000 --- a/cms/templates/js/validation-error-modal.underscore +++ /dev/null @@ -1,34 +0,0 @@ -
      -
      -

      - <%= _.template( // xss-lint: disable=underscore-not-escaped - ngettext( - "There was {strong_start}{num_errors} validation error{strong_end} while trying to save the course settings in the database.", - "There were {strong_start}{num_errors} validation errors{strong_end} while trying to save the course settings in the database.", - num_errors - ), - {interpolate: /\{(.+?)\}/g})( - { - strong_start:'', - num_errors: num_errors, - strong_end: '' - })%> - <%- gettext("Please check the following validation feedbacks and reflect them in your course settings:")%>

      -
      - -
      - -
        - <% _.each(response, function(value, index, list) { %> - -
      • - - - <%- value.model.display_name %>: - - -
      • - - <% }); %> -
      -
      diff --git a/cms/templates/js/video-status.underscore b/cms/templates/js/video-status.underscore deleted file mode 100644 index e2a24faf1d78..000000000000 --- a/cms/templates/js/video-status.underscore +++ /dev/null @@ -1,5 +0,0 @@ -<%- status %> -<% if (show_error && error_description) { %> -
      - <%- error_description %> -<% }%> diff --git a/cms/templates/js/video-thumbnail-error.underscore b/cms/templates/js/video-thumbnail-error.underscore deleted file mode 100644 index 4f7c7c4d2ed4..000000000000 --- a/cms/templates/js/video-thumbnail-error.underscore +++ /dev/null @@ -1,4 +0,0 @@ -
      - - <%- errorText %> -
      diff --git a/cms/templates/js/video-thumbnail.underscore b/cms/templates/js/video-thumbnail.underscore deleted file mode 100644 index 1d6538668985..000000000000 --- a/cms/templates/js/video-thumbnail.underscore +++ /dev/null @@ -1,28 +0,0 @@ -
      - <%- imageAltText %> -
      - - - - - <%- edx.StringUtils.interpolate( - gettext("Recommended image resolution is {imageResolution}, maximum image file size should be {maxFileSize} and format must be one of {supportedImageFormats}."), - {imageResolution: videoImageResolution, maxFileSize: videoImageMaxSize.humanize, supportedImageFormats: videoImageSupportedFileFormats.humanize} - ) %> - -
      - <% if(duration) { %> -
      - <%- duration.humanize %> - -
      - <% } %> -
      diff --git a/cms/templates/js/video-transcript-upload-status.underscore b/cms/templates/js/video-transcript-upload-status.underscore deleted file mode 100644 index 3c3566462582..000000000000 --- a/cms/templates/js/video-transcript-upload-status.underscore +++ /dev/null @@ -1,5 +0,0 @@ - -<%- shortMessage %> - diff --git a/cms/templates/js/video-transcripts.underscore b/cms/templates/js/video-transcripts.underscore deleted file mode 100644 index 290291db4979..000000000000 --- a/cms/templates/js/video-transcripts.underscore +++ /dev/null @@ -1,52 +0,0 @@ -
      -<% if (transcription_status) { %> - <%- transcription_status %> - <% if (error_description) { %> -
      - <%- error_description %> - <% }%> -<% } else if (!transcripts.length){ %> - <%- gettext('No transcript uploaded.') %> -<% }%> -
      -<% if (transcripts.length) { %> - -<% }%> -
      -<% _.each(transcripts, function(transcriptLanguageCode){ %> - <% selectedLanguageCodes = _.keys(_.omit(transcripts, transcriptLanguageCode)); %> -
      -
      - <%- StringUtils.interpolate(gettext('{transcriptClientTitle}_{transcriptLanguageCode}.{fileExtension}'), {transcriptClientTitle: transcriptClientTitle, transcriptLanguageCode: transcriptLanguageCode, fileExtension: transcriptFileFormat}) %> - - -
      - - <%- gettext('Download') %> - - | - - | - -
      -
      -<% }) %> -
      -
      diff --git a/cms/templates/library-block-author-preview-header.html b/cms/templates/library-block-author-preview-header.html index c6ebb4e6214c..f32498053ccf 100644 --- a/cms/templates/library-block-author-preview-header.html +++ b/cms/templates/library-block-author-preview-header.html @@ -1,6 +1,34 @@ <%page expression_filter="h"/> -<%! from django.utils.translation import ngettext %> +<%! +from django.utils.translation import ngettext, gettext as _ +from cms.djangoapps.contentstore.utils import get_libraries_list_url + +libraries_list_url = get_libraries_list_url() +%>
      +

      diff --git a/cms/templates/library.html b/cms/templates/library.html index 57d0363dad9e..0e16beec2881 100644 --- a/cms/templates/library.html +++ b/cms/templates/library.html @@ -5,6 +5,7 @@ <%! from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name +from cms.djangoapps.contentstore.utils import get_libraries_list_url from django.utils.translation import gettext as _ from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.djangolib.markup import HTML, Text @@ -41,7 +42,7 @@ <%block name="content"> - +<% libraries_list_url = get_libraries_list_url() %>

      @@ -77,6 +78,29 @@

      ${_("Page Actions")}

      +
      diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html deleted file mode 100644 index 5da57980a459..000000000000 --- a/cms/templates/manage_users.html +++ /dev/null @@ -1,131 +0,0 @@ -## xss-lint: disable=mako-missing-default -<%inherit file="base.html" /> -<%! -from django.utils.translation import gettext as _ -from django.urls import reverse - -from openedx.core.djangolib.js_utils import ( - dump_js_escaped_json, js_escaped_string -) -%> -<%def name="online_help_token()"><% return "team_course" %> -<%block name="title">${_("Course Team Settings")} -<%block name="bodyclass">is-signedin course users view-team -<%namespace name='static' file='static_content.html'/> - -<%block name="header_extras"> - - - -<%block name="content"> - -
      -
      -

      - ${_("Settings")} - > ${_("Course Team")} -

      - - -
      -
      - -
      -
      -
      - %if allow_actions: -
      -
      -
      -

      ${_("Add a User to Your Course's Team")}

      - -
      - ${_("New Team Member Information")} - -
        -
      1. - - - ${_("Provide the email address of the user you want to add as Staff")} -
      2. -
      -
      -
      - -
      - - -
      -
      -
      - %endif - -
        -
        -

        ${_('Loading')}

        -
        -
      - - % if allow_actions and len(users) == 1: -
      -
      -

      ${_('Add Team Members to This Course')}

      -
      -

      ${_('Adding team members makes course authoring collaborative. Users must be signed up for {studio_name} and have an active account.').format(studio_name=settings.STUDIO_SHORT_NAME)}

      -
      -
      - - -
      - %endif -
      - - -
      -
      - - -<%block name="requirejs"> - require(["js/factories/manage_users"], function(ManageCourseUsersFactory) { - ManageCourseUsersFactory( - // xss-lint: disable=mako-invalid-js-filter - "${context_course.display_name_with_default | h}", - ${users | n, dump_js_escaped_json}, - // xss-lint: disable=mako-invalid-js-filter - "${reverse('course_team_handler', kwargs={'course_key_string': str(context_course.id), 'email': '@@EMAIL@@'}) | n, js_escaped_string}", - ${request.user.id | n, dump_js_escaped_json}, - ${allow_actions | n, dump_js_escaped_json} - ); - }); - diff --git a/cms/templates/settings.html b/cms/templates/settings.html deleted file mode 100644 index df64bcc39361..000000000000 --- a/cms/templates/settings.html +++ /dev/null @@ -1,723 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="base.html" /> -<%def name="online_help_token()"><% return "schedule" %> -<%block name="title">${_("Schedule & Details Settings")} -<%block name="bodyclass">is-signedin course schedule view-settings feature-upload - -<%namespace name='static' file='static_content.html'/> -<%! - from django.utils.translation import gettext as _ - from common.djangoapps.student.auth import has_studio_advanced_settings_access - from cms.djangoapps.contentstore import utils - from lms.djangoapps.certificates.api import can_show_certificate_available_date_field - from openedx.core.djangolib.js_utils import ( - dump_js_escaped_json, js_escaped_string - ) - from openedx.core.djangolib.markup import HTML, Text - from six.moves.urllib.parse import quote - from six.moves.urllib import parse as urllib -%> - -<%block name="header_extras"> -% for template_name in ["basic-modal", "modal-button", "upload-dialog", "license-selector", "course-settings-learning-fields", "course-instructor-details"]: - -% endfor - - -<%block name="jsextra"> - - - - - -<%block name="requirejs"> - require(["js/factories/settings"], function(SettingsFactory) { - SettingsFactory( - "${details_url | n, js_escaped_string}", - ${show_min_grade_warning | n, dump_js_escaped_json}, - ${can_show_certificate_available_date_field(context_course) | n, dump_js_escaped_json}, - "${upgrade_deadline | n, js_escaped_string}", - ); - }); - - -<%block name="content"> -
      -
      -

      - ${_("Settings")} - > ${_("Schedule & Details")} -

      -
      -
      - -
      -
      -
      -
      -
      -
      -

      ${_("Basic Information")}

      - ${_("The nuts and bolts of your course")} -
      - -
        -
      1. - - -
      2. - -
      3. - - -
      4. - -
      5. - - -
      6. -
      - - % if not marketing_enabled: -
      -

      ${_("Course Summary Page")} ${_("(for student enrollment and access)")}

      -
      - <% - link_for_about_page = lms_link_for_about_page - %> -

      ${link_for_about_page}

      -
      - -
        -
      • - <% - email_subject = urllib.quote(_("Enroll in {course_display_name}").format( - course_display_name = context_course.display_name_with_default - ).encode("utf-8")) - email_body = urllib.quote(_('The course "{course_display_name}", provided by {platform_name}, is open for enrollment. Please navigate to this course at {link_for_about_page} to enroll.').format( - course_display_name = context_course.display_name_with_default, - platform_name = settings.PLATFORM_NAME, - link_for_about_page = link_for_about_page - ).encode("utf-8")) - %> - - ${_("Invite your students")} -
      • -
      -
      - % endif - - % if marketing_enabled: -
      -

      ${_("Promoting Your Course with {platform_name}").format(platform_name=settings.PLATFORM_NAME)}

      -
      -

      ${_( - 'Your course summary page will not be viewable until your course ' - 'has been announced. To provide content for the page and preview ' - 'it, follow the instructions provided by your Program Manager.')} - ${_( - 'Please note that changes here may take up to a business day to ' - 'appear on your course summary page.')} -

      -
      -
      - % endif -
      -
      - - % if credit_eligibility_enabled and is_credit_course: -
      -
      -

      ${_("Course Credit Requirements")}

      - ${_("Steps required to earn course credit")} -
      - A requirement appears in this list when you publish the unit that contains the requirement. - - % if credit_requirements: -
        - % if 'grade' in credit_requirements: -
      1. - - % for requirement in credit_requirements['grade']: - - - % endfor -
      2. - % endif - - % if 'proctored_exam' in credit_requirements: -
      3. - - % for requirement in credit_requirements['proctored_exam']: - - - % endfor -
      4. - % endif - - % if 'reverification' in credit_requirements: -
      5. - - % for requirement in credit_requirements['reverification']: - - - % endfor -
      6. - % endif -
      - % else: -

      No credit requirements found.

      - % endif -
      -
      - % endif - -
      -
      - -
      -

      ${_("Course Pacing")}

      - ${_("Set the pacing for this course")} -
      -
      - - -
        -
      1. - - - ${_("Instructor-paced courses progress at the pace that the course author sets. You can configure release dates for course content and due dates for assignments.")} -
      2. -
      3. - - - ${_("Self-paced courses offer suggested due dates for assignments or exams based on the learner’s enrollment date and the expected course duration. These courses offer learners flexibility to modify the assignment dates as needed.")} -
      4. -
      -
      -
      - -
      - -
      -
      -

      ${_('Course Schedule')}

      - ${_('Dates that control when your course can be viewed')} -
      - -
        -
      1. -
        - - - - ${_("First day the course begins")} -
        - -
        - - - ${_("(UTC)")} -
        -
      2. - -
      3. -
        - - - - ${_("Last day your course is active")} -
        - -
        - - - ${_("(UTC)")} -
        -
      4. -
      - - % if can_show_certificate_available_date_field(context_course): -
        -
      1. -
        - - - ${_("Certificates are awarded at the end of a course run")} - - -
        - - -
        -
        - - -
      2. -
      - % endif - -
        -
      1. -
        - - - - ${_("First day students can enroll")} -
        - -
        - - - ${_("(UTC)")} -
        -
      2. - <% - enrollment_end_readonly = HTML("readonly aria-readonly=\"true\"") if not enrollment_end_editable else "" - enrollment_end_editable_class = "is-not-editable" if not enrollment_end_editable else "" - %> -
      3. -
        - - - - - ${_("Last day students can enroll.")} - % if not enrollment_end_editable: - ${_("Contact your {platform_name} partner manager to update these settings.").format(platform_name=settings.PLATFORM_NAME)} - % endif - -
        - -
        - - - ${_("(UTC)")} -
        -
      4. -
      - - % if upgrade_deadline: -
        -
      1. -
        - - - - ${_("Last day students can upgrade to a verified enrollment.")} - ${_("Contact your {platform_name} partner manager to update these settings.").format(platform_name=settings.PLATFORM_NAME)} - -
        - -
        - - - ${_("(UTC)")} -
        -
      2. -
      - % endif -
      - - % if about_page_editable: -
      -
      -

      ${_('Course Details')}

      - ${_('Provide useful information about your course')} -
      -
        -
      1. - - - ${_("Identify the course language here. This is used to assist users find courses that are taught in a specific language. It is also used to localize the 'From:' field in bulk emails.")} -
      2. -
      -
      - % endif - -
      -
      - - % if about_page_editable: -
      -

      ${_("Introducing Your Course")}

      - ${_("Information for prospective students")} -
      - % endif - -
        - - % if enable_extended_course_details: -
      1. - - - ${_("Displayed as title on the course details page. Limit to 50 characters.")} -
      2. -
      3. - - - ${_("Displayed as subtitle on the course details page. Limit to 150 characters.")} -
      4. -
      5. - - - ${_("Displayed on the course details page. Limit to 50 characters.")} -
      6. -
      7. - - - ${_("Displayed on the course details page. Limit to 1000 characters.")} -
      8. - % endif - - % if short_description_editable: -
      9. - - - ${_("Appears on the course catalog page when students roll over the course name. Limit to ~150 characters")} -
      10. - % endif - - % if about_page_editable: -
      11. - - - - ${ - Text(_("Introductions, prerequisites, FAQs that are used on {a_link_start}your course summary page{a_link_end} (formatted in HTML)")).format( - a_link_start=HTML("").format(lms_link_for_about_page=lms_link_for_about_page), - a_link_end=HTML("") - )} -
      12. - % if sidebar_html_enabled: -
      13. - - ${ - Text(_("Custom sidebar content for {a_link_start}your course summary page{a_link_end} (formatted in HTML)")).format( - a_link_start=HTML("").format(lms_link_for_about_page=lms_link_for_about_page), - a_link_end=HTML("") - )} -
      14. - % endif - % endif - - % if about_page_editable: -
      15. - -
        - % if context_course.course_image: - - ${_('Course Card Image')} - - - - ${Text(_("You can manage this image along with all of your other {a_link_start}files and uploads{a_link_end}")).format( - a_link_start=HTML("").format(upload_asset_url=upload_asset_url), - a_link_end=HTML("") - )} - - - % else: - - ${_('Course Card Image')} - - ${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")} - % endif -
        - -
        -
        - ## Translators: This is the placeholder text for a field that requests the URL for a course image - - ${_("Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported)")} -
        - -
        -
      16. - % endif - - % if enable_extended_course_details: -
      17. - -
        - % if context_course.banner_image: - - - - - - ${Text(_("You can manage this image along with all of your other {a_link_start}files and uploads{a_link_end}")).format( - a_link_start=HTML("").format(upload_asset_url=upload_asset_url), - a_link_end=HTML("") - )} - - - % else: - - - - ${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 1440px wide by 400px tall)")} - % endif -
        - -
        -
        - ## Translators: This is the placeholder text for a field that requests the URL for a course banner image - - ${_("Please provide a valid path and name to your banner image (Note: only JPEG or PNG format supported)")} -
        - -
        -
      18. - -
      19. - -
        - % if context_course.video_thumbnail_image: - - ${_('Video Thumbnail Image')} - - - - ${Text(_("You can manage this image along with all of your other {a_link_start}files and uploads{a_link_end}")).format( - a_link_start=HTML("").format(upload_asset_url=upload_asset_url), - a_link_end=HTML("") - )} - - - % else: - - ${_('Video Thumbnail Image')} - - ${_("Your course currently does not have a video thumbnail image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")} - % endif -
        - -
        -
        - ## Translators: This is the placeholder text for a field that requests the URL for a course video thumbnail image - - ${_("Please provide a valid path and name to your video thumbnail image (Note: only JPEG or PNG format supported)")} -
        - -
        -
      20. - % endif - - % if about_page_editable: -
      21. - - - -
        - ## Translators: This is the placeholder text for a field that requests a YouTube video ID for a course video - - ${_("Enter your YouTube video's ID (along with any restriction parameters)")} -
        -
      22. - % endif -
      -
      - - % if enable_extended_course_details: -
      -
      -
      -

      ${_("Learning Outcomes")}

      - ${_("Add the learning outcomes for this course")} -
      -
        -
      1. -
      -
      - -
      -
      - -
      -
      -
      -

      ${_("Instructors")}

      - ${_("Add details about the instructors for this course")} -
      -
        -
      1. -
      -
      - -
      -
      - % endif - - % if about_page_editable or is_prerequisite_courses_enabled or is_entrance_exams_enabled: -
      - -
      -
      -

      ${_("Requirements")}

      - ${_("Expectations of the students taking this course")} -
      - -
        - % if about_page_editable: -
      1. - - - ${_("Time spent on all course work")} -
      2. - % endif - % if is_prerequisite_courses_enabled: -
      3. - - - ${_("Course that students must complete before beginning this course")} - -
      4. - % endif - % if is_entrance_exams_enabled: -
      5. -

        ${_("Entrance Exam")}

        -
        -
        - - -
        - -
        -
      6. - % endif -
      -
      - % endif - - % if settings.FEATURES.get("LICENSING", False): -
      - -
      -
      -

      ${_("Course Content License")}

      - ## Translators: At the course settings, the editor is able to select the default course content license. - ## The course content will have this license set, some assets can override the license with their own. - ## In the form, the license selector for course content is described using the following string: - ${_("Select the default license for course content")} -
      - -
        -
      1. -
        -
      2. -
      -
      - % endif -
      -
      - -
      -
      - diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html deleted file mode 100644 index 3eccb3528d94..000000000000 --- a/cms/templates/settings_advanced.html +++ /dev/null @@ -1,160 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="base.html" /> -<%def name="online_help_token()"><% return "advanced" %> -<%namespace name='static' file='static_content.html'/> -<%! - from six.moves.urllib.parse import quote - from django.utils.translation import gettext as _ - from cms.djangoapps.contentstore import utils - from openedx.core.djangolib.js_utils import ( - dump_js_escaped_json, js_escaped_string - ) - from openedx.core.djangolib.markup import HTML, Text -%> -<%block name="title">${_("Advanced Settings")} -<%block name="bodyclass">is-signedin course advanced view-settings - -<%block name="header_extras"> -% for template_name in ["advanced_entry", "basic-modal", "modal-button", "validation-error-modal"]: - -% endfor - - -<%block name="requirejs"> - require(["js/factories/settings_advanced"], function(SettingsAdvancedFactory) { - SettingsAdvancedFactory( - ${advanced_dict | n, dump_js_escaped_json}, - "${advanced_settings_url | n, js_escaped_string}", - ${publisher_enabled | n, dump_js_escaped_json} - ); - }); - - -<%block name="page_alert"> - %if proctoring_errors: -
      -
      - - -
      -

      ${_("This course has proctored exam settings that are incomplete or invalid.")}

      -

      - % if mfe_proctored_exam_settings_url: - <% url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='') %> - ${Text(_("You will be unable to make changes until the errors are resolved. To update these settings go to the {link_start}Proctored Exam Settings page{link_end}.")).format( - link_start=HTML('').format( - mfe_proctored_exam_settings_url=mfe_proctored_exam_settings_url - ), - link_end=HTML("") - )} - % else: - ${_("You will be unable to make changes until the following settings are updated on the page below.")} - % endif -

      -
      - -
      -
      -
      -
      - %endif - - -<%block name="content"> -
      -
      -

      - ${_("Settings")} - > ${_("Advanced Settings")} -

      -
      -
      - -
      -
      -
      -
      - -
      - ${_("Your policy changes have been saved.")} -
      - -
      - ${_("There was an error saving your information. Please see below.")} -
      - -
      -
      -

      ${_("Manual Policy Definition")}

      - -
      - -

      ${Text(_("{strong_start}Warning{strong_end}: Do not modify these policies unless you are familiar with their purpose.")).format( - strong_start=HTML(''), - strong_end=HTML('') - )}

      - -
      -
      - - -
      -
      - -
        - -
      -
      -
      -
      - - -
      -
      - diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html deleted file mode 100644 index eb0a057b046e..000000000000 --- a/cms/templates/settings_graders.html +++ /dev/null @@ -1,185 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="base.html" /> -<%def name="online_help_token()"><% return "grading" %> -<%block name="title">${_("Grading Settings")} -<%block name="bodyclass">is-signedin course grading view-settings - -<%namespace name='static' file='static_content.html'/> -<%! - from six.moves.urllib.parse import quote - import json - from cms.djangoapps.contentstore import utils - from django.utils.translation import gettext as _ - from common.djangoapps.student.auth import has_studio_advanced_settings_access - from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder - from openedx.core.djangolib.js_utils import ( - dump_js_escaped_json, js_escaped_string - ) -%> - -<%block name="header_extras"> -% for template_name in ["course_grade_policy", "course_grade_cutoff"]: - -% endfor - - -<%block name="jsextra"> - - -<%block name="requirejs"> - require(["js/factories/settings_graders"], function(SettingsGradersFactory) { - SettingsGradersFactory( - _.extend( - ${dump_js_escaped_json(course_details, cls=CourseSettingsEncoder) | n, decode.utf8}, - { is_credit_course: ${is_credit_course | n, dump_js_escaped_json} } - ), - "${grading_url | n, js_escaped_string}", - ${course_assignment_lists | n, dump_js_escaped_json}, - ${default_grade_designations | n, dump_js_escaped_json}, - ); - }); - - -<%block name="content"> -
      -
      -

      - ${_("Settings")} - > ${_("Grading")} -

      -
      -
      - -
      -
      -
      -
      -
      -
      -

      ${_("Overall Grade Range")}

      - ${_("Your overall grading scale for student final grades")} -
      - -
        -
      1. -
        - ${_("Add grade")} -
        -
        -
          -
        1. 0
        2. -
        3. 10
        4. -
        5. 20
        6. -
        7. 30
        8. -
        9. 40
        10. -
        11. 50
        12. -
        13. 60
        14. -
        15. 70
        16. -
        17. 80
        18. -
        19. 90
        20. -
        21. 100
        22. -
        -
          -
        -
        -
        -
        -
      2. -
      -
      -
      - - % if settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course: -
      -
      -

      ${_("Credit Eligibility")}

      - ${_("Settings for course credit eligibility")} -
      - -
        -
      1. - - - % - ${_("Must be greater than or equal to the course passing grade")} -
      2. -
      -
      -
      - % endif - -
      -
      -

      ${_("Grading Rules & Policies")}

      - ${_("Deadlines, requirements, and logistics around grading student work")} -
      - -
        -
      1. - - - ${_("Leeway on due dates")} -
      2. -
      -
      -
      - -
      -
      -

      ${_("Assignment Types")}

      - ${_("Categories and labels for any exercises that are gradable")} -
      - -
        - -
      - - -
      -
      -
      - - -
      -
      - diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index ae786f9ca1b6..e05bee6017bc 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -1,19 +1,19 @@ <%page expression_filter="h"/> <%! from django.utils.translation import gettext as _ -from openedx.core.djangolib.markup import Text +from openedx.core.djangolib.markup import HTML, Text from cms.djangoapps.contentstore.helpers import xblock_studio_url from cms.djangoapps.contentstore.utils import is_visible_to_specific_partition_groups, get_editor_page_base_url, determine_label from lms.lib.utils import is_unit from openedx.core.djangolib.js_utils import ( dump_js_escaped_json, js_escaped_string ) -from cms.djangoapps.contentstore.toggles import use_new_video_editor, use_video_gallery_flow +from cms.djangoapps.contentstore.toggles import use_new_pdf_editor, use_video_gallery_flow from cms.lib.xblock.upstream_sync import UpstreamLink from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled %> <% -use_new_editor_video = use_new_video_editor(xblock.context_key) +use_new_editor_pdf = use_new_pdf_editor() use_new_video_gallery_flow = use_video_gallery_flow() use_tagging = not is_tagging_feature_disabled() xblock_url = xblock_studio_url(xblock) @@ -25,7 +25,7 @@ block_is_unit = is_unit(xblock) upstream_info = UpstreamLink.try_get_for_block(xblock, log_error=False) -can_unlink = upstream_info.upstream_ref and not upstream_info.has_top_level_parent +can_unlink = upstream_info.upstream_ref and not upstream_info.top_level_parent_key %> <%namespace name='static' file='static_content.html'/> @@ -82,7 +82,7 @@ is-collapsed % endif " - use-new-editor-video = ${use_new_editor_video} + use-new-editor-pdf = ${use_new_editor_pdf} use-video-gallery-flow = ${use_new_video_gallery_flow} authoring_MFE_base_url = ${get_editor_page_base_url(xblock.location.course_key)} data-block-type = ${xblock.scope_ids.block_type} @@ -111,7 +111,7 @@ % else: % if upstream_info.upstream_ref: % if upstream_info.error_message: -
      +
      ${_("The referenced library or library object is not available.")} + ${_("The referenced library or library object is not available")}
      % elif upstream_info.ready_to_sync: - % elif len(upstream_info.downstream_customized) > 0: -
      +
      ${_("This library reference has course overrides applied.")} + ${_("This library reference has course overrides applied")}
      % else: -
      +
      {name}').format(name=upstream_info.upstream_name))}"> - ${_("This item is linked to a library item.")} + ${_("This item is linked to a library item")}
      % endif % endif diff --git a/cms/templates/videos_index.html b/cms/templates/videos_index.html deleted file mode 100644 index 5e692042552b..000000000000 --- a/cms/templates/videos_index.html +++ /dev/null @@ -1,82 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="base.html" /> -<%def name="online_help_token()"><% return "video" %> -<%! - import json - from django.core.serializers.json import DjangoJSONEncoder - from django.utils.translation import gettext as _ - from openedx.core.djangolib.js_utils import ( - dump_js_escaped_json, js_escaped_string - ) - from openedx.core.djangolib.markup import HTML, Text -%> -<%block name="title">${_("Video Uploads")} -<%block name="bodyclass">is-signedin course view-video-uploads - -<%namespace name='static' file='static_content.html'/> - -<%block name="header_extras"> -% for template_name in ["active-video-upload", "previous-video-upload-list"]: - -% endfor - - -<%block name="requirejs"> - require(["js/factories/videos_index"], function (VideosIndexFactory) { - "use strict"; - var $contentWrapper = $(".content-primary"); - VideosIndexFactory( - $contentWrapper, - "${image_upload_url | n, js_escaped_string}", - "${video_handler_url | n, js_escaped_string}", - "${encodings_download_url | n, js_escaped_string}", - "${default_video_image_url | n, js_escaped_string}", - ${concurrent_upload_limit | n, dump_js_escaped_json}, - $(".nav-actions .course-video-settings-button"), - $contentWrapper.data("previous-uploads"), - ${video_supported_file_formats | n, dump_js_escaped_json}, - ${video_upload_max_file_size | n, dump_js_escaped_json}, - ${active_transcript_preferences | n, dump_js_escaped_json}, - ${transcript_credentials | n, dump_js_escaped_json}, - ${video_transcript_settings | n, dump_js_escaped_json}, - ${is_video_transcript_enabled | n, dump_js_escaped_json}, - ${video_image_settings | n, dump_js_escaped_json}, - ${transcript_available_languages | n, dump_js_escaped_json} - ); - }); - - -<%block name="content"> - -
      -
      -
      -

      - ${_("Content")} - > ${_("Video Uploads")} -

      - - % if is_video_transcript_enabled : - - % endif -
      -
      - -
      -
      -
      -
      -
      - -% if pagination_context: - <%include file="videos_index_pagination.html"/> -% endif - - diff --git a/cms/templates/videos_index_pagination.html b/cms/templates/videos_index_pagination.html deleted file mode 100644 index a96c2916463b..000000000000 --- a/cms/templates/videos_index_pagination.html +++ /dev/null @@ -1,40 +0,0 @@ -<%page expression_filter="h"/> -<%! -from django.utils.translation import gettext as _ -from openedx.core.djangolib.js_utils import dump_js_escaped_json -%> - - - - - diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index d01ee633ea87..c67c91dd21b0 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -8,7 +8,7 @@ from urllib.parse import quote_plus from common.djangoapps.student.auth import has_studio_advanced_settings_access from cms.djangoapps.contentstore import toggles - from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_course_libraries_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url + from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_course_libraries_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url, get_certificates_url, get_group_configurations_url from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND from openedx.core.djangoapps.lang_pref.api import header_language_selector_is_enabled, released_languages %> @@ -30,29 +30,13 @@

      course_key = context_course.id url_encoded_course_key = quote(str(course_key).encode('utf-8'), safe='') index_url = reverse('course_handler', kwargs={'course_key_string': str(course_key)}) - course_team_url = reverse('course_team_handler', kwargs={'course_key_string': str(course_key)}) assets_url = reverse('assets_handler', kwargs={'course_key_string': str(course_key)}) textbooks_url = reverse('textbooks_list_handler', kwargs={'course_key_string': str(course_key)}) videos_url = reverse('videos_handler', kwargs={'course_key_string': str(course_key)}) - import_url = reverse('import_handler', kwargs={'course_key_string': str(course_key)}) course_info_url = reverse('course_info_handler', kwargs={'course_key_string': str(course_key)}) - export_url = reverse('export_handler', kwargs={'course_key_string': str(course_key)}) - settings_url = reverse('settings_handler', kwargs={'course_key_string': str(course_key)}) - grading_url = reverse('grading_handler', kwargs={'course_key_string': str(course_key)}) - advanced_settings_url = reverse('advanced_settings_handler', kwargs={'course_key_string': str(course_key)}) tabs_url = reverse('tabs_handler', kwargs={'course_key_string': str(course_key)}) - certificates_url = '' - if settings.FEATURES.get("CERTIFICATES_HTML_VIEW") and context_course.cert_html_view_enabled: - certificates_url = reverse('certificates_list_handler', kwargs={'course_key_string': str(course_key)}) checklists_url = reverse('checklists_handler', kwargs={'course_key_string': str(course_key)}) pages_and_resources_mfe_enabled = ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND.is_enabled(context_course.id) - video_upload_mfe_enabled = toggles.use_new_video_uploads_page(context_course.id) - schedule_details_mfe_enabled = toggles.use_new_schedule_details_page(context_course.id) - grading_mfe_enabled = toggles.use_new_grading_page(context_course.id) - course_team_mfe_enabled = toggles.use_new_course_team_page(context_course.id) - advanced_settings_mfe_enabled = toggles.use_new_advanced_settings_page(context_course.id) - import_mfe_enabled = toggles.use_new_import_page(context_course.id) - export_mfe_enabled = toggles.use_new_export_page(context_course.id) optimizer_enabled = toggles.enable_course_optimizer(context_course.id) libraries_v2_enabled = toggles.libraries_v2_enabled() @@ -103,12 +87,7 @@

      ${_("Course" ${_("Textbooks")} % endif - % if context_course.video_pipeline_configured and not video_upload_mfe_enabled: - - % endif - % if context_course.video_pipeline_configured and video_upload_mfe_enabled: + % if context_course.video_pipeline_configured: @@ -124,57 +103,31 @@

      ${_("Course"