diff --git a/.coveragerc b/.coveragerc index ed80994039..1da0bc0d36 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,6 +7,8 @@ omit = course_discovery/settings* course_discovery/conf* course_discovery/apps/course_metadata/tests/factories.py + course_discovery/apps/course_metadata/algolia_models.py + course_discovery/apps/course_metadata/index.py *conftest.py *wsgi.py *migrations* diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000..e7d9e0c1fa --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,22 @@ +name: Push Docker Images + +on: + push: + branches: + - master +jobs: + # Push image to GitHub Packages. + # See also https://docs.docker.com/docker-hub/builds/ + push: + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Build and Push docker image + env: + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run : make docker_push diff --git a/.github/workflows/semgrep-lint.yml b/.github/workflows/semgrep-lint.yml new file mode 100644 index 0000000000..5ae407aa03 --- /dev/null +++ b/.github/workflows/semgrep-lint.yml @@ -0,0 +1,17 @@ +name: Semgrep +on: [pull_request] +jobs: + semgrep: + runs-on: ubuntu-latest + continue-on-error: true + name: Check + steps: + - uses: actions/checkout@v1 + - uses: returntocorp/semgrep-action@v1 + name: django rules + with: + config: p/django + - uses: returntocorp/semgrep-action@v1 + name: other rules + with: + config: https://semgrep.live/dlukeomalley:use-assertEqual-for-equality diff --git a/.pep8 b/.pycodestyle similarity index 78% rename from .pep8 rename to .pycodestyle index 6d37b66312..b6b67f46e4 100644 --- a/.pep8 +++ b/.pycodestyle @@ -1,4 +1,4 @@ -[pep8] -ignore=E501 +[pycodestyle] +ignore=E501,W504 max_line_length=119 exclude=settings,migrations,course_discovery/static,bower_components,course_discovery/wsgi.py diff --git a/.travis.yml b/.travis.yml index 5d74582edd..8b6a6bd62a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,8 @@ +dist: focal language: python python: - - "3.5" + - 3.8 branches: only: @@ -14,7 +15,6 @@ sudo: required cache: - directories: - - node_modules - course_discovery/static/bower_components before_install: @@ -22,35 +22,34 @@ before_install: matrix: include: + - env: TOXENV=django22 + - env: TOXENV=django30 - env: COMMAND=test:quality - - install: - - docker exec -t discovery bash -c 'sed -i "s/course_discovery.settings.devstack/course_discovery.settings.test/" /edx/app/discovery/discovery_env' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make requirements' - - script: - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make docs' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make check_translations_up_to_date' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make validate_translations' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make clean_static' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make static' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make quality' - - - env: COMMAND=test:unittests - install: - - docker exec -t discovery bash -c 'apt update && apt install -y xvfb firefox gettext wget' - # Remove firefox but leave its dependencies, and then download and install a working version of firefox. - - docker exec -t discovery bash -c 'sudo dpkg -r --force-all firefox && TEMP_DEB="$(mktemp)" && wget -O "$TEMP_DEB" https://s3.amazonaws.com/vagrant.testeng.edx.org/firefox_61.0.1%2Bbuild1-0ubuntu0.16.04.1_amd64.deb && dpkg -i "$TEMP_DEB"' - docker exec -t discovery bash -c 'sed -i "s/course_discovery.settings.devstack/course_discovery.settings.test/" /edx/app/discovery/discovery_env' - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make requirements' script: - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make clean_static' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make static' - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && xvfb-run make test' - - after_success: - - pip install -U codecov - - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && coverage xml' - - codecov + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make docs' + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make clean_static' + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make static' + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make quality' + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make check_keywords' + allow_failures: + - env: TOXENV=django30 + +install: + - docker exec -t discovery bash -c 'sed -i "s|http://archive|http://us.archive|g" /etc/apt/sources.list' # US mirrors for speed + - docker exec -t discovery bash -c 'apt update && apt install -y --no-install-recommends firefox gettext' + - docker exec -t discovery bash -c 'sed -i "s/course_discovery.settings.devstack/course_discovery.settings.test/" /edx/app/discovery/discovery_env' + - docker exec -e TOXENV=$TOXENV -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make requirements' + +script: + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make clean_static' + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make static' + - docker exec -e TOXENV=$TOXENV -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && make test' + +after_success: + - pip install -U codecov + - docker exec -t discovery bash -c 'source /edx/app/discovery/discovery_env && cd /edx/app/discovery/discovery/ && coverage xml' + - codecov diff --git a/.travis/docker-compose-travis.yml b/.travis/docker-compose-travis.yml index 9396daf4d8..2770b5b393 100644 --- a/.travis/docker-compose-travis.yml +++ b/.travis/docker-compose-travis.yml @@ -2,20 +2,17 @@ version: "2" services: db: - image: mysql:5.6 + image: mysql:5.7 container_name: db command: mysqld --character-set-server=utf8 --collation-server=utf8_general_ci environment: - MYSQL_ROOT_PASSWORD: "" - MYSQL_ALLOW_EMPTY_PASSWORD: "yes" - MYSQL_USER: "discov001" - MYSQL_PASSWORD: "password" + MYSQL_ROOT_PASSWORD: "password" MYSQL_DATABASE: "discovery" es: - image: elasticsearch:1.5.2 + image: elasticsearch:1.7.6 container_name: es memcached: - image: memcached:1.4.24 + image: memcached:1.6.8 container_name: memcached discovery: # Uncomment this line to use the official course-discovery base image @@ -44,7 +41,7 @@ services: DB_NAME: "discovery" DB_PASSWORD: "password" DB_PORT: "3306" - DB_USER: "discov001" + DB_USER: "root" DJANGO_SETTINGS_MODULE: "course_discovery.settings.test" ENABLE_DJANGO_TOOLBAR: 1 TEST_ELASTICSEARCH_URL: "http://es:9200" diff --git a/.travis/run_tests.sh b/.travis/run_tests.sh index 5613a699a2..a2955bfed5 100755 --- a/.travis/run_tests.sh +++ b/.travis/run_tests.sh @@ -3,7 +3,7 @@ . /edx/app/discovery/nodeenvs/discovery/bin/activate apt update -apt install -y xvfb firefox gettext wget +apt install -y xvfb firefox gettext cd /edx/app/discovery/discovery export PATH=$PATH:$PWD/node_modules/.bin @@ -18,9 +18,6 @@ make requirements.js # Ensure documentation can be compiled make docs -# Check if translation files are up-to-date -make validate_translations - # Compile assets and run validation make clean_static make static diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index ffd3b883a1..0000000000 --- a/AUTHORS +++ /dev/null @@ -1,6 +0,0 @@ -Calen Pennington -Clinton Blackburn -Bill DeRusha -Rabia Iftikhar -Asad Azam -Muhammad Ammar diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..facc76d58c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# The following users are the owners of all course-discovery files +* @edx/course-discovery-admins diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..b6f8b65111 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +FROM ubuntu:focal as app + +# System requirements. +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && \ + apt-get upgrade -qy +RUN apt-get install --yes \ + git \ + language-pack-en \ + python3-venv \ + python3.8-dev \ + python3.8-venv \ + build-essential \ + libffi-dev \ + libmysqlclient-dev \ + libxml2-dev \ + libxslt1-dev \ + libjpeg-dev \ + libssl-dev + +RUN rm -rf /var/lib/apt/lists/* + +ENV VIRTUAL_ENV=/venv +RUN python3.8 -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +RUN pip install pip==20.2.3 setuptools==50.3.0 nodeenv + +# Use UTF-8. +RUN locale-gen en_US.UTF-8 +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +# Make necessary directories and environment variables. +RUN mkdir -p /edx/var/discovery/staticfiles +RUN mkdir -p /edx/var/discovery/media +ENV DJANGO_SETTINGS_MODULE course_discovery.settings.production + +# Working directory will be root of repo. +WORKDIR /edx/app/discovery + +# Copy just JS requirements and install them. +COPY package.json package.json +COPY package-lock.json package-lock.json +RUN nodeenv /edx/app/nodeenv --node=12.11.1 --prebuilt +ENV PATH /edx/app/nodeenv/bin:${PATH} +RUN npm install --production +COPY bower.json bower.json +RUN ./node_modules/.bin/bower install --allow-root --production + +# Copy just Python requirements & install them. +COPY requirements/ requirements/ +RUN pip install -r requirements/production.txt + +# Copy over rest of code. +# We do this AFTER requirements so that the requirements cache isn't busted +# every time any bit of code is changed. +COPY . . + +# Expose canonical Discovery port +EXPOSE 8381 + +CMD gunicorn --bind=0.0.0.0:8381 --workers 2 --max-requests=1000 -c course_discovery/docker_gunicorn_configuration.py course_discovery.wsgi:application + +FROM app as devstack +ENV DISCOVERY_CFG /edx/app/discovery/devstack.yml +RUN make static + +FROM app as newrelic +RUN pip install newrelic +CMD newrelic-admin run-program gunicorn --bind=0.0.0.0:8381 --workers 2 --max-requests=1000 -c course_discovery/docker_gunicorn_configuration.py course_discovery.wsgi:application diff --git a/Makefile b/Makefile index 08ab7fdfa2..8bf2c5826d 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ -.DEFAULT_GOAL := test +.DEFAULT_GOAL := help NODE_BIN=$(CURDIR)/node_modules/.bin +TOX := tox -.PHONY: accept clean clean_static compile_translations detect_changed_source_translations dummy_translations extract_translations \ - fake_translations help html_coverage migrate open-devstack production-requirements pull_translations quality requirements.js \ - requirements start-devstack static stop-devstack test validate check_translations_up_to_date docs static.dev static.watch +.PHONY: accept clean clean_static check_keywords detect_changed_source_translations extract_translations \ + help html_coverage migrate open-devstack production-requirements pull_translations quality requirements.js \ + requirements.python requirements start-devstack static stop-devstack test docs static.dev static.watch include .travis/docker.mk + # Generates a help message. Borrowed from https://github.com/pydanny/cookiecutter-djangopackage. help: ## Display this help message @echo "Please use \`make \` where is one of" @@ -15,7 +17,6 @@ help: ## Display this help message static: ## Gather all static assets for production $(NODE_BIN)/webpack --config webpack.config.js --display-error-details --progress --optimize-minimize python manage.py collectstatic -v 0 --noinput - python manage.py compress -v3 --force static.dev: $(NODE_BIN)/webpack --config webpack.config.js --display-error-details --progress @@ -31,27 +32,39 @@ clean: ## Delete generated byte code and coverage reports coverage erase requirements.js: ## Install JS requirements for local development - npm install + npm install --unsafe-perm ## This flag exists to force node-sass to build correctly on docker. Remove as soon as possible. $(NODE_BIN)/bower install --allow-root -requirements: requirements.js ## Install Python and JS requirements for local development - pip install -r requirements/local.txt +requirements.python: ## Install Python requirements for local development. + pip install -r requirements/local.txt -r requirements/django.txt + +requirements: requirements.js requirements.python ## Install Python and JS requirements for local development production-requirements: ## Install Python and JS requirements for production pip install -r requirements.txt npm install --production $(NODE_BIN)/bower install --allow-root --production +upgrade: + pip install -q -r requirements/pip_tools.txt + pip-compile --upgrade -o requirements/pip_tools.txt requirements/pip_tools.in + pip-compile --upgrade -o requirements/docs.txt requirements/docs.in + pip-compile --upgrade -o requirements/local.txt requirements/local.in + pip-compile --upgrade -o requirements/production.txt requirements/production.in + # Let tox control the Django version for tests + grep -e "^django==" requirements/local.txt > requirements/django.txt + sed -i.tmp '/^[dD]jango==/d' requirements/local.txt + rm -rf requirements/local.txt.tmp + chmod a+rw requirements/*.txt + test: clean ## Run tests and generate coverage report ## The node_modules .bin directory is added to ensure we have access to Geckodriver. - PATH="$(NODE_BIN):$(PATH)" pytest --ds=course_discovery.settings.test --durations=25 - coverage combine - coverage report + PATH="$(NODE_BIN):$(PATH)" $(TOX) -quality: ## Run pep8 and Pylint +quality: ## Run pycodestyle and pylint isort --check-only --diff --recursive acceptance_tests/ course_discovery/ - pep8 --config=.pep8 acceptance_tests course_discovery *.py - pylint --rcfile=pylintrc acceptance_tests course_discovery *.py + pycodestyle --config=.pycodestyle acceptance_tests course_discovery *.py + PYTHONPATH=./course_discovery/apps pylint --rcfile=pylintrc acceptance_tests course_discovery *.py validate: quality test ## Run tests and quality checks @@ -62,23 +75,20 @@ migrate: ## Apply database migrations html_coverage: ## Generate and view HTML coverage report coverage html && open htmlcov/index.html -extract_translations: ## Extract strings to be translated, outputting .mo files +# This Make target should not be removed since it is relied on by a Jenkins job (`edx-internal/tools-edx-jenkins/translation-jobs.yml`), using `ecommerce-scripts/transifex`. +extract_translations: ## Extract strings to be translated, outputting .po and .mo files # NOTE: We need PYTHONPATH defined to avoid ImportError(s) on Travis CI. cd course_discovery && PYTHONPATH="..:${PYTHONPATH}" django-admin.py makemessages -l en -v1 --ignore="assets/*" --ignore="static/bower_components/*" --ignore="static/build/*" -d django cd course_discovery && PYTHONPATH="..:${PYTHONPATH}" django-admin.py makemessages -l en -v1 --ignore="assets/*" --ignore="static/bower_components/*" --ignore="static/build/*" -d djangojs + cd course_discovery && PYTHONPATH="..:${PYTHONPATH}" i18n_tool dummy + cd course_discovery && PYTHONPATH="..:${PYTHONPATH}" django-admin.py compilemessages -dummy_translations: ## Generate dummy translation (.po) files - cd course_discovery && i18n_tool dummy - -compile_translations: ## Compile translation files, outputting .po files for each supported language - python manage.py compilemessages - -fake_translations: extract_translations dummy_translations compile_translations ## Generate and compile dummy translation files - +# This Make target should not be removed since it is relied on by a Jenkins job (`edx-internal/tools-edx-jenkins/translation-jobs.yml`), using `ecommerce-scripts/transifex`. pull_translations: ## Pull translations from Transifex tx pull -af --mode reviewed --minimum-perc=1 -push_translations: ## push source translation files (.po) from Transifex +# This Make target should not be removed since it is relied on by a Jenkins job (`edx-internal/tools-edx-jenkins/translation-jobs.yml`), using `ecommerce-scripts/transifex`. +push_translations: ## Push source translation files (.po) to Transifex tx push -s start-devstack: ## Run a local development copy of the server @@ -94,13 +104,33 @@ open-devstack: ## Open a shell on the server started by start-devstack accept: ## Run acceptance tests nosetests --with-ignore-docstrings -v acceptance_tests +# This Make target should not be removed since it is relied on by a Jenkins job (`edx-internal/tools-edx-jenkins/translation-jobs.yml`), using `ecommerce-scripts/transifex`. detect_changed_source_translations: ## Check if translation files are up-to-date cd course_discovery && i18n_tool changed -validate_translations: ## Check if translation files are valid - cd course_discovery && i18n_tool validate -v -ca - -check_translations_up_to_date: fake_translations detect_changed_source_translations ## Install fake translations and check if translation files are up-to-date - docs: cd docs && make html + +check_keywords: ## Scan the Django models in all installed apps in this project for restricted field names + python manage.py check_reserved_keywords --override_file db_keyword_overrides.yml + +docker_build: + docker build . -f Dockerfile --target app -t openedx/discovery + docker build . -f Dockerfile --target devstack -t openedx/discovery:latest-devstack + docker build . -f Dockerfile --target newrelic -t openedx/discovery:latest-newrelic + +docker_tag: docker_build + docker tag openedx/discovery openedx/discovery:${GITHUB_SHA} + docker tag openedx/discovery:latest-devstack openedx/discovery:${GITHUB_SHA}-devstack + docker tag openedx/discovery:latest-newrelic openedx/discovery:${GITHUB_SHA}-newrelic + +docker_auth: + echo "$$DOCKERHUB_PASSWORD" | docker login -u "$$DOCKERHUB_USERNAME" --password-stdin + +docker_push: docker_tag docker_auth ## push to docker hub + docker push 'openedx/discovery:latest' + docker push "openedx/discovery:${GITHUB_SHA}" + docker push 'openedx/discovery:latest-devstack' + docker push "openedx/discovery:${GITHUB_SHA}-devstack" + docker push 'openedx/discovery:latest-newrelic' + docker push "openedx/discovery:${GITHUB_SHA}-newrelic" diff --git a/README.rst b/README.rst index f0902466c9..5b2551b3aa 100644 --- a/README.rst +++ b/README.rst @@ -28,9 +28,30 @@ Contributions are welcome. Please read `How To Contribute = 400 or self.cache_errors) and + isinstance(response.accepted_renderer, JSONRenderer) and + use_page_cache): + # Put the response in the cache only if there are no cache errors, response errors, + # and the format is json. We avoid caching for the BrowsableAPIRenderer so that users don't see + # different usernames that are cached from the BrowsableAPIRenderer html + response_triple = ( + zlib.compress(response.rendered_content), + response.status_code, + response._headers.copy(), # pylint: disable=protected-access + ) + self.cache.set(key, response_triple, self.timeout) + else: + # If we get data from the cache, we reassemble the data to build a response + # We reassemble the pieces from the cache because we can't actually set rendered_content + # which is the part of the response that we compress + compressed_content, status, headers = response_triple + + try: + decompressed_content = zlib.decompress(compressed_content) + except (TypeError, zlib.error): + # If we get a type error or a zlib error, the response content was never compressed + decompressed_content = compressed_content + + response = HttpResponse(content=decompressed_content, status=status) + response._headers = headers # pylint: disable=protected-access + + if not hasattr(response, '_closable_objects'): + response._closable_objects = [] # pylint: disable=protected-access + + return response + + +# Decorator for mixin +compressed_cache_response = CompressedCacheResponse + + +class CompressedCacheResponseMixin(): + """ + Acts like drf-extensions CacheResponseMixin, but with compression into the cache and decompression out of it + """ + object_cache_key_func = timestamped_object_key_constructor + list_cache_key_func = timestamped_list_key_constructor + object_cache_timeout = settings.REST_FRAMEWORK_EXTENSIONS['DEFAULT_CACHE_RESPONSE_TIMEOUT'] + list_cache_timeout = settings.REST_FRAMEWORK_EXTENSIONS['DEFAULT_CACHE_RESPONSE_TIMEOUT'] + + @conditional_decorator( + settings.USE_API_CACHING, + compressed_cache_response(key_func=list_cache_key_func, timeout=list_cache_timeout), ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) - set_api_timestamp(timestamp) + @conditional_decorator( + settings.USE_API_CACHING, + compressed_cache_response(key_func=object_cache_key_func, timeout=object_cache_timeout), + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) diff --git a/course_discovery/apps/api/fields.py b/course_discovery/apps/api/fields.py index a00f05a67e..b65ba96cc7 100644 --- a/course_discovery/apps/api/fields.py +++ b/course_discovery/apps/api/fields.py @@ -1,26 +1,29 @@ import base64 +from collections import OrderedDict from django.core.files.base import ContentFile from rest_framework import serializers +from course_discovery.apps.course_metadata.utils import clean_html + class StdImageSerializerField(serializers.ImageField): """ Custom serializer field to render out proper JSON representation of the StdImage field on model """ - def to_representation(self, obj): + def to_representation(self, value): serialized = {} - for size_key in obj.field.variations: + for size_key in value.field.variations: # Get different sizes specs from the model field # Then get the file path from the available files - sized_file = getattr(obj, size_key, None) + sized_file = getattr(value, size_key, None) if sized_file: path = sized_file.url serialized_image = serialized.setdefault(size_key, {}) # In case MEDIA_URL does not include scheme+host, ensure that the URLs are absolute and not relative serialized_image['url'] = self.context['request'].build_absolute_uri(path) - serialized_image['width'] = obj.field.variations[size_key]['width'] - serialized_image['height'] = obj.field.variations[size_key]['height'] + serialized_image['width'] = value.field.variations[size_key]['width'] + serialized_image['height'] = value.field.variations[size_key]['height'] return serialized @@ -36,7 +39,7 @@ def to_internal_value(self, data): ext = file_format.split('/')[-1] # guess file extension data = ContentFile(base64.b64decode(imgstr), name='tmp.' + ext) - return super(StdImageSerializerField, self).to_internal_value(data) + return super().to_internal_value(data) class ImageField(serializers.Field): # pylint:disable=abstract-method @@ -51,3 +54,77 @@ def to_representation(self, value): 'height': None, 'width': None } + + +class HtmlField(serializers.CharField): + """ Use this class for any model field defined by a HtmlField or NullHtmlField """ + + def to_internal_value(self, data): + """ Cleans incoming HTML to strip some styling that word processors might inject when copying/pasting. """ + data = super().to_internal_value(data) + return clean_html(data) if data else data + + +class SlugRelatedTranslatableField(serializers.SlugRelatedField): + """ Use in place of SlugRelatedField when the slug field is a TranslatedField """ + + def to_internal_value(self, data): + full_translated_field_name = f'translations__{self.slug_field}' + return self.get_queryset().get(**{full_translated_field_name: data}) + + +class SlugRelatedFieldWithReadSerializer(serializers.SlugRelatedField): + """ + This field accepts slugs on updates, but provides full serializations on reads. + + This is useful if you want nested serializations, but still want to be able to update the list + of nested objects with a simple list of slugs. + + The required parameter read_serializer should be an instance of a serializer to use when + providing a full serialization during read. It does not need 'required' or 'many' parameters to + be passed. It will always be provided a single object. + + As an example: + subjects = SlugRelatedFieldWithReadSerializer(slug_field='slug', required=False, many=True, + queryset=Subject.objects.all(), + read_serializer=SubjectSerializer()) + + update format: {'subjects': ['chemistry']} + read format: {'subjects': [{'display_name': 'Chemistry', 'slug': 'chemistry'}]} + """ + def __init__(self, *args, read_serializer=None, **kwargs): + super().__init__(*args, **kwargs) + + assert read_serializer, 'Must specify a read_serializer to SlugRelatedFieldWithReadSerializer' + self.read_serializer = read_serializer + + # Connect the child serializer to us, so it can find the root serializer context. + # field_name='' is just a DRF trick to force the binding. + self.read_serializer.bind(field_name='', parent=self) + + def to_representation(self, obj): + return self.read_serializer.to_representation(obj) + + def get_choices(self, cutoff=None): + """ + This is an exact copy of RelatedField.get_choices, but using slugs instead of to_representation. + + See 'delta' comment below. + """ + queryset = self.get_queryset() + if queryset is None: + # Ensure that field.choices returns something sensible + # even when accessed with a read-only field. + return {} + + if cutoff is not None: + queryset = queryset[:cutoff] + + return OrderedDict([ + ( + # this next line here is the only delta from our parent class: from 'self' to 'super(...)' + super(SlugRelatedFieldWithReadSerializer, self).to_representation(item), + self.display_value(item) + ) + for item in queryset + ]) diff --git a/course_discovery/apps/api/filters.py b/course_discovery/apps/api/filters.py index d57bd465db..99b9972704 100644 --- a/course_discovery/apps/api/filters.py +++ b/course_discovery/apps/api/filters.py @@ -1,20 +1,22 @@ +import datetime import logging +import pytz from django.contrib.auth import get_user_model -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from django.utils.translation import ugettext as _ from django_filters import rest_framework as filters -from drf_haystack.filters import HaystackFilter as DefaultHaystackFilter from drf_haystack.filters import HaystackFacetFilter +from drf_haystack.filters import HaystackFilter as DefaultHaystackFilter from drf_haystack.query import FacetQueryBuilder from dry_rest_permissions.generics import DRYPermissionFiltersBase from guardian.shortcuts import get_objects_for_user from rest_framework.exceptions import NotFound, PermissionDenied from course_discovery.apps.api.utils import cast2int -from course_discovery.apps.course_metadata.choices import ProgramStatus +from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.models import ( - Course, CourseRun, Organization, Person, Program, Subject, Topic + Course, CourseEditor, CourseRun, LevelType, Organization, Person, Program, ProgramType, Subject, Topic ) logger = logging.getLogger(__name__) @@ -56,7 +58,7 @@ def filter_list_queryset(self, request, queryset, view): class FacetQueryBuilderWithQueries(FacetQueryBuilder): def build_query(self, **query_filters): - query = super(FacetQueryBuilderWithQueries, self).build_query(**query_filters) + query = super().build_query(**query_filters) facet_serializer_cls = self.view.get_facet_serializer_class() query['query_facets'] = getattr(facet_serializer_cls.Meta, 'field_queries', {}) return query @@ -65,7 +67,7 @@ def build_query(self, **query_filters): class HaystackRequestFilterMixin: @staticmethod def get_request_filters(request): - filters = HaystackFacetFilter.get_request_filters(request) + request_filters = HaystackFacetFilter.get_request_filters(request) # Remove items with empty values. # @@ -75,11 +77,11 @@ def get_request_filters(request): # is a `QueryDict` object, not a `dict`. Dictionary comprehension will not preserve the values of # `QueryDict.getlist()`. Since we support multiple values for a single parameter, dictionary comprehension is a # dealbreaker (and production breaker). - for key in list(filters.keys()): - if not filters[key]: - del filters[key] + for key in list(request_filters.keys()): + if not request_filters[key]: + del request_filters[key] - return filters + return request_filters class HaystackFacetFilterWithQueries(HaystackRequestFilterMixin, HaystackFacetFilter): @@ -89,31 +91,31 @@ class HaystackFacetFilterWithQueries(HaystackRequestFilterMixin, HaystackFacetFi class HaystackFilter(HaystackRequestFilterMixin, DefaultHaystackFilter): @staticmethod def get_request_filters(request): - filters = HaystackRequestFilterMixin.get_request_filters(request) + request_filters = HaystackRequestFilterMixin.get_request_filters(request) # Return data for the default partner, if no partner is requested - if not any(field in filters for field in ('partner', 'partner_exact')): - filters['partner'] = request.site.partner.short_code + if not any(field in request_filters for field in ('partner', 'partner_exact')): + request_filters['partner'] = request.site.partner.short_code - return filters + return request_filters class CharListFilter(filters.CharFilter): """ Filters a field via a comma-delimited list of values. """ - def filter(self, qs, value): # pylint: disable=method-hidden + def filter(self, qs, value): if value not in (None, ''): value = value.split(',') - return super(CharListFilter, self).filter(qs, value) + return super().filter(qs, value) class UUIDListFilter(CharListFilter): """ Filters a field via a comma-delimited list of UUIDs. """ - def __init__(self, name='uuid', label=None, widget=None, method=None, lookup_expr='in', required=False, + def __init__(self, field_name='uuid', label=None, widget=None, method=None, lookup_expr='in', required=False, distinct=False, exclude=False, **kwargs): - super().__init__(name=name, label=label, widget=widget, method=method, lookup_expr=lookup_expr, + super().__init__(field_name=field_name, label=label, widget=widget, method=method, lookup_expr=lookup_expr, required=required, distinct=distinct, exclude=exclude, **kwargs) @@ -129,19 +131,41 @@ def filter_marketable(self, queryset, name, value): class CourseFilter(filters.FilterSet): - keys = CharListFilter(name='key', lookup_expr='in') + keys = CharListFilter(field_name='key', lookup_expr='in') uuids = UUIDListFilter() + course_run_statuses = CharListFilter(method='filter_by_course_run_statuses') + editors = CharListFilter(field_name='editors__user__pk', lookup_expr='in', distinct=True) class Meta: model = Course fields = ('keys', 'uuids',) + def filter_by_course_run_statuses(self, queryset, _, value): + statuses = set(value.split(',')) + or_queries = [] # a list of Q() expressions to add to our filter as alternatives to status check + + if 'in_review' in statuses: # any of our review statuses + statuses.remove('in_review') + statuses.add(CourseRunStatus.LegalReview) + statuses.add(CourseRunStatus.InternalReview) + if 'unsubmitted' in statuses: # unpublished and unarchived + statuses.remove('unsubmitted') + # "is not archived" logic stolen from CourseRun.has_ended + now = datetime.datetime.now(pytz.UTC) + or_queries.append(Q(course_runs__status=CourseRunStatus.Unpublished) & ~Q(course_runs__end__lt=now)) + + status_check = Q(course_runs__status__in=statuses) + for query in or_queries: + status_check |= query + + return queryset.filter(status_check, course_runs__hidden=False).distinct() + class CourseRunFilter(FilterSetMixin, filters.FilterSet): active = filters.BooleanFilter(method='filter_active') marketable = filters.BooleanFilter(method='filter_marketable') - keys = CharListFilter(name='key', lookup_expr='in') - license = filters.CharFilter(name='license', lookup_expr='iexact') + keys = CharListFilter(field_name='key', lookup_expr='in') + license = filters.CharFilter(field_name='license', lookup_expr='iexact') @property def qs(self): @@ -150,7 +174,7 @@ def qs(self): if not isinstance(self.queryset, QuerySet): return self.queryset - return super(CourseRunFilter, self).qs + return super().qs class Meta: model = CourseRun @@ -160,8 +184,8 @@ class Meta: class ProgramFilter(FilterSetMixin, filters.FilterSet): marketable = filters.BooleanFilter(method='filter_marketable') status = filters.MultipleChoiceFilter(choices=ProgramStatus.choices) - type = filters.CharFilter(name='type__name', lookup_expr='iexact') - types = CharListFilter(name='type__slug', lookup_expr='in') + type = filters.CharFilter(field_name='type__translations__name_t', lookup_expr='iexact') + types = CharListFilter(field_name='type__slug', lookup_expr='in') uuids = UUIDListFilter() class Meta: @@ -169,8 +193,30 @@ class Meta: fields = ('hidden', 'marketable', 'marketing_slug', 'status', 'type', 'types',) +class ProgramTypeFilter(filters.FilterSet): + language_code = filters.CharFilter(method='_set_language') + + def _set_language(self, queryset, _, language_code): + return queryset.language(language_code) + + class Meta: + model = ProgramType + fields = ('language_code',) + + +class LevelTypeFilter(filters.FilterSet): + language_code = filters.CharFilter(method='_set_language') + + def _set_language(self, queryset, _, language_code): + return queryset.language(language_code) + + class Meta: + model = LevelType + fields = ('language_code',) + + class OrganizationFilter(filters.FilterSet): - tags = CharListFilter(name='tags__name', lookup_expr='in') + tags = CharListFilter(field_name='tags__name', lookup_expr='in') uuids = UUIDListFilter() class Meta: @@ -181,7 +227,7 @@ class Meta: class PersonFilter(filters.FilterSet): class Meta: model = Person - fields = ('slug',) + fields = ('slug', 'marketing_id') class SubjectFilter(filters.FilterSet): @@ -204,3 +250,11 @@ def _set_language(self, queryset, _, language_code): class Meta: model = Topic fields = ('slug', 'language_code') + + +class CourseEditorFilter(filters.FilterSet): + course = filters.CharFilter(field_name='course__uuid') + + class Meta: + model = CourseEditor + fields = ('course',) diff --git a/course_discovery/apps/api/mixins.py b/course_discovery/apps/api/mixins.py index 8e9d3f4d5a..ef95541ec5 100644 --- a/course_discovery/apps/api/mixins.py +++ b/course_discovery/apps/api/mixins.py @@ -3,17 +3,17 @@ """ # pylint: disable=not-callable -from rest_framework.decorators import list_route +from rest_framework.decorators import action from rest_framework.response import Response -class DetailMixin(object): +class DetailMixin: """Mixin for adding in a detail endpoint using a special detail serializer.""" detail_serializer_class = None - @list_route(methods=['get']) - def details(self, request): # pylint: disable=unused-argument + @action(detail=False, methods=['get']) + def details(self, request): """ List detailed results. --- diff --git a/course_discovery/apps/api/pagination.py b/course_discovery/apps/api/pagination.py index 7f0a8b4211..5bab76bc06 100644 --- a/course_discovery/apps/api/pagination.py +++ b/course_discovery/apps/api/pagination.py @@ -1,5 +1,5 @@ -from rest_framework.pagination import PageNumberPagination as BasePageNumberPagination from rest_framework.pagination import LimitOffsetPagination +from rest_framework.pagination import PageNumberPagination as BasePageNumberPagination class PageNumberPagination(BasePageNumberPagination): diff --git a/course_discovery/apps/api/permissions.py b/course_discovery/apps/api/permissions.py index d1911cfaa0..1e269fcc8e 100644 --- a/course_discovery/apps/api/permissions.py +++ b/course_discovery/apps/api/permissions.py @@ -1,11 +1,107 @@ -from rest_framework.permissions import BasePermission +from django.conf import settings +from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoModelPermissions + +from course_discovery.apps.course_metadata.models import CourseEditor +from course_discovery.apps.course_metadata.utils import parse_course_key_fragment + +USERNAME_REPLACEMENT_GROUP = "username_replacement_admin" class ReadOnlyByPublisherUser(BasePermission): """ - Custom Permission class to check user is a publisher user. + Custom Permission class to check user is a publisher user or a staff user. """ def has_permission(self, request, view): if request.method == 'GET': - return request.user.groups.exists() + return request.user.is_staff or request.user.groups.exists() return True + + +class IsInOrgOrReadOnly(BasePermission): + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + else: + org = request.data.get('org') + if not org: + # Fail happily because OPTIONS goes down this path too with a fake POST. + # If this is a real POST, we'll complain about the missing org in the view. + return True + return CourseEditor.can_create_course(request.user, org) + + +class IsCourseEditorOrReadOnly(BasePermission): + """ + Custom Permission class to check user is a course editor for the course, if they are trying to write. + """ + def has_permission(self, request, view): + if request.method == 'POST': + org = request.data.get('org') + if not org: + # Fail happily because OPTIONS goes down this path too with a fake POST. + # If this is a real POST, we'll complain about the missing org in the view. + return True + return CourseEditor.can_create_course(request.user, org) + else: + return True # other write access attempts will be caught by object permissions below + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + else: + return CourseEditor.is_course_editable(request.user, obj) + + +class IsCourseRunEditorOrDjangoOrReadOnly(BasePermission): + """ + Custom Permission class to check user is a course editor for the course or has django model access + """ + def __init__(self): + self.django_perms = DjangoModelPermissions() + + def has_permission(self, request, view): + if self.django_perms.has_permission(request, view): + return True + elif request.user.is_staff: + return True + elif request.method == 'POST': + course = request.data.get('course') + if not course: + # Fail happily because OPTIONS goes down this path too with a fake POST. + # If this is a real POST, we'll complain about the missing course in the view. + return True + org, _ = parse_course_key_fragment(course) + return org and CourseEditor.can_create_course(request.user, org) + else: + return True # other write access attempts will be caught by object permissions below + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + else: + return CourseEditor.is_course_editable(request.user, obj.course) + + +class CanReplaceUsername(BasePermission): + """ + Grants access to the Username Replacement API for the service user. + """ + def has_permission(self, request, view): + return request.user.username == settings.USERNAME_REPLACEMENT_WORKER + + +class CanAppointCourseEditor(BasePermission): + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + else: + course = request.data.get('course') + if not course: + # Fail happily because OPTIONS goes down this path too with a fake POST. + # If this is a real POST, we'll complain about the missing course in the view. + return True + + # We could do a lookup on the course from the request above, but the logic already exists in the view so we + # use that to avoid writing it twice + return CourseEditor.is_course_editable(request.user, view.course) diff --git a/course_discovery/apps/api/renderers.py b/course_discovery/apps/api/renderers.py index a37203e478..85f5dc1620 100644 --- a/course_discovery/apps/api/renderers.py +++ b/course_discovery/apps/api/renderers.py @@ -14,52 +14,69 @@ class AffiliateWindowXMLRenderer(XMLRenderer): class CourseRunCSVRenderer(CSVStreamingRenderer): """ CSV renderer for course runs. """ + # This ordering is mostly alphabetical, for historical reasons. In 2016, we added this CSV endpoint with a nice + # sensible field ordering (like, key as the first column). In 2018, we broke that ordering and accidentally + # switched to DRF-CSV's default ordering (alphabetical field names). Which, for the year that it was in the wild, + # meant that any new course fields would be inserted into the middle, breaking ordering again. + # + # Now, I don't know how important ordering *really* is - the headers are labeled, so a sufficiently advanced + # parser can always find what the key is. But a dumb or just quickly-written parser (like the kind of script a + # partner might throw together in an afternoon) might reasonably assume columns aren't moving around on them. + # + # Anyway. When I noticed this was going on, I froze the alphabetical ordering at the time of writing, to make it + # easier to assume column ordering and write dumb scripts that ingest this data. New columns will be no longer be + # automatically appended to the end. So if we want them, we'll need to explicitly add them. + # + # (If you're adding new columns, please add them to the end to avoid breaking that theoretically-useful consistent + # ordering that we now have. Even though that means making this mostly alphabetical list less alphabetical.) header = [ - 'key', - 'title', - 'pacing_type', - 'start', + 'announcement', + 'content_language', + 'course_key', 'end', - 'enrollment_start', 'enrollment_end', - 'announcement', + 'enrollment_start', + 'expected_learning_items', 'full_description', - 'short_description', - 'marketing_url', - 'image.src', 'image.description', 'image.height', + 'image.src', 'image.width', - 'video.src', - 'video.description', - 'video.image.src', - 'video.image.description', - 'video.image.height', - 'video.image.width', - 'content_language', + 'key', 'level_type', + 'marketing_url', 'max_effort', 'min_effort', - 'subjects', - 'expected_learning_items', - 'prerequisites', + 'modified', 'owners', - 'sponsors', + 'pacing_type', + 'prerequisites', 'seats.audit.type', + 'seats.credit.credit_hours', + 'seats.credit.credit_provider', + 'seats.credit.currency', + 'seats.credit.price', + 'seats.credit.type', + 'seats.credit.upgrade_deadline', 'seats.honor.type', - 'seats.professional.type', - 'seats.professional.price', + 'seats.masters.type', 'seats.professional.currency', + 'seats.professional.price', + 'seats.professional.type', 'seats.professional.upgrade_deadline', - 'seats.verified.type', - 'seats.verified.price', 'seats.verified.currency', + 'seats.verified.price', + 'seats.verified.type', 'seats.verified.upgrade_deadline', - 'seats.credit.type', - 'seats.credit.price', - 'seats.credit.currency', - 'seats.credit.upgrade_deadline', - 'seats.credit.credit_provider', - 'seats.credit.credit_hours', - 'modified', + 'short_description', + 'sponsors', + 'start', + 'subjects', + 'title', + 'video.description', + 'video.image.description', + 'video.image.height', + 'video.image.src', + 'video.image.width', + 'video.src', ] diff --git a/course_discovery/apps/api/serializers.py b/course_discovery/apps/api/serializers.py index 40e95f71ed..7116d00206 100644 --- a/course_discovery/apps/api/serializers.py +++ b/course_discovery/apps/api/serializers.py @@ -1,34 +1,53 @@ -# pylint: disable=abstract-method,no-member +# pylint: disable=abstract-method import datetime import json +import logging +import re +from collections import OrderedDict +from operator import attrgetter from urllib.parse import urlencode +from uuid import uuid4 import pytz import waffle from django.conf import settings from django.contrib.auth import get_user_model +from django.core.exceptions import FieldDoesNotExist from django.db.models.query import Prefetch from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ +from drf_dynamic_fields import DynamicFieldsMixin from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer, HaystackSerializerMixin +from opaque_keys.edx.locator import CourseLocator from rest_framework import serializers -from rest_framework.fields import DictField +from rest_framework.fields import CharField, CreateOnlyDefault, DictField, IntegerField, UUIDField +from rest_framework.metadata import SimpleMetadata +from rest_framework.relations import ManyRelatedField +from rest_framework.utils.field_mapping import get_field_kwargs from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from course_discovery.apps.api.fields import ImageField, StdImageSerializerField +from course_discovery.apps.api.fields import ( + HtmlField, ImageField, SlugRelatedFieldWithReadSerializer, SlugRelatedTranslatableField, StdImageSerializerField +) +from course_discovery.apps.api.utils import StudioAPI from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.core.api_client.lms import LMSAPIClient from course_discovery.apps.course_metadata import search_indexes from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus +from course_discovery.apps.course_metadata.fields import HtmlField as MetadataHtmlField from course_discovery.apps.course_metadata.models import ( - FAQ, AdditionalPromoArea, CorporateEndorsement, Course, CourseEntitlement, CourseRun, Curriculum, Degree, - DegreeCost, DegreeDeadline, Endorsement, IconTextPairing, Image, Organization, Pathway, Person, + FAQ, AdditionalPromoArea, Collaborator, CorporateEndorsement, Course, CourseEditor, CourseEntitlement, CourseRun, + CourseRunType, CourseType, Curriculum, CurriculumCourseMembership, CurriculumProgramMembership, Degree, DegreeCost, + DegreeDeadline, Endorsement, IconTextPairing, Image, LevelType, Mode, Organization, Pathway, Person, PersonAreaOfExpertise, PersonSocialNetwork, Position, Prerequisite, Program, ProgramType, Ranking, Seat, SeatType, - Subject, Topic, Video + Subject, Topic, Track, Video ) -from course_discovery.apps.publisher.models import CourseRun as PublisherCourseRun +from course_discovery.apps.course_metadata.utils import get_course_run_estimated_hours, parse_course_key_fragment +from course_discovery.apps.ietf_language_tags.models import LanguageTag +from course_discovery.apps.publisher.api.serializers import GroupUserSerializer User = get_user_model() +logger = logging.getLogger(__name__) COMMON_IGNORED_FIELDS = ('text',) COMMON_SEARCH_FIELD_ALIASES = {'q': 'text'} @@ -43,6 +62,7 @@ 'language', 'seats', 'seats__currency', + 'seats__type', 'staff', 'staff__position', 'staff__position__organization', @@ -54,6 +74,7 @@ 'authoring_organizations__tags', 'course_runs', 'expected_learning_items', + 'url_slug_history', 'level_type', 'prerequisites', 'programs', @@ -62,6 +83,7 @@ 'sponsoring_organizations__tags', 'subjects', 'video', + 'collaborators' ], } @@ -71,7 +93,34 @@ } -def get_marketing_url_for_user(partner, user, marketing_url, exclude_utm=False): +# Implementation from drf-haystack 1.8.6, but without checking for index_fieldname. +# If we include that (which wasn't in 1.8.2), something doesn't recognize/filter OneToOne or ForeignKey +# fields and DRF fails while calling validators for those fields. + +def get_default_field_kwargs(model, field): + kwargs = {} + try: + field_name = field.model_attr # chopped off "or field.index_fieldname" + model_field = model._meta.get_field(field_name) + kwargs.update(get_field_kwargs(field_name, model_field)) + + delete_attrs = [ + "allow_blank", + "choices", + "model_field", + "allow_unicode", + ] + + for attr in delete_attrs: + if attr in kwargs: + del kwargs[attr] + except FieldDoesNotExist: + pass + + return kwargs + + +def get_marketing_url_for_user(partner, user, marketing_url, exclude_utm=False, draft=False, official_version=None): """ Return the given marketing URL with affiliate query parameters for the user. @@ -82,11 +131,12 @@ def get_marketing_url_for_user(partner, user, marketing_url, exclude_utm=False): Keyword Arguments: exclude_utm (bool): Whether to exclude UTM parameters from marketing URLs. - + draft (bool): True if this is a Draft version + official_version: Object for the Official version of the Object, None otherwise Returns: str | None """ - if not marketing_url: + if not marketing_url or (draft and not official_version): return None elif exclude_utm: return marketing_url @@ -95,7 +145,7 @@ def get_marketing_url_for_user(partner, user, marketing_url, exclude_utm=False): 'utm_source': get_utm_source_for_user(partner, user), 'utm_medium': user.referral_tracking_id, }) - return '{url}?{params}'.format(url=marketing_url, params=params) + return f'{marketing_url}?{params}' def get_lms_course_url_for_archived(partner, course_key): @@ -113,7 +163,7 @@ def get_lms_course_url_for_archived(partner, course_key): if not course_key or not lms_url: return None - return '{lms_url}/courses/{course_key}/course/'.format(lms_url=lms_url, course_key=course_key) + return f'{lms_url}/courses/{course_key}/course/' def get_utm_source_for_user(partner, user): @@ -143,9 +193,24 @@ def get_utm_source_for_user(partner, user): return slugify(utm_source) -class TimestampModelSerializer(serializers.ModelSerializer): +class BaseModelSerializer(serializers.ModelSerializer): + """ Base ModelSerializer class for any generic overrides we want """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.serializer_field_mapping[MetadataHtmlField] = HtmlField + + +class TimestampModelSerializer(BaseModelSerializer): """Serializer for timestamped models.""" - modified = serializers.DateTimeField() + modified = serializers.DateTimeField(required=False) + + +class CommentSerializer(serializers.Serializer): + """ + Serializer for retrieving comments from Salesforce. + This is required by DRF despite being empty. + """ class ContentTypeSerializer(serializers.Serializer): @@ -155,21 +220,21 @@ class ContentTypeSerializer(serializers.Serializer): def get_content_type(self, obj): return obj._meta.model_name - class Meta(object): + class Meta: fields = ('content_type',) -class NamedModelSerializer(serializers.ModelSerializer): +class NamedModelSerializer(BaseModelSerializer): """Serializer for models inheriting from ``AbstractNamedModel``.""" name = serializers.CharField() - class Meta(object): + class Meta: fields = ('name',) -class TitleDescriptionSerializer(serializers.ModelSerializer): +class TitleDescriptionSerializer(BaseModelSerializer): """Serializer for models inheriting from ``AbstractTitleDescription``.""" - class Meta(object): + class Meta: fields = ('title', 'description',) @@ -179,24 +244,54 @@ class Meta(TitleDescriptionSerializer.Meta): model = AdditionalPromoArea -class FAQSerializer(serializers.ModelSerializer): +class FAQSerializer(BaseModelSerializer): """Serializer for the ``FAQ`` model.""" - class Meta(object): + class Meta: model = FAQ fields = ('question', 'answer',) -class SubjectSerializer(serializers.ModelSerializer): +class SubjectSerializer(DynamicFieldsMixin, BaseModelSerializer): """Serializer for the ``Subject`` model.""" + number_of_courses = serializers.SerializerMethodField() @classmethod - def prefetch_queryset(cls): - return Subject.objects.filter() + def prefetch_queryset(cls, partner): + return Subject.objects.filter(partner=partner).prefetch_related('translations') - class Meta(object): + class Meta: model = Subject - fields = ('name', 'subtitle', 'description', 'banner_image_url', 'card_image_url', 'slug', 'uuid') + fields = ('name', 'subtitle', 'description', 'banner_image_url', 'card_image_url', 'slug', 'uuid', 'marketing_url', 'number_of_courses') + + @property + def choices(self): + # choices shows the possible values via HTTP's OPTIONS verb + return OrderedDict(sorted([(x.slug, x.name) for x in Subject.objects.all()], key=lambda x: x[1])) + + def get_number_of_courses(self, obj): + return CourseRun.objects.filter(course__subjects=obj, status=CourseRunStatus.Published).count() + +class CollaboratorSerializer(BaseModelSerializer): + """Serializer for the ``Collaborator`` model.""" + image = StdImageSerializerField() + image_url = serializers.SerializerMethodField() + + @classmethod + def prefetch_queryset(cls): + return Collaborator.objects.all() + + def get_image_url(self, obj): + if obj.image: + return obj.image_url + return None + + def create(self, validated_data): + return Collaborator.objects.create(**validated_data) + + class Meta: + model = Collaborator + fields = ('name', 'image', 'image_url', 'uuid') class PrerequisiteSerializer(NamedModelSerializer): @@ -206,7 +301,7 @@ class Meta(NamedModelSerializer.Meta): model = Prerequisite -class MediaSerializer(serializers.ModelSerializer): +class MediaSerializer(BaseModelSerializer): """Serializer for models inheriting from ``AbstractMediaModel``.""" src = serializers.CharField() description = serializers.CharField() @@ -217,7 +312,7 @@ class ImageSerializer(MediaSerializer): height = serializers.IntegerField() width = serializers.IntegerField() - class Meta(object): + class Meta: model = Image fields = ('src', 'description', 'height', 'width') @@ -226,20 +321,25 @@ class VideoSerializer(MediaSerializer): """Serializer for the ``Video`` model.""" image = ImageSerializer() - class Meta(object): + class Meta: model = Video fields = ('src', 'description', 'image',) -class PositionSerializer(serializers.ModelSerializer): +class PositionSerializer(BaseModelSerializer): """Serializer for the ``Position`` model.""" organization_marketing_url = serializers.SerializerMethodField() + organization_uuid = serializers.SerializerMethodField() + organization_logo_image_url = serializers.SerializerMethodField() + # Order organization by key so that frontends will display dropdowns of organization choices that way + organization = serializers.PrimaryKeyRelatedField(allow_null=True, write_only=True, required=False, + queryset=Organization.objects.all().order_by('key')) - class Meta(object): + class Meta: model = Position fields = ( 'title', 'organization_name', 'organization', 'organization_id', 'organization_override', - 'organization_marketing_url', + 'organization_marketing_url', 'organization_uuid', 'organization_logo_image_url' ) extra_kwargs = { 'organization': {'write_only': True} @@ -248,9 +348,72 @@ class Meta(object): def get_organization_marketing_url(self, obj): if obj.organization: return obj.organization.marketing_url + return None + + def get_organization_uuid(self, obj): + if obj.organization: + return obj.organization.uuid + return None + + def get_organization_logo_image_url(self, obj): + if obj.organization: + image = obj.organization.logo_image + if image: + return image.url + return None -class MinimalPersonSerializer(serializers.ModelSerializer): +class MinimalOrganizationSerializer(BaseModelSerializer): + class Meta: + model = Organization + fields = ('uuid', 'key', 'name', 'auto_generate_course_run_keys',) + read_only_fields = ('auto_generate_course_run_keys',) + + +class OrganizationSerializer(TaggitSerializer, MinimalOrganizationSerializer): + """Serializer for the ``Organization`` model.""" + tags = TagListSerializerField() + certificate_logo_image_url = serializers.SerializerMethodField() + logo_image_url = serializers.SerializerMethodField() + banner_image_url = serializers.SerializerMethodField() + + def get_certificate_logo_image_url(self, obj): + image = getattr(obj, 'certificate_logo_image', None) + if image: + return image.url + return None + + def get_logo_image_url(self, obj): + image = getattr(obj, 'logo_image', None) + if image: + return image.url + return None + + def get_banner_image_url(self, obj): + image = getattr(obj, 'banner_image', None) + if image: + return image.url + return None + + @classmethod + def prefetch_queryset(cls, partner): + return Organization.objects.filter(partner=partner).select_related('partner').prefetch_related('tags') + + class Meta(MinimalOrganizationSerializer.Meta): + fields = MinimalOrganizationSerializer.Meta.fields + ( + 'certificate_logo_image_url', + 'description', + 'homepage_url', + 'tags', + 'logo_image_url', + 'marketing_url', + 'slug', + 'banner_image_url', + ) + read_only_fields = ('slug',) + + +class MinimalPersonSerializer(BaseModelSerializer): """ Minimal serializer for the ``Person`` model. """ @@ -266,15 +429,19 @@ class MinimalPersonSerializer(serializers.ModelSerializer): @classmethod def prefetch_queryset(cls): return Person.objects.all().select_related( - 'position__organization' - ).prefetch_related('person_networks') + 'position__organization', + ).prefetch_related( + 'person_networks', + 'areas_of_expertise', + 'position__organization__partner', + ) - class Meta(object): + class Meta: model = Person fields = ( 'uuid', 'salutation', 'given_name', 'family_name', 'bio', 'slug', 'position', 'areas_of_expertise', 'profile_image', 'partner', 'works', 'urls', 'urls_detailed', 'email', 'profile_image_url', 'major_works', - 'published', + 'published', 'marketing_id', 'marketing_url', 'designation', 'phone_number', 'website', ) extra_kwargs = { 'partner': {'write_only': True} @@ -290,6 +457,7 @@ def get_social_network_url(self, url_type, obj): if social_networks: return social_networks[0].url + return None def get_profile_image_url(self, obj): return obj.get_profile_image_url @@ -302,30 +470,38 @@ def get_urls(self, obj): } def get_urls_detailed(self, obj): + """ + Sort the person_networks with sorted rather than order_by to avoid + additional calls to the database + """ return [{ 'id': network.id, 'type': network.type, 'title': network.title, 'display_title': network.display_title, 'url': network.url, - } for network in obj.person_networks.all().order_by('id')] + } for network in sorted(obj.person_networks.all(), key=attrgetter('id'))] def get_areas_of_expertise(self, obj): + """ + Sort the areas_of_expertise with sorted rather than order_by to avoid + additional calls to the database + """ return [{ 'id': area_of_expertise.id, 'value': area_of_expertise.value, - } for area_of_expertise in obj.areas_of_expertise.all().order_by('id')] + } for area_of_expertise in sorted(obj.areas_of_expertise.all(), key=attrgetter('id'))] def get_email(self, _obj): # We are removing this field so this is to not break any APIs - return None + return _obj.email class PersonSerializer(MinimalPersonSerializer): """Full serializer for the ``Person`` model.""" - def validate(self, data): - validated_data = super(PersonSerializer, self).validate(data) + def validate(self, attrs): + validated_data = super().validate(attrs) validated_data['urls_detailed'] = self.initial_data.get('urls_detailed', []) validated_data['areas_of_expertise'] = self.initial_data.get('areas_of_expertise', []) return validated_data @@ -400,7 +576,7 @@ def update(self, instance, validated_data): return instance -class EndorsementSerializer(serializers.ModelSerializer): +class EndorsementSerializer(BaseModelSerializer): """Serializer for the ``Endorsement`` model.""" endorser = MinimalPersonSerializer() @@ -408,12 +584,12 @@ class EndorsementSerializer(serializers.ModelSerializer): def prefetch_queryset(cls): return Endorsement.objects.all().select_related('endorser') - class Meta(object): + class Meta: model = Endorsement fields = ('endorser', 'quote',) -class CorporateEndorsementSerializer(serializers.ModelSerializer): +class CorporateEndorsementSerializer(BaseModelSerializer): """Serializer for the ``CorporateEndorsement`` model.""" image = ImageSerializer() individual_endorsements = EndorsementSerializer(many=True) @@ -424,19 +600,58 @@ def prefetch_queryset(cls): Prefetch('individual_endorsements', queryset=EndorsementSerializer.prefetch_queryset()), ) - class Meta(object): + class Meta: model = CorporateEndorsement fields = ('corporation_name', 'statement', 'image', 'individual_endorsements',) -class SeatSerializer(serializers.ModelSerializer): +class SeatTypeSerializer(BaseModelSerializer): + """Serializer for the ``SeatType`` model.""" + class Meta: + model = SeatType + fields = ('name', 'slug') + + +class ModeSerializer(BaseModelSerializer): + """Serializer for the ``Mode`` model.""" + class Meta: + model = Mode + fields = ('name', 'slug', 'is_id_verified', 'is_credit_eligible', 'certificate_type', 'payee') + + +class TrackSerializer(BaseModelSerializer): + """Serializer for the ``Track`` model.""" + seat_type = SeatTypeSerializer(allow_null=True) + mode = ModeSerializer() + + @classmethod + def prefetch_queryset(cls): + return Track.objects.select_related('seat_type', 'mode') + + class Meta: + model = Track + fields = ('seat_type', 'mode') + + +class CourseRunTypeSerializer(BaseModelSerializer): + """Serializer for the ``CourseRunType`` model.""" + modes = serializers.SerializerMethodField() + + class Meta: + model = CourseRunType + fields = ('uuid', 'name', 'is_marketable', 'modes') + + def get_modes(self, obj): + return [track.mode.slug for track in obj.tracks.all()] + + +class SeatSerializer(BaseModelSerializer): """Serializer for the ``Seat`` model.""" - type = serializers.ChoiceField( - choices=[name for name, __ in Seat.SEAT_TYPE_CHOICES] - ) + type = serializers.SlugRelatedField(slug_field='slug', queryset=SeatType.objects.all().order_by('name')) price = serializers.DecimalField( decimal_places=Seat.PRICE_FIELD_CONFIG['decimal_places'], - max_digits=Seat.PRICE_FIELD_CONFIG['max_digits'] + max_digits=Seat.PRICE_FIELD_CONFIG['max_digits'], + min_value=0, ) currency = serializers.SlugRelatedField(read_only=True, slug_field='code') upgrade_deadline = serializers.DateTimeField() @@ -447,54 +662,39 @@ class SeatSerializer(serializers.ModelSerializer): @classmethod def prefetch_queryset(cls): - return Seat.objects.all().select_related('currency') + return Seat.everything.all().select_related('currency', 'type') - class Meta(object): + class Meta: model = Seat fields = ('type', 'price', 'currency', 'upgrade_deadline', 'credit_provider', 'credit_hours', 'sku', 'bulk_sku') -class CourseEntitlementSerializer(serializers.ModelSerializer): +class CourseEntitlementSerializer(BaseModelSerializer): """Serializer for the ``CourseEntitlement`` model.""" price = serializers.DecimalField( decimal_places=CourseEntitlement.PRICE_FIELD_CONFIG['decimal_places'], - max_digits=CourseEntitlement.PRICE_FIELD_CONFIG['max_digits'] + max_digits=CourseEntitlement.PRICE_FIELD_CONFIG['max_digits'], + min_value=0, ) currency = serializers.SlugRelatedField(read_only=True, slug_field='code') - sku = serializers.CharField() - mode = serializers.SlugRelatedField(slug_field='slug', queryset=SeatType.objects.all()) - expires = serializers.DateTimeField() + sku = serializers.CharField(allow_blank=True, allow_null=True) + mode = serializers.SlugRelatedField(slug_field='slug', queryset=SeatType.objects.all().order_by('name')) + expires = serializers.SerializerMethodField() @classmethod def prefetch_queryset(cls): - return CourseEntitlement.objects.all().select_related('currency', 'mode') + return CourseEntitlement.everything.all().select_related('currency', 'mode') - class Meta(object): + class Meta: model = CourseEntitlement fields = ('mode', 'price', 'currency', 'sku', 'expires') - -class MinimalOrganizationSerializer(serializers.ModelSerializer): - class Meta: - model = Organization - fields = ('uuid', 'key', 'name',) - - -class OrganizationSerializer(TaggitSerializer, MinimalOrganizationSerializer): - """Serializer for the ``Organization`` model.""" - tags = TagListSerializerField() - - @classmethod - def prefetch_queryset(cls, partner): - return Organization.objects.filter(partner=partner).select_related('partner').prefetch_related('tags') - - class Meta(MinimalOrganizationSerializer.Meta): - fields = MinimalOrganizationSerializer.Meta.fields + ( - 'certificate_logo_image_url', 'description', 'homepage_url', 'tags', 'logo_image_url', 'marketing_url', - ) + def get_expires(self, _obj): + # This was a never-used, deprecated field. Just keep returning None to avoid breaking our API. + return None -class CatalogSerializer(serializers.ModelSerializer): +class CatalogSerializer(BaseModelSerializer): """Serializer for the ``Catalog`` model.""" courses_count = serializers.IntegerField(read_only=True, help_text=_('Number of courses contained in this catalog')) viewers = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all(), many=True, @@ -507,53 +707,95 @@ def create(self, validated_data): viewers = User.objects.filter(username__in=viewers) # Set viewers after the model has been saved - instance = super(CatalogSerializer, self).create(validated_data) + instance = super().create(validated_data) instance.viewers = viewers instance.save() return instance - class Meta(object): + class Meta: model = Catalog fields = ('id', 'name', 'query', 'courses_count', 'viewers') -class NestedProgramSerializer(serializers.ModelSerializer): +class ProgramTypeSerializer(BaseModelSerializer): + """ Serializer for the Program Types. """ + applicable_seat_types = serializers.SlugRelatedField(many=True, read_only=True, slug_field='slug') + logo_image = StdImageSerializerField() + name = serializers.SerializerMethodField('get_translated_name') + + @classmethod + def prefetch_queryset(cls, queryset): + return queryset.prefetch_related('applicable_seat_types', 'translations') + + def get_translated_name(self, obj): + return obj.name_t + + class Meta: + model = ProgramType + fields = ('uuid', 'name', 'logo_image', 'applicable_seat_types', 'slug', 'coaching_supported') + + +class LevelTypeSerializer(BaseModelSerializer): + """Serializer for the ``LevelType`` model.""" + name = serializers.CharField(source='name_t') + + @classmethod + def prefetch_queryset(cls, queryset): + return queryset.prefetch_related('translations') + + class Meta: + model = LevelType + fields = ('name', 'sort_value') + + +class ProgramTypeAttrsSerializer(BaseModelSerializer): + """ Serializer for the Program Type Attributes. """ + + class Meta: + model = ProgramType + fields = ('uuid', 'slug', 'coaching_supported') + + +class NestedProgramSerializer(DynamicFieldsMixin, BaseModelSerializer): """ Serializer used when nesting a Program inside another entity (e.g. a Course). The resulting data includes only the basic details of the Program and none of the details about its related entities, aside from the number of courses in the program. """ type = serializers.SlugRelatedField(slug_field='name', queryset=ProgramType.objects.all()) + type_attrs = ProgramTypeAttrsSerializer(source='type') number_of_courses = serializers.SerializerMethodField() + @classmethod + def prefetch_queryset(cls, queryset=None): + # Explicitly check for None to avoid returning all Programs when the + # queryset passed in happens to be empty. + return queryset if queryset is not None else Program.objects.all() + class Meta: model = Program - fields = ('uuid', 'title', 'type', 'marketing_slug', 'marketing_url', 'number_of_courses',) - read_only_fields = ('uuid', 'marketing_url', 'number_of_courses',) + fields = ('uuid', 'title', 'type', 'type_attrs', 'marketing_slug', 'marketing_url', 'number_of_courses',) + read_only_fields = ('uuid', 'marketing_url', 'number_of_courses', 'type_attrs') def get_number_of_courses(self, obj): return obj.courses.count() -class MinimalPublisherCourseRunSerializer(TimestampModelSerializer): - course = serializers.SerializerMethodField() - title = serializers.SerializerMethodField() - - class Meta: - model = PublisherCourseRun - fields = ('lms_course_id', 'course', 'title', 'start', 'end', 'pacing_type',) - - def get_course(self, obj): - return obj.course.key - - def get_title(self, obj): - return obj.title_override or obj.course.title - - -class MinimalCourseRunSerializer(TimestampModelSerializer): +class MinimalCourseRunSerializer(DynamicFieldsMixin, TimestampModelSerializer): image = ImageField(read_only=True, source='image_url') marketing_url = serializers.SerializerMethodField() - seats = SeatSerializer(many=True) + seats = SeatSerializer(required=False, many=True) + key = serializers.CharField(required=False, read_only=True) + title = serializers.CharField(required=False) + external_key = serializers.CharField(required=False, allow_blank=True) + short_description = HtmlField(required=False, allow_blank=True) + start = serializers.DateTimeField(required=True) # required so we can craft key number from it + end = serializers.DateTimeField(required=True) # required by studio + type = serializers.CharField(read_only=True, source='type_legacy') + run_type = serializers.SlugRelatedField(required=True, slug_field='uuid', source='type', + queryset=CourseRunType.objects.all()) + term = serializers.CharField(required=False, write_only=True) + subjects = SubjectSerializer(many=True, allow_null=True, required=False) @classmethod def prefetch_queryset(cls, queryset=None): @@ -561,15 +803,17 @@ def prefetch_queryset(cls, queryset=None): # queryset passed in happens to be empty. queryset = queryset if queryset is not None else CourseRun.objects.all() - return queryset.select_related('course').prefetch_related( + return queryset.select_related('course', 'type').prefetch_related( + '_official_version', 'course__partner', Prefetch('seats', queryset=SeatSerializer.prefetch_queryset()), ) class Meta: model = CourseRun - fields = ('key', 'uuid', 'title', 'image', 'short_description', 'marketing_url', 'seats', - 'start', 'end', 'enrollment_start', 'enrollment_end', 'pacing_type', 'type', 'status',) + fields = ('key', 'uuid', 'title', 'external_key', 'image', 'short_description', 'marketing_url', + 'seats', 'start', 'end', 'go_live_date', 'enrollment_start', 'enrollment_end', + 'pacing_type', 'type', 'run_type', 'status', 'is_enrollable', 'is_marketable', 'term', 'subjects',) def get_marketing_url(self, obj): include_archived = self.context.get('include_archived') @@ -581,25 +825,89 @@ def get_marketing_url(self, obj): obj.course.partner, self.context['request'].user, obj.marketing_url, - exclude_utm=self.context.get('exclude_utm') + exclude_utm=self.context.get('exclude_utm'), + draft=obj.draft, + official_version=obj.official_version ) return marketing_url + def ensure_term(self, data): + course = data['course'] # required + org, number = parse_course_key_fragment(course.key_for_reruns or course.key) + + # Here we determine what value to use for the term section of the course run key. If a key + # is provided in the body and the organization of the course has auto_generate_course_key + # turned on, we use that value, otherwise we calculate it using StudioAPI. + allow_key_override = False + for organization in course.authoring_organizations.all(): + if not organization.auto_generate_course_run_keys: + allow_key_override = True + break + if allow_key_override and 'term' in data: + run = data['term'] + else: + start = data['start'] # required + run = StudioAPI.calculate_course_run_key_run_value(number, start) + if 'term' in data: + data.pop('term') + key = CourseLocator(org=org, course=number, run=run) + data['key'] = str(key) + + def validate(self, attrs): + start = attrs.get('start', self.instance.start if self.instance else None) + end = attrs.get('end', self.instance.end if self.instance else None) + + if start and end and start > end: + raise serializers.ValidationError({'start': _('Start date cannot be after the End date')}) + + if not self.instance: # if we're creating an object, we need to make sure to generate a key + self.ensure_term(attrs) + elif 'term' in attrs and self.instance.key != attrs['term']: + raise serializers.ValidationError({'term': _('Term cannot be changed')}) + + return super().validate(attrs) + class CourseRunSerializer(MinimalCourseRunSerializer): """Serializer for the ``CourseRun`` model.""" - course = serializers.SlugRelatedField(read_only=True, slug_field='key') + course = serializers.SlugRelatedField(required=True, slug_field='key', queryset=Course.objects.filter_drafts()) + course_uuid = serializers.ReadOnlyField(source='course.uuid', default=None) content_language = serializers.SlugRelatedField( - read_only=True, slug_field='code', source='language', + required=False, allow_null=True, slug_field='code', source='language', + queryset=LanguageTag.objects.all().order_by('name'), help_text=_('Language in which the course is administered') ) - transcript_languages = serializers.SlugRelatedField(many=True, read_only=True, slug_field='code') - video = VideoSerializer(source='get_video') - seats = SeatSerializer(many=True) + transcript_languages = serializers.SlugRelatedField( + required=False, many=True, slug_field='code', queryset=LanguageTag.objects.all().order_by('name') + ) + video = VideoSerializer(required=False, allow_null=True, source='get_video') instructors = serializers.SerializerMethodField(help_text='This field is deprecated. Use staff.') - staff = MinimalPersonSerializer(many=True) - level_type = serializers.SlugRelatedField(read_only=True, slug_field='name') + staff = SlugRelatedFieldWithReadSerializer(slug_field='uuid', required=False, many=True, + queryset=Person.objects.all(), + read_serializer=MinimalPersonSerializer()) + level_type = serializers.SlugRelatedField( + required=False, + allow_null=True, + slug_field='name_t', + queryset=LevelType.objects.all() + ) + full_description = HtmlField(required=False, allow_blank=True) + outcome = HtmlField(required=False, allow_blank=True) + expected_program_type = serializers.SlugRelatedField( + required=False, + allow_null=True, + slug_field='slug', + queryset=ProgramType.objects.all() + ) + estimated_hours = serializers.SerializerMethodField() + subjects = SlugRelatedFieldWithReadSerializer( + slug_field='slug', + required=False, + many=True, + queryset=Subject.objects.all(), + read_serializer=SubjectSerializer(), + ) @classmethod def prefetch_queryset(cls, queryset=None): @@ -617,14 +925,58 @@ class Meta(MinimalCourseRunSerializer.Meta): 'course', 'full_description', 'announcement', 'video', 'seats', 'content_language', 'license', 'outcome', 'transcript_languages', 'instructors', 'staff', 'min_effort', 'max_effort', 'weeks_to_complete', 'modified', 'level_type', 'availability', 'mobile_available', 'hidden', 'reporting_type', 'eligible_for_financial_aid', - 'first_enrollable_paid_seat_price', 'has_ofac_restrictions', - 'enrollment_count', 'recent_enrollment_count', + 'first_enrollable_paid_seat_price', 'has_ofac_restrictions', 'ofac_comment', + 'enrollment_count', 'recent_enrollment_count', 'expected_program_type', 'expected_program_name', + 'course_uuid', 'estimated_hours', 'invite_only', 'subjects', + 'is_marketing_price_set', 'marketing_price_value', 'is_marketing_price_hidden', 'featured', 'card_image_url', + 'average_rating', 'total_raters', 'yt_video_url', 'course_duration_override', 'course_training_packages', + 'course_department', 'course_certifications', 'course_format', 'course_difficulty_level', 'course_language', ) + read_only_fields = ('enrollment_count', 'recent_enrollment_count',) def get_instructors(self, obj): # pylint: disable=unused-argument # This field is deprecated. Use the staff field. return [] + def get_estimated_hours(self, obj): + return get_course_run_estimated_hours(obj) + + def update_video(self, instance, video_data): + # A separate video object is a historical concept. These days, we really just use the link address. So + # we look up a foreign key just based on the link and don't bother trying to match or set any other fields. + # This matches the behavior of our traditional built-in publisher tool. Similarly, we don't try to delete + # old video entries (just like the publisher tool didn't). + video_url = video_data and video_data.get('src') + if video_url: + video, _ = Video.objects.get_or_create(src=video_url) + instance.video = video + else: + instance.video = None + # save() will be called by main update() + + def update(self, instance, validated_data): + # logging to help debug error around course url slugs incrementing + logger.info('The data coming from publisher is {}.'.format(validated_data)) + + # Handle writing nested video data separately + if 'get_video' in validated_data: + self.update_video(instance, validated_data.pop('get_video')) + return super().update(instance, validated_data) + + def validate(self, attrs): + course = attrs.get('course', None) + if course and self.instance and self.instance.course != course: + raise serializers.ValidationError({'course': _('Course cannot be changed for an existing course run')}) + + min_effort = attrs.get('min_effort', self.instance.min_effort if self.instance else None) + max_effort = attrs.get('max_effort', self.instance.max_effort if self.instance else None) + if min_effort and max_effort and min_effort > max_effort: + raise serializers.ValidationError({'min_effort': _('Minimum effort cannot be greater than Maximum effort')}) + if min_effort and max_effort and min_effort == max_effort: + raise serializers.ValidationError({'min_effort': _('Minimum effort and Maximum effort cannot be the same')}) + + return super().validate(attrs) + class CourseRunWithProgramsSerializer(CourseRunSerializer): """A ``CourseRunSerializer`` which includes programs derived from parent course.""" @@ -667,11 +1019,14 @@ class ContainedCourseRunsSerializer(serializers.Serializer): ) -class MinimalCourseSerializer(TimestampModelSerializer): +class MinimalCourseSerializer(DynamicFieldsMixin, TimestampModelSerializer): course_runs = MinimalCourseRunSerializer(many=True) - entitlements = CourseEntitlementSerializer(many=True) + entitlements = CourseEntitlementSerializer(required=False, many=True) owners = MinimalOrganizationSerializer(many=True, source='authoring_organizations') image = ImageField(read_only=True, source='image_url') + type = serializers.SlugRelatedField(required=True, slug_field='uuid', queryset=CourseType.objects.all()) + uuid = UUIDField(read_only=True, default=CreateOnlyDefault(uuid4)) + url_slug = serializers.SerializerMethodField() @classmethod def prefetch_queryset(cls, queryset=None, course_runs=None): @@ -679,65 +1034,127 @@ def prefetch_queryset(cls, queryset=None, course_runs=None): # queryset passed in happens to be empty. queryset = queryset if queryset is not None else Course.objects.all() - return queryset.select_related('partner').prefetch_related( + return queryset.select_related('partner', 'type', 'canonical_course_run').prefetch_related( 'authoring_organizations', - 'entitlements', + Prefetch('entitlements', queryset=CourseEntitlementSerializer.prefetch_queryset()), Prefetch('course_runs', queryset=MinimalCourseRunSerializer.prefetch_queryset(queryset=course_runs)), ) + def get_url_slug(self, obj): # pylint: disable=unused-argument + return None # this has been removed from the MinimalCourseSerializer, set to None to not break APIs + class Meta: model = Course - fields = ('key', 'uuid', 'title', 'course_runs', 'entitlements', 'owners', 'image', 'short_description',) + fields = ('key', 'uuid', 'title', 'course_runs', 'entitlements', 'owners', 'image', + 'short_description', 'type', 'url_slug',) + + +class CourseEditorSerializer(serializers.ModelSerializer): + """Serializer for the ``CourseEditor`` model.""" + user = GroupUserSerializer(read_only=True) + user_id = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), write_only=True) + course = serializers.SlugRelatedField(queryset=Course.objects.filter_drafts(), slug_field='uuid') + + class Meta: + model = CourseEditor + fields = ( + 'id', + 'user', + 'user_id', + 'course', + ) + + def create(self, validated_data): + course_editor = CourseEditor.objects.create( + user=validated_data['user_id'], + course=validated_data['course'] + ) + return course_editor class CourseSerializer(TaggitSerializer, MinimalCourseSerializer): """Serializer for the ``Course`` model.""" - level_type = serializers.SlugRelatedField(read_only=True, slug_field='name') - subjects = SubjectSerializer(many=True) - prerequisites = PrerequisiteSerializer(many=True) + level_type = SlugRelatedTranslatableField(required=False, allow_null=True, slug_field='name_t', + queryset=LevelType.objects.all()) + subjects = SlugRelatedFieldWithReadSerializer(slug_field='slug', required=False, many=True, + queryset=Subject.objects.all(), + read_serializer=SubjectSerializer()) + prerequisites = PrerequisiteSerializer(required=False, many=True) expected_learning_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value') - video = VideoSerializer() - owners = OrganizationSerializer(many=True, source='authoring_organizations') - sponsors = OrganizationSerializer(many=True, source='sponsoring_organizations') + video = VideoSerializer(required=False) + owners = OrganizationSerializer(required=False, many=True, source='authoring_organizations') + sponsors = OrganizationSerializer(required=False, many=True, source='sponsoring_organizations') course_runs = CourseRunSerializer(many=True) marketing_url = serializers.SerializerMethodField() canonical_course_run_key = serializers.SerializerMethodField() original_image = ImageField(read_only=True, source='original_image_url') - extra_description = AdditionalPromoAreaSerializer() - topics = TagListSerializerField() + extra_description = AdditionalPromoAreaSerializer(required=False) + topics = TagListSerializerField(required=False) + url_slug = serializers.SlugField(read_only=True, source='active_url_slug') + url_slug_history = serializers.SlugRelatedField(slug_field='url_slug', read_only=True, many=True) + url_redirects = serializers.SlugRelatedField(slug_field='value', read_only=True, many=True) + course_run_statuses = serializers.ReadOnlyField() + editors = CourseEditorSerializer(many=True, read_only=True) + collaborators = SlugRelatedFieldWithReadSerializer(slug_field='uuid', required=False, many=True, + queryset=Collaborator.objects.all(), + read_serializer=CollaboratorSerializer()) @classmethod - def prefetch_queryset(cls, partner, queryset=None, course_runs=None): + def prefetch_queryset(cls, partner, queryset=None, course_runs=None): # pylint: disable=arguments-differ # Explicitly check for None to avoid returning all Courses when the # queryset passed in happens to be empty. queryset = queryset if queryset is not None else Course.objects.filter(partner=partner) - return queryset.select_related('level_type', 'video', 'partner', 'extra_description').prefetch_related( + return queryset.select_related( + 'level_type', + 'video', + 'video__image', + 'partner', + 'extra_description', + '_official_version', + 'canonical_course_run', + 'type', + ).prefetch_related( 'expected_learning_items', 'prerequisites', 'subjects', - 'entitlements', + 'collaborators', + 'topics', + 'url_slug_history', + 'url_redirects', Prefetch('course_runs', queryset=CourseRunSerializer.prefetch_queryset(queryset=course_runs)), + 'canonical_course_run', + 'canonical_course_run__seats', + 'canonical_course_run__seats__course_run__course', + 'canonical_course_run__seats__type', + 'canonical_course_run__seats__currency', Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), Prefetch('sponsoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), + Prefetch('entitlements', queryset=CourseEntitlementSerializer.prefetch_queryset()), ) class Meta(MinimalCourseSerializer.Meta): model = Course fields = MinimalCourseSerializer.Meta.fields + ( - 'short_description', 'full_description', 'level_type', 'subjects', 'prerequisites', + 'full_description', 'level_type', 'subjects', 'prerequisites', 'prerequisites_raw', 'expected_learning_items', 'video', 'sponsors', 'modified', 'marketing_url', 'syllabus_raw', 'outcome', 'original_image', 'card_image_url', 'canonical_course_run_key', 'extra_description', 'additional_information', 'faq', 'learner_testimonials', - 'enrollment_count', 'recent_enrollment_count', 'topics', + 'enrollment_count', 'recent_enrollment_count', 'topics', 'partner', 'key_for_reruns', 'url_slug', + 'url_slug_history', 'url_redirects', 'course_run_statuses', 'editors', 'collaborators', ) + extra_kwargs = { + 'partner': {'write_only': True} + } def get_marketing_url(self, obj): return get_marketing_url_for_user( obj.partner, self.context['request'].user, obj.marketing_url, - exclude_utm=self.context.get('exclude_utm') + exclude_utm=self.context.get('exclude_utm'), + draft=obj.draft, + official_version=obj.official_version, ) def get_canonical_course_run_key(self, obj): @@ -745,29 +1162,54 @@ def get_canonical_course_run_key(self, obj): return obj.canonical_course_run.key return None + def create(self, validated_data): + return Course.objects.create(**validated_data) + class CourseWithProgramsSerializer(CourseSerializer): """A ``CourseSerializer`` which includes programs.""" + advertised_course_run_uuid = serializers.SerializerMethodField() + course_run_keys = serializers.SerializerMethodField() course_runs = serializers.SerializerMethodField() - programs = serializers.SerializerMethodField() + programs = NestedProgramSerializer(read_only=True, many=True) + editable = serializers.SerializerMethodField() @classmethod - def prefetch_queryset(cls, partner, queryset=None, course_runs=None): + def prefetch_queryset(cls, partner, queryset=None, course_runs=None, programs=None): # pylint: disable=arguments-differ """ Similar to the CourseSerializer's prefetch_queryset, but prefetches a filtered CourseRun queryset. """ queryset = queryset if queryset is not None else Course.objects.filter(partner=partner) - - return queryset.select_related('level_type', 'video', 'partner').prefetch_related( + return queryset.select_related( + 'level_type', + 'video', + 'video__image', + 'partner', + 'canonical_course_run', + 'type', + ).prefetch_related( + '_official_version', 'expected_learning_items', 'prerequisites', - 'subjects', + 'topics', + 'url_slug_history', + Prefetch('subjects', queryset=SubjectSerializer.prefetch_queryset(partner)), Prefetch('course_runs', queryset=CourseRunSerializer.prefetch_queryset(queryset=course_runs)), Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), Prefetch('sponsoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), + Prefetch('programs', queryset=NestedProgramSerializer.prefetch_queryset(queryset=programs)), + Prefetch('entitlements', queryset=CourseEntitlementSerializer.prefetch_queryset()), ) + def get_advertised_course_run_uuid(self, course): + if course.advertised_course_run: + return course.advertised_course_run.uuid + return None + + def get_course_run_keys(self, course): + return [course_run.key for course_run in course.course_runs.all()] + def get_course_runs(self, course): return CourseRunSerializer( course.course_runs, @@ -778,17 +1220,17 @@ def get_course_runs(self, course): } ).data - def get_programs(self, obj): - if self.context.get('include_deleted_programs'): - eligible_programs = obj.programs.all() - else: - eligible_programs = obj.programs.exclude(status=ProgramStatus.Deleted) - - return NestedProgramSerializer(eligible_programs, many=True).data + def get_editable(self, course): + return CourseEditor.is_course_editable(self.context['request'].user, course) class Meta(CourseSerializer.Meta): model = Course - fields = CourseSerializer.Meta.fields + ('programs',) + fields = CourseSerializer.Meta.fields + ( + 'programs', + 'course_run_keys', + 'editable', + 'advertised_course_run_uuid' + ) class CatalogCourseSerializer(CourseSerializer): @@ -811,9 +1253,13 @@ def prefetch_queryset(cls, partner, queryset=None, course_runs=None): 'expected_learning_items', 'prerequisites', 'subjects', + 'url_slug_history', + 'editors', + 'url_redirects', Prefetch('course_runs', queryset=CourseRunSerializer.prefetch_queryset(queryset=course_runs)), Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), Prefetch('sponsoring_organizations', queryset=OrganizationSerializer.prefetch_queryset(partner)), + Prefetch('entitlements', queryset=CourseEntitlementSerializer.prefetch_queryset()), ) def get_course_runs(self, course): @@ -862,7 +1308,7 @@ def get_course_runs(self, course): ).data -class RankingSerializer(serializers.ModelSerializer): +class RankingSerializer(BaseModelSerializer): """ Ranking model serializer """ class Meta: model = Ranking @@ -871,7 +1317,7 @@ class Meta: ) -class DegreeDeadlineSerializer(serializers.ModelSerializer): +class DegreeDeadlineSerializer(BaseModelSerializer): """ DegreeDeadline model serializer """ class Meta: model = DegreeDeadline @@ -883,7 +1329,7 @@ class Meta: ) -class DegreeCostSerializer(serializers.ModelSerializer): +class DegreeCostSerializer(BaseModelSerializer): """ DegreeCost model serializer """ class Meta: model = DegreeCost @@ -893,48 +1339,156 @@ class Meta: ) -class CurriculumSerializer(serializers.ModelSerializer): +class CurriculumSerializer(BaseModelSerializer): """ Curriculum model serializer """ + courses = serializers.SerializerMethodField() + programs = serializers.SerializerMethodField() + class Meta: model = Curriculum - fields = ('marketing_text', 'marketing_text_brief') + fields = ('uuid', 'name', 'marketing_text', 'marketing_text_brief', 'is_active', 'courses', 'programs') + + def get_courses(self, curriculum): + + course_serializer = MinimalProgramCourseSerializer( + self.prefetched_courses(curriculum), + many=True, + context={ + 'request': self.context.get('request'), + 'published_course_runs_only': self.context.get('published_course_runs_only'), + 'exclude_utm': self.context.get('exclude_utm'), + 'course_runs': self.prefetched_course_runs(curriculum), + 'use_full_course_serializer': self.context.get('use_full_course_serializer', False), + } + ) + + return course_serializer.data + + def get_programs(self, curriculum): + + program_serializer = MinimalProgramSerializer( + self.prefetched_programs(curriculum), + many=True, + context={ + 'request': self.context.get('request'), + 'published_course_runs_only': self.context.get('published_course_runs_only'), + 'exclude_utm': self.context.get('exclude_utm'), + 'use_full_course_serializer': self.context.get('use_full_course_serializer', False), + } + ) + + return program_serializer.data + + def prefetch_course_memberships(self, curriculum): + """ + Prefetch all member courses and related objects for this curriculum + """ + if not hasattr(self, '_prefetched_memberships'): + queryset = CurriculumCourseMembership.objects.filter(curriculum=curriculum).select_related( + 'course__partner', 'course__type' + ).prefetch_related( + 'course', + 'course__course_runs', + 'course__course_runs__seats', + 'course__course_runs__seats__type', + 'course__course_runs__type', + 'course__entitlements', + 'course__authoring_organizations', + 'course_run_exclusions', + ) + self._prefetched_memberships = list(queryset) # pylint: disable=attribute-defined-outside-init + + def prefetch_program_memberships(self, curriculum): + """ + Prefetch all child programs and related objects for this curriculum + """ + if not hasattr(self, '_prefetched_program_memberships'): + queryset = CurriculumProgramMembership.objects.filter(curriculum=curriculum).select_related( + 'program__type', 'program__partner' + ).prefetch_related( + 'program__excluded_course_runs', + # `type` is serialized by a third-party serializer. Providing this field name allows us to + # prefetch `applicable_seat_types`, a m2m on `ProgramType`, through `type`, a foreign key to + # `ProgramType` on `Program`. + 'program__type__applicable_seat_types', + 'program__authoring_organizations', + 'program__degree', + Prefetch('program__courses', queryset=MinimalProgramCourseSerializer.prefetch_queryset()), + ) + self._prefetched_program_memberships = list(queryset) # pylint: disable=attribute-defined-outside-init + + def prefetched_programs(self, curriculum): + self.prefetch_program_memberships(curriculum) + return [membership.program for membership in self._prefetched_program_memberships] + + def prefetched_courses(self, curriculum): + self.prefetch_course_memberships(curriculum) + return [membership.course for membership in self._prefetched_memberships] + + def prefetched_course_runs(self, curriculum): + self.prefetch_course_memberships(curriculum) + return [ + course_run for membership in self._prefetched_memberships + for course_run in membership.course_runs + ] -class IconTextPairingSerializer(serializers.ModelSerializer): +class IconTextPairingSerializer(BaseModelSerializer): class Meta: model = IconTextPairing fields = ('text', 'icon',) -class DegreeSerializer(serializers.ModelSerializer): +class DegreeSerializer(BaseModelSerializer): """ Degree model serializer """ campus_image = serializers.ImageField() title_background_image = serializers.ImageField() costs = DegreeCostSerializer(many=True) - curriculum = CurriculumSerializer() quick_facts = IconTextPairingSerializer(many=True) lead_capture_image = StdImageSerializerField() deadlines = DegreeDeadlineSerializer(many=True) rankings = RankingSerializer(many=True) micromasters_background_image = StdImageSerializerField() + micromasters_path = serializers.SerializerMethodField() class Meta: model = Degree fields = ( 'application_requirements', 'apply_url', 'banner_border_color', 'campus_image', 'title_background_image', - 'costs', 'curriculum', 'deadlines', 'lead_capture_list_name', 'quick_facts', + 'costs', 'deadlines', 'lead_capture_list_name', 'quick_facts', 'overall_ranking', 'prerequisite_coursework', 'rankings', - 'lead_capture_image', 'micromasters_url', 'micromasters_long_title', 'micromasters_long_description', - 'micromasters_background_image', 'costs_fine_print', 'deadlines_fine_print', + 'lead_capture_image', 'micromasters_path', 'micromasters_url', + 'micromasters_long_title', 'micromasters_long_description', + 'micromasters_background_image', 'micromasters_org_name_override', 'costs_fine_print', + 'deadlines_fine_print', 'hubspot_lead_capture_form_id', ) + def get_micromasters_path(self, degree): + if degree and isinstance(degree.micromasters_url, str): + url = re.compile(r"https?:\/\/[^\/]*") + return url.sub('', degree.micromasters_url) + else: + return degree.micromasters_url + + +class MinimalProgramSerializer(DynamicFieldsMixin, BaseModelSerializer): + """ + Basic program serializer + + When using the DynamicFieldsMixin to get the courses field on a program, + you will also need to include the fields you want on the course object + since the course serializer also uses drf_dynamic_fields. + Eg: ?fields=courses,course_runs + """ -class MinimalProgramSerializer(serializers.ModelSerializer): authoring_organizations = MinimalOrganizationSerializer(many=True) - banner_image = StdImageSerializerField() + banner_image = StdImageSerializerField(allow_null=True, required=False) courses = serializers.SerializerMethodField() - type = serializers.SlugRelatedField(slug_field='name', queryset=ProgramType.objects.all()) - degree = DegreeSerializer() + type = serializers.SlugRelatedField(slug_field='slug', queryset=ProgramType.objects.all()) + type_attrs = ProgramTypeAttrsSerializer(source='type') + degree = DegreeSerializer(allow_null=True, required=False) + curricula = CurriculumSerializer(many=True) + card_image_url = serializers.SerializerMethodField() @classmethod def prefetch_queryset(cls, partner, queryset=None): @@ -947,16 +1501,19 @@ def prefetch_queryset(cls, partner, queryset=None): # prefetch `applicable_seat_types`, a m2m on `ProgramType`, through `type`, a foreign key to # `ProgramType` on `Program`. 'type__applicable_seat_types', + 'type__translations', 'authoring_organizations', + 'degree', + 'curricula', Prefetch('courses', queryset=MinimalProgramCourseSerializer.prefetch_queryset()), ) class Meta: model = Program fields = ( - 'uuid', 'title', 'subtitle', 'type', 'status', 'marketing_slug', 'marketing_url', 'banner_image', 'hidden', - 'courses', 'authoring_organizations', 'card_image_url', 'is_program_eligible_for_one_click_purchase', - 'degree' + 'uuid', 'title', 'subtitle', 'type', 'type_attrs', 'status', 'marketing_slug', 'marketing_url', + 'banner_image', 'hidden', 'courses', 'authoring_organizations', 'card_image_url', + 'is_program_eligible_for_one_click_purchase', 'degree', 'curricula', 'marketing_hook', ) read_only_fields = ('uuid', 'marketing_url', 'banner_image') @@ -1043,16 +1600,21 @@ def min_run_start(course): return courses + def get_card_image_url(self, obj): + if obj.card_image: + return obj.card_image.url + return obj.card_image_url + class ProgramSerializer(MinimalProgramSerializer): - authoring_organizations = OrganizationSerializer(many=True) - video = VideoSerializer() + authoring_organizations = OrganizationSerializer(many=True, read_only=True) + video = VideoSerializer(allow_null=True, required=False) expected_learning_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value') - faq = FAQSerializer(many=True) - credit_backing_organizations = OrganizationSerializer(many=True) - corporate_endorsements = CorporateEndorsementSerializer(many=True) + faq = FAQSerializer(many=True, allow_null=True, required=False) + credit_backing_organizations = OrganizationSerializer(many=True, read_only=True) + corporate_endorsements = CorporateEndorsementSerializer(many=True, allow_null=True, required=False) job_outlook_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value') - individual_endorsements = EndorsementSerializer(many=True) + individual_endorsements = EndorsementSerializer(many=True, allow_null=True, required=False) languages = serializers.SlugRelatedField( many=True, read_only=True, slug_field='code', help_text=_('Languages that course runs in this program are offered in.'), @@ -1061,11 +1623,16 @@ class ProgramSerializer(MinimalProgramSerializer): many=True, read_only=True, slug_field='code', help_text=_('Languages that course runs in this program have available transcripts in.'), ) - subjects = SubjectSerializer(many=True) - staff = MinimalPersonSerializer(many=True) - instructor_ordering = MinimalPersonSerializer(many=True) + subjects = SubjectSerializer(many=True, allow_null=True, required=False) + staff = MinimalPersonSerializer(many=True, allow_null=True, required=False) + instructor_ordering = MinimalPersonSerializer(many=True, allow_null=True, required=False) applicable_seat_types = serializers.SerializerMethodField() topics = serializers.SerializerMethodField() + min_hours_effort_per_week = IntegerField(min_value=0, default=0) + max_hours_effort_per_week = IntegerField(min_value=0, default=0) + marketing_slug = CharField() + type_attrs = ProgramTypeAttrsSerializer(source='type', required=False) + curricula = CurriculumSerializer(many=True, required=False) @classmethod def prefetch_queryset(cls, partner, queryset=None): @@ -1111,11 +1678,87 @@ class Meta(MinimalProgramSerializer.Meta): 'faq', 'credit_backing_organizations', 'corporate_endorsements', 'job_outlook_items', 'individual_endorsements', 'languages', 'transcript_languages', 'subjects', 'price_ranges', 'staff', 'credit_redemption_overview', 'applicable_seat_types', 'instructor_ordering', - 'enrollment_count', 'recent_enrollment_count', 'topics', + 'enrollment_count', 'recent_enrollment_count', 'topics', 'credit_value', ) + def create(self, validated_data): + authoring_organizations = self.context.get('authoring_organizations') + credit_backing_organizations = self.context.get('credit_backing_organizations') + courses = self.context.get('courses') + excluded_course_runs = self.context.get('excluded_course_runs') + + # No need to set these fields, these are being extracted from courses + subjects = validated_data.pop('subjects', None) + staff = validated_data.pop('staff', None) + + instructor_ordering = validated_data.pop('instructor_ordering', []) + corporate_endorsements = validated_data.pop('corporate_endorsements', []) + individual_endorsements = validated_data.pop('individual_endorsements', []) + faq = validated_data.pop('faq', []) + + program = Program(**validated_data) + program.partner = self.context.get("partner") + program.save() + + program.authoring_organizations.set(authoring_organizations) + program.credit_backing_organizations.set(credit_backing_organizations) + + program.courses.set(courses) + program.excluded_course_runs.set(excluded_course_runs) + + program.instructor_ordering.set(instructor_ordering) + program.corporate_endorsements.set(corporate_endorsements) + program.individual_endorsements.set(individual_endorsements) + program.faq.set(faq) + + return program + + def update(self, instance, validated_data): + instance.title = validated_data.get('title', instance.title) + instance.status = validated_data.get('status', instance.status) + instance.banner_image = validated_data.get('banner_image', instance.banner_image) + instance.min_hours_effort_per_week = validated_data.get('min_hours_effort_per_week', instance.min_hours_effort_per_week) + instance.max_hours_effort_per_week = validated_data.get('max_hours_effort_per_week', instance.max_hours_effort_per_week) + instance.marketing_slug = validated_data.get('marketing_slug', instance.marketing_slug) + + instance.save() + + courses = self.context.get('courses') + if courses: + instance.courses.set(courses, clear=True) + + exluded_course_runs = self.context.get('excluded_course_runs') + if exluded_course_runs: + instance.excluded_course_runs.set(exluded_course_runs, clear=True) + + authoring_organizations = self.context.get('authoring_organizations') + if authoring_organizations: + instance.authoring_organizations.set(authoring_organizations, clear=True) + + credit_backing_organizations = self.context.get('credit_backing_organizations') + if credit_backing_organizations: + instance.credit_backing_organizations.set(credit_backing_organizations, clear=True) + + instructor_ordering = validated_data.pop('instructor_ordering', []) + if instructor_ordering: + instance.instructor_ordering.set(instructor_ordering, clear=True) + + corporate_endorsements = validated_data.pop('corporate_endorsements', []) + if corporate_endorsements: + instance.corporate_endorsements.set(corporate_endorsements, clear=True) + + individual_endorsements = validated_data.pop('individual_endorsements', []) + if corporate_endorsements: + instance.individual_endorsements.set(individual_endorsements, clear=True) + + faq = validated_data.pop('faq', []) + if faq: + instance.faq.set(faq, clear=True) -class PathwaySerializer(serializers.ModelSerializer): + return instance + + +class PathwaySerializer(BaseModelSerializer): """ Serializer for Pathway. """ uuid = serializers.CharField() name = serializers.CharField() @@ -1126,6 +1769,14 @@ class PathwaySerializer(serializers.ModelSerializer): destination_url = serializers.CharField() pathway_type = serializers.CharField() + @classmethod + def prefetch_queryset(cls, partner): + queryset = Pathway.objects.filter(partner=partner) + + return queryset.prefetch_related( + Prefetch('programs', queryset=MinimalProgramSerializer.prefetch_queryset(partner=partner)), + ) + class Meta: model = Pathway fields = ( @@ -1141,21 +1792,68 @@ class Meta: ) -class ProgramTypeSerializer(serializers.ModelSerializer): - """ Serializer for the Program Types. """ - applicable_seat_types = serializers.SlugRelatedField(many=True, read_only=True, slug_field='slug') - logo_image = StdImageSerializerField() +class ProgramsAffiliateWindowSerializer(BaseModelSerializer): + """ Serializer for Affiliate Window product feeds for Program products. """ + # We use a hardcoded value since it is determined by Affiliate Window's taxonomy. + CATEGORY = 'Other Experiences' - @classmethod - def prefetch_queryset(cls, queryset): - return queryset.prefetch_related('applicable_seat_types') + name = serializers.CharField(source='title') + pid = serializers.CharField(source='uuid') + desc = serializers.CharField(source='overview') + purl = serializers.CharField(source='marketing_url') + imgurl = serializers.SerializerMethodField() + category = serializers.SerializerMethodField() + price = serializers.SerializerMethodField() + lang = serializers.SerializerMethodField() + currency = serializers.SerializerMethodField() + + # Optional Fields from https://wiki.awin.com/images/a/a0/PM-FeedColumnDescriptions.pdf + custom1 = serializers.SerializerMethodField() # Program Type class Meta: - model = ProgramType - fields = ('name', 'logo_image', 'applicable_seat_types', 'slug',) + model = Program + fields = ( + 'name', + 'pid', + 'desc', + 'purl', + 'imgurl', + 'category', + 'price', + 'lang', + 'currency', + 'custom1', + ) + def get_category(self, obj): # pylint: disable=unused-argument + return self.CATEGORY -class AffiliateWindowSerializer(serializers.ModelSerializer): + def get_imgurl(self, obj): + if obj.banner_image and obj.banner_image.url: + return obj.banner_image.url + return obj.card_image_url + + def get_price(self, obj): + price_range = obj.price_ranges + if price_range: + return str(price_range[0].get('total')) + return 'Unknown' + + def get_currency(self, obj): + price_range = obj.price_ranges + if price_range: + return str(price_range[0].get('currency')) + return 'Unknown' + + def get_lang(self, obj): + languages = obj.languages + return languages.pop().code.split('-')[0].lower() if languages else 'en' + + def get_custom1(self, obj): + return obj.type.slug + + +class AffiliateWindowSerializer(BaseModelSerializer): """ Serializer for Affiliate Window product feeds. """ # We use a hardcoded value since it is determined by Affiliate Window's taxonomy. @@ -1179,10 +1877,11 @@ class AffiliateWindowSerializer(serializers.ModelSerializer): # These field names are required by AWIN for data that doesn't fit into one # of their default fields. custom1 = serializers.CharField(source='course_run.pacing_type') - custom2 = serializers.SlugRelatedField(source='course_run.level_type', read_only=True, slug_field='name') + custom2 = serializers.SlugRelatedField(source='course_run.level_type', read_only=True, slug_field='name_t') custom3 = serializers.SerializerMethodField() custom4 = serializers.SerializerMethodField() custom5 = serializers.CharField(source='course_run.short_description') + custom6 = serializers.SerializerMethodField() class Meta: model = Seat @@ -1205,10 +1904,11 @@ class Meta: 'custom3', 'custom4', 'custom5', + 'custom6', ) def get_pid(self, obj): - return '{}-{}'.format(obj.course_run.key, obj.type) + return f'{obj.course_run.key}-{obj.type.slug}' def get_price(self, obj): return { @@ -1221,7 +1921,7 @@ def get_category(self, obj): # pylint: disable=unused-argument def get_lang(self, obj): language = obj.course_run.language - return language.code.split('-')[0].upper() if language else 'EN' + return language.code.split('-')[0].lower() if language else 'en' def get_custom3(self, obj): return ','.join(subject.name for subject in obj.course_run.subjects.all()) @@ -1229,8 +1929,14 @@ def get_custom3(self, obj): def get_custom4(self, obj): return ','.join(org.name for org in obj.course_run.authoring_organizations.all()) + def get_custom6(self, obj): + weeks = obj.course_run.weeks_to_complete + if weeks and weeks > 0: + return '{} {}'.format(weeks, 'week' if weeks == 1 else 'weeks') + return '' + def get_imgurl(self, obj): - return obj.course_run.card_image_url or obj.course_run.course.card_image_url + return obj.course_run.image_url or obj.course_run.card_image_url class FlattenedCourseRunWithCourseSerializer(CourseRunSerializer): @@ -1280,14 +1986,17 @@ def get_seats(self, obj): 'credit_provider': [], 'credit_hours': [], }, + 'masters': { + 'type': '' + } } for seat in obj.seats.all(): - for key in seats[seat.type].keys(): - if seat.type == 'credit': + for key in seats[seat.type.slug].keys(): + if seat.type.slug == 'credit': seats['credit'][key].append(SeatSerializer(seat).data[key]) else: - seats[seat.type][key] = SeatSerializer(seat).data[key] + seats[seat.type.slug][key] = SeatSerializer(seat).data[key] for credit_attr in seats['credit']: seats['credit'][credit_attr] = ','.join([str(e) for e in seats['credit'][credit_attr]]) @@ -1351,7 +2060,7 @@ def get_narrow_url(self, instance): selected_facets.add(field) query_params.setlist('selected_query_facets', sorted(selected_facets)) - path = '{path}?{query}'.format(path=request.path_info, query=query_params.urlencode()) + path = f'{request.path_info}?{query_params.urlencode()}' url = request.build_absolute_uri(path) return serializers.Hyperlink(url, 'narrow-url') @@ -1363,7 +2072,7 @@ class BaseHaystackFacetSerializer(HaystackFacetSerializer): def get_fields(self): query_facet_counts = self.instance.pop('queries', {}) - field_mapping = super(BaseHaystackFacetSerializer, self).get_fields() + field_mapping = super().get_fields() query_data = self.format_query_facet_data(query_facet_counts) @@ -1391,19 +2100,74 @@ def format_query_facet_data(self, query_facet_counts): class CourseSearchSerializer(HaystackSerializer): course_runs = serializers.SerializerMethodField() + seat_types = serializers.SerializerMethodField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context['request'] + detail_fields = request.GET.get("detail_fields") + # if detail_fields query_param not in request than do not add the following fields in serializer response. + if not detail_fields: + self.fields.pop('level_type') + self.fields.pop('modified') + self.fields.pop('outcome') + + @staticmethod + def _get_default_field_kwargs(model, field): + return get_default_field_kwargs(model, field) + + @staticmethod + def course_run_detail(request, detail_fields, course_run): + course_run_detail = { + 'key': course_run.key, + 'enrollment_start': course_run.enrollment_start, + 'enrollment_end': course_run.enrollment_end, + 'go_live_date': course_run.go_live_date, + 'start': course_run.start, + 'end': course_run.end, + 'modified': course_run.modified, + 'availability': course_run.availability, + 'status': course_run.status, + 'pacing_type': course_run.pacing_type, + 'enrollment_mode': course_run.type_legacy, + 'min_effort': course_run.min_effort, + 'max_effort': course_run.max_effort, + 'weeks_to_complete': course_run.weeks_to_complete, + 'estimated_hours': get_course_run_estimated_hours(course_run), + 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price or 0.0, + 'is_enrollable': course_run.is_enrollable, + } + if detail_fields: + course_run_detail.update( + { + 'staff': MinimalPersonSerializer(course_run.staff, many=True, + context={'request': request}).data, + 'content_language': course_run.language.code if course_run.language else None, + } + ) + return course_run_detail def get_course_runs(self, result): + request = self.context['request'] + course_runs = result.object.course_runs.all() + now = datetime.datetime.now(pytz.UTC) + exclude_expired = request.GET.get("exclude_expired_course_run") + detail_fields = request.GET.get("detail_fields") return [ - { - 'key': course_run.key, - 'enrollment_start': course_run.enrollment_start, - 'enrollment_end': course_run.enrollment_end, - 'start': course_run.start, - 'end': course_run.end, - } - for course_run in result.object.course_runs.all() + self.course_run_detail(request, detail_fields, course_run) + + for course_run in course_runs + # Check if exclude_expire_course_run is in query_params then exclude the course + # runs whose end date is passed. We do this here, rather than as an additional + # `.exclude` because the course_runs have been prefetched by the read_queryset + # of the search index. + if (not exclude_expired or course_run.end is None or course_run.end > now) ] + def get_seat_types(self, result): + seat_types = [seat.slug for course_run in result.object.course_runs.all() for seat in course_run.seat_types] + return list(set(seat_types)) + class Meta: field_aliases = COMMON_SEARCH_FIELD_ALIASES ignore_fields = COMMON_IGNORED_FIELDS @@ -1414,9 +2178,17 @@ class Meta: 'short_description', 'title', 'card_image_url', + 'image_url', 'course_runs', 'uuid', + 'seat_types', 'subjects', + 'languages', + 'organizations', + 'outcome', + 'level_type', + 'modified', + 'org', ) @@ -1435,6 +2207,8 @@ class Meta: class CourseRunSearchSerializer(HaystackSerializer): availability = serializers.SerializerMethodField() first_enrollable_paid_seat_price = serializers.SerializerMethodField() + type = serializers.SerializerMethodField() + is_enrollable = serializers.SerializerMethodField() def get_availability(self, result): return result.object.availability @@ -1442,6 +2216,12 @@ def get_availability(self, result): def get_first_enrollable_paid_seat_price(self, result): return result.object.first_enrollable_paid_seat_price + def get_type(self, result): + return result.object.type_legacy + + def get_is_enrollable(self, result): + return result.object.is_enrollable + class Meta: field_aliases = COMMON_SEARCH_FIELD_ALIASES ignore_fields = COMMON_IGNORED_FIELDS @@ -1455,8 +2235,10 @@ class Meta: 'first_enrollable_paid_seat_sku', 'first_enrollable_paid_seat_price', 'full_description', + 'go_live_date', 'has_enrollable_seats', 'image_url', + 'is_enrollable', 'key', 'language', 'level_type', @@ -1480,7 +2262,23 @@ class Meta: 'title', 'transcript_languages', 'type', - 'weeks_to_complete' + 'weeks_to_complete', + 'title_override', + 'featured', + 'is_marketing_price_set', + 'marketing_price_value', + 'is_marketing_price_hidden', + 'card_image_url', + 'average_rating', + 'total_raters', + 'yt_video_url', + 'course_duration_override', + 'course_language', + 'course_training_packages', + 'course_department', + 'course_certifications', + 'course_format', + 'course_difficulty_level', ) @@ -1500,7 +2298,6 @@ class Meta: 'seat_types': {}, 'subjects': {}, 'transcript_languages': {}, - 'type': {}, } field_queries = { 'availability_current': {'query': 'start:now'}, @@ -1516,6 +2313,10 @@ class PersonSearchSerializer(HaystackSerializer): def get_profile_image_url(self, result): return result.object.get_profile_image_url + @staticmethod + def _get_default_field_kwargs(model, field): + return get_default_field_kwargs(model, field) + class Meta: field_aliases = COMMON_SEARCH_FIELD_ALIASES ignore_fields = COMMON_IGNORED_FIELDS @@ -1528,6 +2329,10 @@ class Meta: 'bio_language', 'profile_image_url', 'position', + 'organizations', + 'marketing_id', + 'marketing_url', + 'designation', ) @@ -1541,7 +2346,10 @@ class Meta: field_aliases = COMMON_SEARCH_FIELD_ALIASES ignore_fields = COMMON_IGNORED_FIELDS index_classes = [search_indexes.PersonIndex] - fields = () + fields = ('organizations',) + field_options = { + 'organizations': {}, + } class ProgramSearchSerializer(HaystackSerializer): @@ -1589,7 +2397,8 @@ class AggregateSearchSerializer(HaystackSerializer): class Meta: field_aliases = COMMON_SEARCH_FIELD_ALIASES ignore_fields = COMMON_IGNORED_FIELDS - fields = CourseRunSearchSerializer.Meta.fields + ProgramSearchSerializer.Meta.fields + fields = CourseRunSearchSerializer.Meta.fields + ProgramSearchSerializer.Meta.fields + \ + CourseSearchSerializer.Meta.fields serializers = { search_indexes.CourseRunIndex: CourseRunSearchSerializer, search_indexes.CourseIndex: CourseSearchSerializer, @@ -1598,6 +2407,26 @@ class Meta: } +class LimitedAggregateSearchSerializer(HaystackSerializer): + class Meta: + field_aliases = COMMON_SEARCH_FIELD_ALIASES + ignore_fields = COMMON_IGNORED_FIELDS + fields = [ + 'partner', + 'authoring_organization_uuids', + 'subject_uuids', + 'uuid', + 'key', + 'aggregation_key', + 'content_type' + ] + index_classes = [ + search_indexes.CourseRunIndex, + search_indexes.CourseIndex, + search_indexes.ProgramIndex, + ] + + class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer): class Meta: field_aliases = COMMON_SEARCH_FIELD_ALIASES @@ -1663,13 +2492,83 @@ class TypeaheadSearchSerializer(serializers.Serializer): programs = TypeaheadProgramSearchSerializer(many=True) -class TopicSerializer(serializers.ModelSerializer): +class TopicSerializer(BaseModelSerializer): """Serializer for the ``Topic`` model.""" @classmethod def prefetch_queryset(cls): return Topic.objects.filter() - class Meta(object): + class Meta: model = Topic fields = ('name', 'subtitle', 'description', 'long_description', 'banner_image_url', 'slug', 'uuid') + + +class MetadataWithRelatedChoices(SimpleMetadata): + """ A version of the normal DRF metadata class that also returns choices for RelatedFields """ + + def determine_metadata(self, request, view): + self.view = view # pylint: disable=attribute-defined-outside-init + self.request = request # pylint: disable=attribute-defined-outside-init + return super().determine_metadata(request, view) + + def get_field_info(self, field): + info = super().get_field_info(field) + + in_whitelist = False + if hasattr(self.view, 'metadata_related_choices_whitelist'): + in_whitelist = field.field_name in self.view.metadata_related_choices_whitelist + + # The normal metadata class excludes RelatedFields, but we want them! So we do the same thing the normal + # class does, but without the RelatedField check. + if in_whitelist and not info.get('read_only') and hasattr(field, 'choices'): + choices = [ + { + 'value': choice_value, + 'display_name': choice_name, + } + for choice_value, choice_name in field.choices.items() + ] + if isinstance(field, ManyRelatedField): + # To mirror normal many=True serializers that have a parent ListSerializer, we want to add + # a 'child' entry with the choices below that. This is mostly here for historical + # not-breaking-the-api reasons. + info['child'] = {'choices': choices} + else: + info['choices'] = choices + + return info + + +class MetadataWithType(MetadataWithRelatedChoices): + """ A version of the MetadataWithRelatedChoices class that also includes logic for Course Types """ + + def create_type_options(self, info): + user = self.request.user + info['type_options'] = [{ + 'uuid': course_type.uuid, + 'name': course_type.name, + 'entitlement_types': [entitlement_type.slug for entitlement_type in course_type.entitlement_types.all()], + 'course_run_types': [ + CourseRunTypeSerializer(course_run_type).data for course_run_type + in course_type.course_run_types.all() + ], + 'tracks': [ + TrackSerializer(track).data for track + in TrackSerializer.prefetch_queryset().filter(courseruntype__coursetype=course_type).distinct() + ], + } for course_type in CourseType.objects.prefetch_related( + 'course_run_types__tracks__mode', 'entitlement_types', 'white_listed_orgs' + ).exclude(slug=CourseType.EMPTY) if not course_type.white_listed_orgs.exists() or user.is_staff or + user.groups.filter(organization_extension__organization__in=course_type.white_listed_orgs.all()).exists()] + return info + + def get_field_info(self, field): + info = super().get_field_info(field) + + # This line is because a child serializer of Course (the ProgramSerializer) also has + # the type field, but we only want it to match with the CourseSerializer + if field.field_name == 'type' and isinstance(field.parent, self.view.get_serializer_class()): + return self.create_type_options(info) + + return info diff --git a/course_discovery/apps/api/tests/jwt_utils.py b/course_discovery/apps/api/tests/jwt_utils.py index f70d0d5c0e..e44b1603a5 100644 --- a/course_discovery/apps/api/tests/jwt_utils.py +++ b/course_discovery/apps/api/tests/jwt_utils.py @@ -26,7 +26,7 @@ def generate_jwt_token(payload): def generate_jwt_header(token): """Generate a valid JWT header given a token.""" - return 'JWT {token}'.format(token=token) + return f'JWT {token}' def generate_jwt_header_for_user(user): diff --git a/course_discovery/apps/api/tests/mixins.py b/course_discovery/apps/api/tests/mixins.py index 9d79b0581a..7ae64cb68f 100644 --- a/course_discovery/apps/api/tests/mixins.py +++ b/course_discovery/apps/api/tests/mixins.py @@ -2,17 +2,27 @@ from django.contrib.sites.models import Site from django.test import RequestFactory +from conftest import TEST_DOMAIN from course_discovery.apps.core.tests.factories import PartnerFactory, SiteFactory -class SiteMixin(object): - def setUp(self): - super(SiteMixin, self).setUp() - domain = 'testserver.fake' - self.client = self.client_class(SERVER_NAME=domain) +class SiteMixin: + @classmethod + def setUpClass(cls): + super().setUpClass() + Site.objects.all().delete() - self.site = SiteFactory(id=settings.SITE_ID, domain=domain) - self.partner = PartnerFactory(site=self.site) + cls.site = SiteFactory(id=settings.SITE_ID, domain=TEST_DOMAIN) + cls.partner = PartnerFactory(site=cls.site) + + cls.request = RequestFactory(SERVER_NAME=cls.site.domain).get('') + cls.request.site = cls.site - self.request = RequestFactory(SERVER_NAME=self.site.domain).get('') - self.request.site = self.site + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.partner.delete() + + def setUp(self): + super().setUp() + self.client = self.client_class(SERVER_NAME=TEST_DOMAIN) diff --git a/course_discovery/apps/api/tests/test_fields.py b/course_discovery/apps/api/tests/test_fields.py index 355f546e59..22f7f95e92 100644 --- a/course_discovery/apps/api/tests/test_fields.py +++ b/course_discovery/apps/api/tests/test_fields.py @@ -1,13 +1,17 @@ -# pylint: disable=no-member import base64 import pytest from django.core.files.base import ContentFile +from django.test import TestCase -from course_discovery.apps.api.fields import ImageField, StdImageSerializerField +from course_discovery.apps.api.fields import ( + ImageField, SlugRelatedFieldWithReadSerializer, SlugRelatedTranslatableField, StdImageSerializerField +) +from course_discovery.apps.api.serializers import ProgramSerializer from course_discovery.apps.api.tests.test_serializers import make_request from course_discovery.apps.core.tests.helpers import make_image_file -from course_discovery.apps.course_metadata.tests.factories import ProgramFactory +from course_discovery.apps.course_metadata.models import Program, Subject +from course_discovery.apps.course_metadata.tests.factories import ProgramFactory, SubjectFactory @pytest.mark.django_db @@ -27,9 +31,9 @@ def test_to_representation(self): expected = { size_key: { 'url': '{}{}'.format('http://testserver', getattr(program.banner_image, size_key).url), - 'width': program.banner_image.field.variations[size_key]['width'], - 'height': program.banner_image.field.variations[size_key]['height'] - } for size_key in program.banner_image.field.variations} + 'width': program.banner_image.field.variations[size_key]['width'], # pylint: disable=no-member + 'height': program.banner_image.field.variations[size_key]['height'] # pylint: disable=no-member + } for size_key in program.banner_image.field.variations} # pylint: disable=no-member assert field.to_representation(program.banner_image) == expected def test_to_internal_value(self): @@ -68,3 +72,37 @@ def test_to_internal_value(self): @pytest.mark.parametrize('falsey_value', ("", False, None, [])) def test_to_internal_value_falsey(self, falsey_value): assert StdImageSerializerField().to_internal_value(falsey_value) is None + + +class SlugRelatedFieldWithReadSerializerTests(TestCase): + """ Tests for SlugRelatedFieldWithReadSerializer """ + def test_get_choices_no_queryset(self): + """ Make sure that we reproduce the empty-state edge case of the parent class's version """ + serializer = SlugRelatedFieldWithReadSerializer(slug_field='uuid', read_only=True, + read_serializer=ProgramSerializer()) + self.assertIsNone(serializer.get_queryset()) + self.assertEqual(serializer.get_choices(), {}) + + def test_get_choices_cutoff(self): + """ We should slice the queryset if provided a cutoff parameter """ + ProgramFactory() + ProgramFactory() + serializer = SlugRelatedFieldWithReadSerializer(slug_field='uuid', queryset=Program.objects.all(), + read_serializer=ProgramSerializer()) + self.assertEqual(len(serializer.get_choices()), 2) + self.assertEqual(len(serializer.get_choices(cutoff=1)), 1) + + def test_to_representation(self): + """ Should be using provided serializer, rather than the slug """ + program = ProgramFactory() + serializer = SlugRelatedFieldWithReadSerializer(slug_field='uuid', queryset=Program.objects.all(), + read_serializer=ProgramSerializer()) + self.assertIsInstance(serializer.to_representation(program), dict) + + +class SlugRelatedTranslatableFieldTest(TestCase): + """ Test for SlugRelatedTranslatableField """ + def test_to_internal_value(self): + subject = SubjectFactory(name='Subject') # 'name' is a translated field on Subject + serializer = SlugRelatedTranslatableField(slug_field='name', queryset=Subject.objects.all()) + self.assertEqual(serializer.to_internal_value('Subject'), subject) diff --git a/course_discovery/apps/api/tests/test_serializers.py b/course_discovery/apps/api/tests/test_serializers.py index 0a41a992e7..e72ced86d5 100644 --- a/course_discovery/apps/api/tests/test_serializers.py +++ b/course_discovery/apps/api/tests/test_serializers.py @@ -1,10 +1,11 @@ -# pylint: disable=no-member,test-inherits-tests +# pylint: disable=test-inherits-tests import datetime import itertools +import re +from unittest import mock from urllib.parse import urlencode import ddt -import mock import pytest import responses from django.test import TestCase @@ -14,23 +15,24 @@ from pytz import UTC from rest_framework.test import APIRequestFactory from taggit.models import Tag -from waffle.models import Switch from waffle.testutils import override_switch from course_discovery.apps.api.fields import ImageField, StdImageSerializerField from course_discovery.apps.api.serializers import ( - AdditionalPromoAreaSerializer, AffiliateWindowSerializer, CatalogSerializer, ContainedCourseRunsSerializer, - ContainedCoursesSerializer, ContentTypeSerializer, CorporateEndorsementSerializer, CourseEntitlementSerializer, - CourseRunSearchModelSerializer, CourseRunSearchSerializer, CourseRunSerializer, CourseRunWithProgramsSerializer, - CourseSearchModelSerializer, CourseSearchSerializer, CourseSerializer, CourseWithProgramsSerializer, - CurriculumSerializer, DegreeCostSerializer, DegreeDeadlineSerializer, EndorsementSerializer, FAQSerializer, - FlattenedCourseRunWithCourseSerializer, IconTextPairingSerializer, ImageSerializer, MinimalCourseRunSerializer, - MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalPersonSerializer, MinimalProgramCourseSerializer, - MinimalProgramSerializer, NestedProgramSerializer, OrganizationSerializer, PathwaySerializer, - PersonSearchModelSerializer, PersonSearchSerializer, PersonSerializer, PositionSerializer, PrerequisiteSerializer, - ProgramSearchModelSerializer, ProgramSearchSerializer, ProgramSerializer, ProgramTypeSerializer, RankingSerializer, - SeatSerializer, SubjectSerializer, TopicSerializer, TypeaheadCourseRunSearchSerializer, - TypeaheadProgramSearchSerializer, VideoSerializer, get_lms_course_url_for_archived, get_utm_source_for_user + AdditionalPromoAreaSerializer, AffiliateWindowSerializer, CatalogSerializer, CollaboratorSerializer, + ContainedCourseRunsSerializer, ContainedCoursesSerializer, ContentTypeSerializer, CorporateEndorsementSerializer, + CourseEditorSerializer, CourseEntitlementSerializer, CourseRunSearchModelSerializer, CourseRunSearchSerializer, + CourseRunSerializer, CourseRunWithProgramsSerializer, CourseSearchModelSerializer, CourseSearchSerializer, + CourseSerializer, CourseWithProgramsSerializer, CurriculumSerializer, DegreeCostSerializer, + DegreeDeadlineSerializer, EndorsementSerializer, FAQSerializer, FlattenedCourseRunWithCourseSerializer, + IconTextPairingSerializer, ImageSerializer, MinimalCourseRunSerializer, MinimalCourseSerializer, + MinimalOrganizationSerializer, MinimalPersonSerializer, MinimalProgramCourseSerializer, MinimalProgramSerializer, + NestedProgramSerializer, OrganizationSerializer, PathwaySerializer, PersonSearchModelSerializer, + PersonSearchSerializer, PersonSerializer, PositionSerializer, PrerequisiteSerializer, + ProgramsAffiliateWindowSerializer, ProgramSearchModelSerializer, ProgramSearchSerializer, ProgramSerializer, + ProgramTypeAttrsSerializer, ProgramTypeSerializer, RankingSerializer, SeatSerializer, SubjectSerializer, + TopicSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer, VideoSerializer, + get_lms_course_url_for_archived, get_utm_source_for_user ) from course_discovery.apps.api.tests.mixins import SiteMixin from course_discovery.apps.catalogs.tests.factories import CatalogFactory @@ -38,36 +40,36 @@ from course_discovery.apps.core.tests.factories import PartnerFactory, UserFactory from course_discovery.apps.core.tests.helpers import make_image_file from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin, LMSAPIClientMixin +from course_discovery.apps.core.utils import serialize_datetime from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.models import Course, CourseRun, Person, Program from course_discovery.apps.course_metadata.tests.factories import ( - AdditionalPromoAreaFactory, CorporateEndorsementFactory, CourseFactory, CourseRunFactory, CurriculumFactory, - DegreeCostFactory, DegreeDeadlineFactory, DegreeFactory, EndorsementFactory, ExpectedLearningItemFactory, - IconTextPairingFactory, ImageFactory, JobOutlookItemFactory, OrganizationFactory, PathwayFactory, - PersonAreaOfExpertiseFactory, PersonFactory, PersonSocialNetworkFactory, PositionFactory, PrerequisiteFactory, - ProgramFactory, ProgramTypeFactory, RankingFactory, SeatFactory, SeatTypeFactory, SubjectFactory, TopicFactory, - VideoFactory + AdditionalPromoAreaFactory, CollaboratorFactory, CorporateEndorsementFactory, CourseEditorFactory, + CourseEntitlementFactory, CourseFactory, CourseRunFactory, CurriculumCourseMembershipFactory, CurriculumFactory, + CurriculumProgramMembershipFactory, DegreeCostFactory, DegreeDeadlineFactory, DegreeFactory, EndorsementFactory, + ExpectedLearningItemFactory, IconTextPairingFactory, ImageFactory, JobOutlookItemFactory, OrganizationFactory, + PathwayFactory, PersonAreaOfExpertiseFactory, PersonFactory, PersonSocialNetworkFactory, PositionFactory, + PrerequisiteFactory, ProgramFactory, ProgramTypeFactory, RankingFactory, SeatFactory, SeatTypeFactory, + SubjectFactory, TopicFactory, VideoFactory ) +from course_discovery.apps.course_metadata.utils import get_course_run_estimated_hours from course_discovery.apps.ietf_language_tags.models import LanguageTag def json_date_format(datetime_obj): - return datetime.datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ") + return datetime_obj and datetime.datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ") -def make_request(): +def make_request(query_param=None): user = UserFactory() - request = APIRequestFactory().get('/') + if query_param: + request = APIRequestFactory().get('/', query_param) + else: + request = APIRequestFactory().get('/') request.user = user return request -def serialize_datetime_without_timezone(d): - # TODO: Remove this function, and replace usage of it with serialize_datetime, after - # https://github.com/encode/django-rest-framework/issues/3732 is released. - return d.strftime('%Y-%m-%dT%H:%M:%S') if d else None - - def serialize_language(language): if language.code.startswith('zh'): return language.name @@ -110,7 +112,7 @@ def test_invalid_data_user_create(self): } serializer = CatalogSerializer(data=data) self.assertFalse(serializer.is_valid()) - self.assertEqual(User.objects.filter(username=username).count(), 0) # pylint: disable=no-member + self.assertEqual(User.objects.filter(username=username).count(), 0) class MinimalCourseSerializerTests(SiteMixin, TestCase): @@ -128,7 +130,9 @@ def get_expected_data(cls, course, request): 'entitlements': [], 'owners': MinimalOrganizationSerializer(course.authoring_organizations, many=True, context=context).data, 'image': ImageField().to_representation(course.image_url), - 'short_description': course.short_description + 'short_description': course.short_description, + 'type': course.type.uuid, + 'url_slug': None, } def test_data(self): @@ -147,17 +151,18 @@ class CourseSerializerTests(MinimalCourseSerializerTests): @classmethod def get_expected_data(cls, course, request): expected = super().get_expected_data(course, request) + expected.update({ 'short_description': course.short_description, 'full_description': course.full_description, - 'level_type': course.level_type.name, + 'level_type': course.level_type.name_t, 'extra_description': AdditionalPromoAreaSerializer(course.extra_description).data, 'subjects': [], 'prerequisites': [], 'expected_learning_items': [], 'video': VideoSerializer(course.video).data, 'sponsors': OrganizationSerializer(course.sponsoring_organizations, many=True).data, - 'modified': json_date_format(course.modified), # pylint: disable=no-member + 'modified': json_date_format(course.modified), 'marketing_url': '{url}?{params}'.format( url=course.marketing_url, params=urlencode({ @@ -180,6 +185,13 @@ def get_expected_data(cls, course, request): 'enrollment_count': 0, 'recent_enrollment_count': 0, 'topics': list(course.topics.names()), + 'key_for_reruns': course.key_for_reruns, + 'url_slug': course.active_url_slug, + 'url_slug_history': [course.active_url_slug], + 'url_redirects': [], + 'course_run_statuses': course.course_run_statuses, + 'editors': CourseEditorSerializer(course.editors, many=True, read_only=True).data, + 'collaborators': [], }) return expected @@ -197,16 +209,64 @@ def test_canonical_course_run_key(self): request = make_request() course = CourseFactory() course_runs = CourseRunFactory.create_batch(3, course=course) - course.course_runs = course_runs + course.course_runs.set(course_runs) course.canonical_course_run = course_runs[0] serializer = self.serializer_class(course, context={'request': request, 'exclude_utm': 1}) self.assertEqual(serializer.data['canonical_course_run_key'], course_runs[0].key) + def test_draft_no_marketing_url(self): + request = make_request() + course_draft = CourseFactory(draft=True) + draft_course_run = CourseRunFactory(draft=True, course=course_draft) + course_draft.canonical_course_run = draft_course_run + course_draft.save() + serializer = self.serializer_class(course_draft, context={'request': request, 'exclude_utm': 1, 'editable': 1}) + + self.assertIsNone(serializer.data['marketing_url']) + + def test_draft_and_official(self): + request = make_request() + course_draft = CourseFactory(draft=True) + draft_course_run = CourseRunFactory(draft=True, course=course_draft) + course_draft.canonical_course_run = draft_course_run + course_draft.save() + + course = CourseFactory(draft=False, draft_version_id=course_draft.id) + course_run = CourseRunFactory(draft=False, course=course, draft_version_id=draft_course_run.id) + course.canonical_course_run = course_run + course.save() + + serializer = self.serializer_class(course, context={'request': request, 'exclude_utm': 1, 'editable': 1}) + self.assertIsNotNone(serializer.data['marketing_url']) + self.assertEqual(serializer.data['marketing_url'], course.marketing_url) + + +class CourseEditorSerializerTests(TestCase): + + def test_data(self): + course_editor = CourseEditorFactory() + serializer = CourseEditorSerializer(course_editor) + + expected = { + 'id': course_editor.id, + 'course': course_editor.course.uuid, + 'user': { + 'id': course_editor.user.id, + 'full_name': course_editor.user.full_name, + 'email': course_editor.user.email + } + } + + self.assertEqual(expected, serializer.data) + @ddt.ddt class CourseWithProgramsSerializerTests(CourseSerializerTests): serializer_class = CourseWithProgramsSerializer + YESTERDAY = datetime.datetime.now(UTC) - datetime.timedelta(days=1) + TOMORROW = datetime.datetime.now(UTC) + datetime.timedelta(days=1) + TWO_WEEKS_FROM_TODAY = datetime.datetime.now(UTC) + datetime.timedelta(days=14) @classmethod def get_expected_data(cls, course, request): @@ -217,10 +277,27 @@ def get_expected_data(cls, course, request): many=True, context={'request': request} ).data, + 'course_run_keys': [course_run.key for course_run in course.course_runs.all()], + 'editable': False, + 'advertised_course_run_uuid': None, }) return expected + def create_upgradeable_seat_for_course_run(self, course_run): + return SeatFactory( + course_run=course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.TOMORROW + ) + + def create_not_upgradeable_seat_for_course_run(self, course_run): + return SeatFactory( + course_run=course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=datetime.datetime(2014, 1, 1, tzinfo=UTC) + ) + def setUp(self): super().setUp() self.request = make_request() @@ -231,31 +308,254 @@ def setUp(self): status=ProgramStatus.Deleted ) - def test_exclude_deleted_programs(self): - """ - If the associated program is deleted, - CourseWithProgramsSerializer should not return any serialized programs - """ + def test_data(self): + expected = self.get_expected_data(self.course, self.request) serializer = self.serializer_class(self.course, context={'request': self.request}) - self.assertEqual(serializer.data['programs'], []) + self.assertDictEqual(serializer.data, expected) - def test_include_deleted_programs(self): - """ - If the associated program is deleted, but we are sending in the 'include_deleted_programs' flag - CourseWithProgramsSerializer should return deleted programs - """ - serializer = self.serializer_class( - self.course, - context={'request': self.request, 'include_deleted_programs': 1} + def test_advertised_course_run_is_upgradeable_and_end_not_within_two_weeks(self): + start_days = [ + self.YESTERDAY, + datetime.datetime.now(UTC) - datetime.timedelta(days=2), + self.TOMORROW, + datetime.datetime.now(UTC) - datetime.timedelta(days=30), + ] + + end_days = [ + datetime.datetime.now(UTC) + datetime.timedelta(days=13), + self.TWO_WEEKS_FROM_TODAY, + datetime.datetime.now(UTC) - datetime.timedelta(days=30), + self.YESTERDAY, + ] + + expected_advertised_course_run = CourseRunFactory( + course=self.course, + start=self.YESTERDAY, + end=self.TWO_WEEKS_FROM_TODAY, + status=CourseRunStatus.Published, + enrollment_end=self.TWO_WEEKS_FROM_TODAY, + ) + + self.create_upgradeable_seat_for_course_run(expected_advertised_course_run) + + not_upgradeable_course_run = CourseRunFactory( + course=self.course, + start=self.YESTERDAY, + end=self.TWO_WEEKS_FROM_TODAY, + status=CourseRunStatus.Published, + enrollment_end=self.TWO_WEEKS_FROM_TODAY, + ) + + self.create_not_upgradeable_seat_for_course_run(not_upgradeable_course_run) + + not_marketable_course_run = CourseRunFactory( + course=self.course, + start=self.YESTERDAY, + end=self.TWO_WEEKS_FROM_TODAY, + status=CourseRunStatus.Unpublished, + enrollment_end=self.TWO_WEEKS_FROM_TODAY, + ) + + self.create_upgradeable_seat_for_course_run(not_marketable_course_run) + + for i in range(4): + cr = CourseRunFactory( + course=self.course, + start=start_days[i], + end=end_days[i], + status=CourseRunStatus.Published, + enrollment_end=end_days[i], + ) + self.create_upgradeable_seat_for_course_run(cr) + + serializer = self.serializer_class(self.course, context={'request': self.request}) + self.assertEqual(serializer.data['advertised_course_run_uuid'], expected_advertised_course_run.uuid) + + def test_advertised_course_run_is_upgradeable_and_starts_in_the_future(self): + start_days = [ + self.YESTERDAY, + datetime.datetime.now(UTC) + datetime.timedelta(days=2), + datetime.datetime.now(UTC) - datetime.timedelta(days=30), + ] + + end_days = [ + datetime.datetime.now(UTC) + datetime.timedelta(days=13), + self.TWO_WEEKS_FROM_TODAY, + datetime.datetime.now(UTC) - datetime.timedelta(days=1), + ] + + expected_advertised_course_run = CourseRunFactory( + course=self.course, + start=self.TOMORROW, + status=CourseRunStatus.Published, + enrollment_end=None, + end=None + ) + + self.create_upgradeable_seat_for_course_run(expected_advertised_course_run) + + not_upgradeable_course_run = CourseRunFactory( + course=self.course, + start=self.TOMORROW, + end=datetime.datetime.now(UTC) + datetime.timedelta(days=30), + status=CourseRunStatus.Published, + enrollment_end=datetime.datetime.now(UTC) + datetime.timedelta(days=30), + ) + + self.create_not_upgradeable_seat_for_course_run(not_upgradeable_course_run) + + not_marketable_course_run = CourseRunFactory( + course=self.course, + start=self.YESTERDAY, + end=self.TWO_WEEKS_FROM_TODAY, + status=CourseRunStatus.Unpublished, + enrollment_end=self.TWO_WEEKS_FROM_TODAY, + ) + + self.create_upgradeable_seat_for_course_run(not_marketable_course_run) + + for i in range(3): + cr = CourseRunFactory( + course=self.course, + start=start_days[i], + end=end_days[i], + status=CourseRunStatus.Published, + enrollment_end=end_days[i] + ) + self.create_upgradeable_seat_for_course_run(cr) + + serializer = self.serializer_class(self.course, context={'request': self.request}) + self.assertEqual(serializer.data['advertised_course_run_uuid'], expected_advertised_course_run.uuid) + + def test_advertise_course_run_else_condition(self): + start_days = [ + self.YESTERDAY, + self.TOMORROW, + datetime.datetime.now(UTC) - datetime.timedelta(days=30), + ] + + end_days = [ + datetime.datetime.now(UTC) + datetime.timedelta(days=13), + self.TWO_WEEKS_FROM_TODAY, + datetime.datetime.now(UTC) - datetime.timedelta(days=1), + ] + + expected_advertised_course_run = CourseRunFactory( + course=self.course, + start=datetime.datetime.now(UTC) + datetime.timedelta(days=2), + end=datetime.datetime.now(UTC) + datetime.timedelta(days=30), + status=CourseRunStatus.Published ) - self.assertEqual(serializer.data, self.get_expected_data(self.course, self.request)) + + self.create_not_upgradeable_seat_for_course_run(expected_advertised_course_run) + + not_marketable_course_run = CourseRunFactory( + course=self.course, + start=self.YESTERDAY, + end=self.TWO_WEEKS_FROM_TODAY, + status=CourseRunStatus.Unpublished + ) + + self.create_upgradeable_seat_for_course_run(not_marketable_course_run) + + for i in range(3): + cr = CourseRunFactory( + course=self.course, + start=start_days[i], + end=end_days[i], + status=CourseRunStatus.Published + ) + if not i == 0: + self.create_not_upgradeable_seat_for_course_run(cr) + + serializer = self.serializer_class(self.course, context={'request': self.request}) + self.assertEqual(serializer.data['advertised_course_run_uuid'], expected_advertised_course_run.uuid) + + def test_advertised_course_run_no_start_date(self): + expected_advertised_course_run = CourseRunFactory( + course=self.course, + start=datetime.datetime.now(UTC) + datetime.timedelta(days=2), + end=datetime.datetime.now(UTC) + datetime.timedelta(days=30), + status=CourseRunStatus.Published + ) + self.create_not_upgradeable_seat_for_course_run(expected_advertised_course_run) + + other_run_no_start = CourseRunFactory( + course=self.course, + start=None, + end=datetime.datetime.now(UTC) + datetime.timedelta(days=30), + status=CourseRunStatus.Published + ) + self.create_not_upgradeable_seat_for_course_run(other_run_no_start) + serializer = self.serializer_class(self.course, context={'request': self.request}) + self.assertEqual(serializer.data['advertised_course_run_uuid'], expected_advertised_course_run.uuid) + + +class CurriculumSerializerTests(TestCase): + serializer_class = CurriculumSerializer + + @classmethod + def get_expected_data(cls, curriculum, request): + + curriculum_programs = [m.program for m in list(curriculum.curriculumprogrammembership_set.all())] + curriculum_courses = [m.course for m in list(curriculum.curriculumcoursemembership_set.all())] + curriculum_course_runs = [ + course_run for course in curriculum_courses + for course_run in list(course.course_runs.all()) + ] + + return { + 'uuid': str(curriculum.uuid), + 'name': curriculum.name, + 'marketing_text': curriculum.marketing_text, + 'marketing_text_brief': curriculum.marketing_text_brief, + 'is_active': curriculum.is_active, + 'courses': MinimalProgramCourseSerializer( + curriculum_courses, + many=True, + context={ + 'request': request, + 'course_runs': curriculum_course_runs + } + ).data, + 'programs': MinimalProgramSerializer( + curriculum_programs, + many=True, + context={ + 'request': request + } + ).data + } + + def test_data(self): + request = make_request() + + person = PersonFactory() + parent_program = ProgramFactory() + child_program = ProgramFactory() + + curriculum = CurriculumFactory(program=parent_program) + course = CourseFactory() + CourseRunFactory(course=course, staff=[person]) + CurriculumCourseMembershipFactory( + course=course, + curriculum=curriculum + ) + CurriculumProgramMembershipFactory( + program=child_program, + curriculum=curriculum + ) + expected = self.get_expected_data(curriculum, request) + + serializer = CurriculumSerializer(curriculum, context={'request': request}) + self.assertDictEqual(serializer.data, expected) class MinimalCourseRunBaseTestSerializer(TestCase): serializer_class = MinimalCourseRunSerializer @classmethod - def get_expected_data(cls, course_run, request): # pylint: disable=unused-argument + def get_expected_data(cls, course_run, request): return { 'key': course_run.key, 'uuid': str(course_run.uuid), @@ -271,12 +571,17 @@ def get_expected_data(cls, course_run, request): # pylint: disable=unused-argum ), 'start': json_date_format(course_run.start), 'end': json_date_format(course_run.end), + 'go_live_date': json_date_format(course_run.go_live_date), 'enrollment_start': json_date_format(course_run.enrollment_start), 'enrollment_end': json_date_format(course_run.enrollment_end), 'pacing_type': course_run.pacing_type, - 'type': course_run.type, + 'type': course_run.type_legacy, + 'run_type': course_run.type.uuid, 'seats': SeatSerializer(course_run.seats, many=True).data, 'status': course_run.status, + 'external_key': course_run.external_key, + 'is_enrollable': course_run.is_enrollable, + 'is_marketable': course_run.is_marketable, } @@ -295,8 +600,9 @@ def test_get_lms_course_url(self): lms_course_url = get_lms_course_url_for_archived(partner, '') self.assertIsNone(lms_course_url) + partner.lms_url = 'http://127.0.0.1:8000' lms_course_url = get_lms_course_url_for_archived(partner, course_key) - expected_url = '{lms_url}/courses/{course_key}/course/'.format(lms_url=partner.lms_url, course_key=course_key) + expected_url = f'{partner.lms_url}/courses/{course_key}/course/' self.assertEqual(lms_course_url, expected_url) @@ -309,8 +615,8 @@ def get_expected_data(cls, course_run, request): expected.update({ 'course': course_run.course.key, 'key': course_run.key, - 'title': course_run.title, # pylint: disable=no-member - 'full_description': course_run.full_description, # pylint: disable=no-member + 'title': course_run.title, + 'full_description': course_run.full_description, 'announcement': json_date_format(course_run.announcement), 'video': VideoSerializer(course_run.video).data, 'mobile_available': course_run.mobile_available, @@ -324,8 +630,8 @@ def get_expected_data(cls, course_run, request): 'instructors': [], 'staff': [], 'seats': [], - 'modified': json_date_format(course_run.modified), # pylint: disable=no-member - 'level_type': course_run.level_type.name, + 'modified': json_date_format(course_run.modified), + 'level_type': course_run.level_type.name_t, 'availability': course_run.availability, 'reporting_type': course_run.reporting_type, 'status': course_run.status, @@ -334,10 +640,38 @@ def get_expected_data(cls, course_run, request): 'has_ofac_restrictions': course_run.has_ofac_restrictions, 'enrollment_count': 0, 'recent_enrollment_count': 0, + 'course_uuid': course_run.course.uuid, + 'expected_program_name': course_run.expected_program_name, + 'expected_program_type': course_run.expected_program_type, + 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price, + 'ofac_comment': course_run.ofac_comment, + 'estimated_hours': get_course_run_estimated_hours(course_run), + 'invite_only': course_run.invite_only, + 'featured': course_run.featured, + 'is_marketing_price_set': course_run.is_marketing_price_set, + 'marketing_price_value': course_run.marketing_price_value, + 'yt_video_url': course_run.yt_video_url, + 'course_duration_override': course_run.course_duration_override, + 'course_training_packages': course_run.course_training_packages, + 'course_department': course_run.course_department, + 'course_certifications': course_run.course_certifications, + 'course_format': course_run.course_format, + 'course_difficulty_level': course_run.course_difficulty_level, + 'course_language' : course_run.course_language, + 'is_marketing_price_hidden': course_run.is_marketing_price_hidden, + 'card_image_url': course_run.card_image_url, + 'subjects': [], }) - return expected + def test_data(self): + request = make_request() + course_run = CourseRunFactory() + serializer = self.serializer_class(course_run, context={'request': request}) + expected = self.get_expected_data(course_run, request) + + assert serializer.data == expected + def test_exclude_utm(self): request = make_request() course_run = CourseRunFactory() @@ -345,6 +679,22 @@ def test_exclude_utm(self): self.assertEqual(serializer.data['marketing_url'], course_run.marketing_url) + def test_draft_no_marketing_url(self): + request = make_request() + draft_course_run = CourseRunFactory(draft=True) + serializer = self.serializer_class(draft_course_run, context={'request': request, 'editable': 1}) + + self.assertIsNone(serializer.data['marketing_url']) + + def test_draft_and_official(self): + request = make_request() + draft_course_run = CourseRunFactory(draft=True) + course_run = CourseRunFactory(draft=False, draft_version_id=draft_course_run.id) + + serializer = self.serializer_class(course_run, context={'request': request, 'exclude_utm': 1, 'editable': 1}) + self.assertIsNotNone(serializer.data['marketing_url']) + self.assertEqual(serializer.data['marketing_url'], course_run.marketing_url) + class CourseRunWithProgramsSerializerTests(TestCase): def setUp(self): @@ -369,28 +719,6 @@ def test_data_excluded_course_run(self): expected.update({'programs': []}) assert serializer.data == expected - def test_exclude_deleted_programs(self): - """ - If the associated program is deleted, - CourseRunWithProgramsSerializer should not return any serialized programs - """ - ProgramFactory(courses=[self.course_run.course], status=ProgramStatus.Deleted) - serializer = CourseRunWithProgramsSerializer(self.course_run, context=self.serializer_context) - self.assertEqual(serializer.data['programs'], []) - - def test_include_deleted_programs(self): - """ - If the associated program is deleted, but we are sending in the 'include_deleted_programs' flag - CourseRunWithProgramsSerializer should return deleted programs - """ - deleted_program = ProgramFactory(courses=[self.course_run.course], status=ProgramStatus.Deleted) - self.serializer_context['include_deleted_programs'] = 1 - serializer = CourseRunWithProgramsSerializer(self.course_run, context=self.serializer_context) - self.assertEqual( - serializer.data['programs'], - NestedProgramSerializer([deleted_program], many=True, context=self.serializer_context).data - ) - def test_exclude_unpublished_program(self): """ If a program is unpublished, that program should not be returned on the course run endpoint by default. @@ -476,14 +804,17 @@ def serialize_seats(cls, course_run): 'credit_provider': [], 'credit_hours': [], }, + 'masters': { + 'type': '' + }, } for seat in course_run.seats.all(): - for key in seats[seat.type].keys(): - if seat.type == 'credit': + for key in seats[seat.type.slug].keys(): + if seat.type.slug == 'credit': seats['credit'][key].append(SeatSerializer(seat).data[key]) else: - seats[seat.type][key] = SeatSerializer(seat).data[key] + seats[seat.type.slug][key] = SeatSerializer(seat).data[key] for credit_attr in seats['credit']: seats['credit'][credit_attr] = ','.join([str(e) for e in seats['credit'][credit_attr]]) @@ -505,10 +836,11 @@ def get_expected_data(cls, request, course_run): 'owners': cls.serialize_items(course.authoring_organizations.all(), 'key'), 'sponsors': cls.serialize_items(course.sponsoring_organizations.all(), 'key'), 'prerequisites': cls.serialize_items(course.prerequisites.all(), 'name'), - 'level_type': course_run.level_type.name if course_run.level_type else None, + 'level_type': course_run.level_type.name_t if course_run.level_type else None, 'expected_learning_items': cls.serialize_items(course.expected_learning_items.all(), 'value'), 'course_key': course.key, 'image': ImageField().to_representation(course_run.card_image_url), + 'term': 'example-term', }) # Remove fields found in CourseRunSerializer, but not in FlattenedCourseRunWithCourseSerializer. @@ -522,7 +854,7 @@ def get_expected_data(cls, request, course_run): def test_data(self): request = make_request() course_run = CourseRunFactory() - SeatFactory(course_run=course_run) + SeatFactory(course_run=course_run, type=SeatTypeFactory.audit()) serializer_context = {'request': request} serializer = FlattenedCourseRunWithCourseSerializer(course_run, context=serializer_context) expected = self.get_expected_data(request, course_run) @@ -532,7 +864,7 @@ def test_data_without_level_type(self): """ Verify the serializer handles courses with no level type set. """ request = make_request() course_run = CourseRunFactory(course__level_type=None) - SeatFactory(course_run=course_run) + SeatFactory(course_run=course_run, type=SeatTypeFactory.audit()) serializer_context = {'request': request} serializer = FlattenedCourseRunWithCourseSerializer(course_run, context=serializer_context) expected = self.get_expected_data(request, course_run) @@ -541,7 +873,7 @@ def test_data_without_level_type(self): class MinimalProgramCourseSerializerTests(TestCase): def setUp(self): - super(MinimalProgramCourseSerializerTests, self).setUp() + super().setUp() self.program = ProgramFactory(courses=[CourseFactory()]) def assert_program_courses_serialized(self, program): @@ -684,7 +1016,8 @@ def get_expected_data(cls, program, request): 'uuid': str(program.uuid), 'title': program.title, 'subtitle': program.subtitle, - 'type': program.type.name, + 'type': program.type.name_t, + 'type_attrs': ProgramTypeAttrsSerializer(program.type).data, 'status': program.status, 'marketing_slug': program.marketing_slug, 'marketing_url': program.marketing_url, @@ -701,7 +1034,9 @@ def get_expected_data(cls, program, request): 'authoring_organizations': MinimalOrganizationSerializer(program.authoring_organizations, many=True).data, 'card_image_url': program.card_image_url, 'is_program_eligible_for_one_click_purchase': program.is_program_eligible_for_one_click_purchase, - 'degree': None + 'degree': None, + 'curricula': [], + 'marketing_hook': program.marketing_hook, } def test_data(self): @@ -740,7 +1075,7 @@ def get_expected_data(cls, program, request): context={'request': request} ).data, 'job_outlook_items': [item.value for item in program.job_outlook_items.all()], - 'languages': [serialize_language_to_code(l) for l in program.languages], + 'languages': [serialize_language_to_code(p_lang) for p_lang in program.languages], 'weeks_to_complete': program.weeks_to_complete, 'total_hours_of_effort': program.total_hours_of_effort, 'weeks_to_complete_min': program.weeks_to_complete_min, @@ -750,10 +1085,11 @@ def get_expected_data(cls, program, request): 'overview': program.overview, 'price_ranges': program.price_ranges, 'subjects': SubjectSerializer(program.subjects, many=True).data, - 'transcript_languages': [serialize_language_to_code(l) for l in program.transcript_languages], + 'transcript_languages': [serialize_language_to_code(p_t_l) for p_t_l in program.transcript_languages], 'enrollment_count': 0, 'recent_enrollment_count': 0, 'topics': [topic.name for topic in program.topics], + 'credit_value': program.credit_value, }) return expected @@ -790,7 +1126,7 @@ def test_course_ordering(self): # Create a second run with matching start, but later enrollment_start. CourseRunFactory( course=course_list[1], - enrollment_start=datetime.datetime(2014, 1, 2), + enrollment_start=datetime.datetime(2014, 1, 2, tzinfo=UTC), start=datetime.datetime(2014, 2, 1, tzinfo=UTC), ) @@ -839,7 +1175,7 @@ def test_course_ordering_with_exclusions(self): # Create a run with matching start, but later enrollment_start. CourseRunFactory( course=course_list[1], - enrollment_start=datetime.datetime(2014, 1, 2), + enrollment_start=datetime.datetime(2014, 1, 2, tzinfo=UTC), start=datetime.datetime(2014, 2, 1, tzinfo=UTC), ) @@ -879,7 +1215,7 @@ def test_course_ordering_with_no_start(self): # Create a second run with matching start, but later enrollment_start. CourseRunFactory( course=course_list[1], - enrollment_start=datetime.datetime(2014, 1, 2), + enrollment_start=datetime.datetime(2014, 1, 2, tzinfo=UTC), start=datetime.datetime(2014, 2, 1, tzinfo=UTC), ) @@ -963,8 +1299,8 @@ def test_degree_marketing_data(self): rankings = RankingFactory.create_batch(3) degree = DegreeFactory.create(rankings=rankings) - curriculum = CurriculumFactory.create(degree=degree) - degree.curriculum = curriculum + curriculum = CurriculumFactory.create(program=degree) + degree.curricula.set([curriculum]) quick_facts = IconTextPairingFactory.create_batch(3, degree=degree) degree.deadline = DegreeDeadlineFactory.create_batch(size=3, degree=degree) degree.cost = DegreeCostFactory.create_batch(size=3, degree=degree) @@ -977,7 +1313,11 @@ def test_degree_marketing_data(self): expected_degree_deadlines = DegreeDeadlineSerializer(degree.deadline, many=True).data expected_degree_costs = DegreeCostSerializer(degree.cost, many=True).data + url = re.compile(r"https?:\/\/[^\/]*") + expected_micromasters_path = url.sub('', degree.micromasters_url) + # Tack in degree data + expected['curricula'] = [expected_curriculum] expected['degree'] = { 'application_requirements': degree.application_requirements, 'apply_url': degree.apply_url, @@ -985,25 +1325,39 @@ def test_degree_marketing_data(self): 'banner_border_color': degree.banner_border_color, 'campus_image': degree.campus_image, 'costs': expected_degree_costs, - 'curriculum': expected_curriculum, 'deadlines': expected_degree_deadlines, 'quick_facts': expected_quick_facts, 'prerequisite_coursework': degree.prerequisite_coursework, 'rankings': expected_rankings, 'lead_capture_list_name': degree.lead_capture_list_name, 'lead_capture_image': lead_capture_image_field.to_representation(degree.lead_capture_image), + 'hubspot_lead_capture_form_id': degree.hubspot_lead_capture_form_id, + 'micromasters_path': expected_micromasters_path, 'micromasters_url': degree.micromasters_url, 'micromasters_long_title': degree.micromasters_long_title, 'micromasters_long_description': degree.micromasters_long_description, 'micromasters_background_image': mm_background_image_field.to_representation( degree.micromasters_background_image ), + 'micromasters_org_name_override': degree.micromasters_org_name_override, 'costs_fine_print': degree.costs_fine_print, 'deadlines_fine_print': degree.deadlines_fine_print, 'title_background_image': degree.title_background_image, } self.assertDictEqual(serializer.data, expected) + def test_data_with_card_image(self): + program = self.create_program() + request = make_request() + card_image_file = make_image_file('test_card.jpg') + program.card_image = card_image_file + serializer = self.serializer_class(program, context={'request': request}) + expected = self.get_expected_data(program, request) + expected.update({ + 'card_image_url': '/media/test_card.jpg' + }) + self.assertDictEqual(serializer.data, expected) + class PathwaySerialzerTests(TestCase): def test_data(self): @@ -1033,10 +1387,12 @@ def get_expected_data(cls, program_type, request): image_field._context = {'request': request} # pylint: disable=protected-access return { - 'name': program_type.name, + 'uuid': str(program_type.uuid), + 'name': program_type.name_t, 'logo_image': image_field.to_representation(program_type.logo_image), 'applicable_seat_types': [seat_type.slug for seat_type in program_type.applicable_seat_types.all()], 'slug': program_type.slug, + 'coaching_supported': program_type.coaching_supported } def test_data(self): @@ -1119,6 +1475,8 @@ def test_data(self): 'subtitle': subject.subtitle, 'slug': subject.slug, 'uuid': str(subject.uuid), + 'marketing_url': None, + 'number_of_courses': 0 } self.assertDictEqual(serializer.data, expected) @@ -1184,8 +1542,9 @@ def test_data(self): expected = { 'uuid': str(program.uuid), 'marketing_slug': program.marketing_slug, - 'marketing_url': program.marketing_url, # pylint: disable=no-member + 'marketing_url': program.marketing_url, 'type': program.type.name, + 'type_attrs': ProgramTypeAttrsSerializer(program.type).data, 'title': program.title, 'number_of_courses': program.courses.count(), } @@ -1220,6 +1579,7 @@ def get_expected_data(cls, organization): 'uuid': str(organization.uuid), 'key': organization.key, 'name': organization.name, + 'auto_generate_course_run_keys': organization.auto_generate_course_run_keys, } def test_data(self): @@ -1242,39 +1602,84 @@ def create_organization(self): def get_expected_data(cls, organization): expected = super().get_expected_data(organization) expected.update({ - 'certificate_logo_image_url': organization.certificate_logo_image_url, + 'certificate_logo_image_url': organization.certificate_logo_image.url, 'description': organization.description, 'homepage_url': organization.homepage_url, - 'logo_image_url': organization.logo_image_url, + 'logo_image_url': organization.logo_image.url, 'tags': [cls.TAG], 'marketing_url': organization.marketing_url, + 'slug': organization.slug, + 'banner_image_url': organization.banner_image.url, }) return expected -class SeatSerializerTests(TestCase): +@ddt.ddt +class CourseEntitlementSerializerTests(TestCase): + def setUp(self): + super().setUp() + self.entitlement = CourseEntitlementFactory() + def test_data(self): + serializer = CourseEntitlementSerializer(self.entitlement) + + expected = { + 'price': str(self.entitlement.price), + 'currency': self.entitlement.currency.code, + 'sku': self.entitlement.sku, + 'mode': str(self.entitlement.mode).lower(), + 'expires': None, + } + + self.assertDictEqual(serializer.data, expected) + + @ddt.data('0.000', '100000000.00', '-1') + def test_price_validation_errors(self, price): + """Test Cases: More than two decimals, More than 8 digits, Less than 0""" + self.entitlement.price = price + serializer = CourseEntitlementSerializer(data=self.entitlement.__dict__) + serializer.is_valid() + + self.assertTrue(serializer.errors['price']) + + +@ddt.ddt +class SeatSerializerTests(TestCase): + def setUp(self): + super().setUp() course_run = CourseRunFactory() - seat = SeatFactory(course_run=course_run) - serializer = SeatSerializer(seat) + self.seat = SeatFactory(course_run=course_run) + + def test_data(self): + serializer = SeatSerializer(self.seat) expected = { - 'type': seat.type, - 'price': str(seat.price), - 'currency': seat.currency.code, - 'upgrade_deadline': json_date_format(seat.upgrade_deadline), - 'credit_provider': seat.credit_provider, # pylint: disable=no-member - 'credit_hours': seat.credit_hours, # pylint: disable=no-member - 'sku': seat.sku, - 'bulk_sku': seat.bulk_sku + 'type': self.seat.type.slug, + 'price': str(self.seat.price), + 'currency': self.seat.currency.code, + 'upgrade_deadline': json_date_format(self.seat.upgrade_deadline), + 'credit_provider': self.seat.credit_provider, + 'credit_hours': self.seat.credit_hours, + 'sku': self.seat.sku, + 'bulk_sku': self.seat.bulk_sku } self.assertDictEqual(serializer.data, expected) + @ddt.data('0.000', '100000000.00', '-1') + def test_price_validation_errors(self, price): + """Test Cases: More than two decimals, More than 8 digits, Less than 0""" + self.seat.price = price + serializer = SeatSerializer(data=self.seat.__dict__) + serializer.is_valid() + + self.assertTrue(serializer.errors['price']) + class MinimalPersonSerializerTests(TestCase): def setUp(self): + super().setUp() request = make_request() self.context = {'request': request} image_field = StdImageSerializerField() @@ -1387,6 +1792,8 @@ def test_data(self): 'organization_id': position.organization_id, 'organization_override': position.organization_override, 'organization_marketing_url': position.organization.marketing_url, + 'organization_uuid': position.organization.uuid, + 'organization_logo_image_url': position.organization.logo_image.url } self.assertDictEqual(serializer.data, expected) @@ -1400,6 +1807,24 @@ def test_position_with_no_org(self): 'organization_id': None, 'organization_override': None, 'organization_marketing_url': None, + 'organization_logo_image_url': None, + 'organization_uuid': None, + } + + self.assertDictEqual(serializer.data, expected) + + def test_position_with_org_no_image(self): + organization = OrganizationFactory(logo_image=None) + position = PositionFactory(organization=organization) + serializer = PositionSerializer(position) + expected = { + 'title': str(position.title), + 'organization_name': position.organization_name, + 'organization_id': position.organization_id, + 'organization_override': position.organization_override, + 'organization_marketing_url': position.organization.marketing_url, + 'organization_uuid': position.organization.uuid, + 'organization_logo_image_url': None } self.assertDictEqual(serializer.data, expected) @@ -1423,15 +1848,16 @@ def test_data(self): user = UserFactory() CatalogFactory(query='*:*', viewers=[user]) course_run = CourseRunFactory(card_image_url='') + course_run.weeks_to_complete = 1 + course_run.save() seat = SeatFactory(course_run=course_run) serializer = AffiliateWindowSerializer(seat) # Verify none of the course run attributes are empty; otherwise, Affiliate Window will report errors. - # pylint: disable=no-member assert all((course_run.title, course_run.short_description, course_run.marketing_url)) expected = { - 'pid': '{}-{}'.format(course_run.key, seat.type), + 'pid': f'{course_run.key}-{seat.type.slug}', 'name': course_run.title, 'desc': course_run.full_description, 'purl': course_run.marketing_url, @@ -1439,22 +1865,68 @@ def test_data(self): 'actualp': seat.price }, 'currency': seat.currency.code, - 'imgurl': course_run.course.card_image_url, + 'imgurl': course_run.image_url, 'category': 'Other Experiences', 'validfrom': course_run.start.strftime('%Y-%m-%d'), 'validto': course_run.end.strftime('%Y-%m-%d'), - 'lang': course_run.language.code.split('-')[0].upper(), + 'lang': course_run.language.code.split('-')[0].lower(), 'custom1': course_run.pacing_type, - 'custom2': course_run.level_type.name, + 'custom2': course_run.level_type.name_t, 'custom3': ','.join(subject.name for subject in course_run.subjects.all()), 'custom4': ','.join(org.name for org in course_run.authoring_organizations.all()), 'custom5': course_run.short_description, + 'custom6': str(course_run.weeks_to_complete) + ' week', } assert serializer.data == expected -class CourseSearchSerializerMixin(object): +class ProgramsAffiliateWindowSerializerTests(TestCase): + def test_data(self): + user = UserFactory() + + CatalogFactory(query='*:*', program_query='*', viewers=[user]) + course = CourseFactory() + course_run = CourseRunFactory( + transcript_languages=LanguageTag.objects.filter(code__in=['en-us']), + authoring_organizations=[OrganizationFactory()] + ) + course_run.save() + course.course_runs.add(course_run) + course.canonical_course_run = course_run + course.save() + + applicable_seat_types = SeatTypeFactory.create_batch(3) + SeatFactory.create( + course_run=course_run, + type=applicable_seat_types[0], + price=10, + sku='ABCDEF' + ) + program_type = ProgramTypeFactory(applicable_seat_types=applicable_seat_types) + program = ProgramFactory( + courses=[course_run.course], + type=program_type, + banner_image=make_image_file('test_banner.jpg'), + ) + serializer = ProgramsAffiliateWindowSerializer(program) + + expected = { + 'pid': str(program.uuid), + 'name': program.title, + 'desc': program.overview, + 'purl': program.marketing_url, + 'price': str(program.price_ranges[0].get('total')), + 'currency': program.price_ranges[0].get('currency'), + 'imgurl': program.banner_image.url, + 'category': 'Other Experiences', + 'lang': program.languages.pop().code.split('-')[0].lower(), + 'custom1': program.type.slug, + } + assert serializer.data == expected + + +class CourseSearchSerializerMixin: serializer_class = None def serialize_course(self, course, request): @@ -1468,32 +1940,199 @@ class CourseSearchSerializerTests(TestCase, CourseSearchSerializerMixin): def test_data(self): request = make_request() - course = CourseFactory(subjects=SubjectFactory.create_batch(3)) + organization = OrganizationFactory() + # 'organizations' in serialized data should not return duplicate organization names + # Add the same organization twice to the course and make sure only one is in the serialized data + course = CourseFactory( + subjects=SubjectFactory.create_batch(3), + authoring_organizations=[organization], + sponsoring_organizations=[organization], + ) course_run = CourseRunFactory(course=course) course.course_runs.add(course_run) course.save() + seat = SeatFactory(course_run=course_run) serializer = self.serialize_course(course, request) - assert serializer.data == self.get_expected_data(course, course_run, request) + assert serializer.data == self.get_expected_data(course, course_run, seat) + + def test_exclude_expired_and_keep_current_course_run(self): + request = make_request({'exclude_expired_course_run': True}) + organization = OrganizationFactory() + course = CourseFactory( + subjects=SubjectFactory.create_batch(3), + authoring_organizations=[organization], + sponsoring_organizations=[organization], + ) + course_run = CourseRunFactory( + course=course, + end=datetime.datetime.now(UTC) + datetime.timedelta(days=10) + ) + course_run_expired = CourseRunFactory( + course=course, + end=datetime.datetime.now(UTC) - datetime.timedelta(days=10), + enrollment_end=datetime.datetime.now(UTC) - datetime.timedelta(days=10) + ) + course.course_runs.add(course_run, course_run_expired) + course.save() + seat = SeatFactory(course_run=course_run) + serializer = self.serialize_course(course, request) + assert serializer.data["course_runs"] == self.get_expected_data(course, course_run, seat)["course_runs"] + + def test_exclude_expired_course_run(self): + request = make_request({'exclude_expired_course_run': True}) + organization = OrganizationFactory() + course = CourseFactory( + subjects=SubjectFactory.create_batch(3), + authoring_organizations=[organization], + sponsoring_organizations=[organization], + ) + course_run = CourseRunFactory( + course=course, + end=datetime.datetime.now(UTC) - datetime.timedelta(days=10), + enrollment_end=datetime.datetime.now(UTC) - datetime.timedelta(days=10) + ) + course.course_runs.add(course_run) + course.save() + seat = SeatFactory(course_run=course_run) + expected = { + 'key': course.key, + 'title': course.title, + 'short_description': course.short_description, + 'full_description': course.full_description, + 'content_type': 'course', + 'aggregation_key': f'course:{course.key}', + 'card_image_url': course.card_image_url, + 'image_url': course.image_url, + 'course_runs': [], + 'uuid': str(course.uuid), + 'subjects': [subject.name for subject in course.subjects.all()], + 'languages': [ + serialize_language(course_run.language) for course_run in course.course_runs.all() + if course_run.language + ], + 'seat_types': [seat.type.slug], + 'organizations': [ + '{key}: {name}'.format( + key=course.sponsoring_organizations.first().key, + name=course.sponsoring_organizations.first().name, + ) + ] + } + + serializer = self.serialize_course(course, request) + self.assertDictEqual(serializer.data, expected) + + def test_detail_fields_in_response(self): + request = make_request({'detail_fields': True}) + organization = OrganizationFactory() + # 'organizations' in serialized data should not return duplicate organization names + # Add the same organization twice to the course and make sure only one is in the serialized data + course = CourseFactory( + subjects=SubjectFactory.create_batch(3), + authoring_organizations=[organization], + sponsoring_organizations=[organization], + ) + course_run = CourseRunFactory(course=course) + course.course_runs.add(course_run) + course.save() + seat = SeatFactory(course_run=course_run) + expected = { + 'key': course.key, + 'title': course.title, + 'short_description': course.short_description, + 'full_description': course.full_description, + 'content_type': 'course', + 'aggregation_key': f'course:{course.key}', + 'card_image_url': course.card_image_url, + 'image_url': course.image_url, + 'course_runs': [{ + 'key': course_run.key, + 'enrollment_start': course_run.enrollment_start, + 'enrollment_end': course_run.enrollment_end, + 'go_live_date': course_run.go_live_date, + 'start': course_run.start, + 'end': course_run.end, + 'modified': course_run.modified, + 'availability': course_run.availability, + 'status': course_run.status, + 'pacing_type': course_run.pacing_type, + 'enrollment_mode': course_run.type_legacy, + 'min_effort': course_run.min_effort, + 'max_effort': course_run.max_effort, + 'weeks_to_complete': course_run.weeks_to_complete, + 'estimated_hours': get_course_run_estimated_hours(course_run), + 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price or 0.0, + 'is_enrollable': course_run.is_enrollable, + 'staff': MinimalPersonSerializer(course_run.staff, many=True, + context={'request': request}).data, + 'content_language': course_run.language.code if course_run.language else None, + + }], + 'uuid': str(course.uuid), + 'subjects': [subject.name for subject in course.subjects.all()], + 'languages': [ + serialize_language(course_run.language) for course_run in course.course_runs.all() + if course_run.language + ], + 'seat_types': [seat.type.slug], + 'organizations': [ + '{key}: {name}'.format( + key=course.sponsoring_organizations.first().key, + name=course.sponsoring_organizations.first().name, + ) + ], + 'outcome': course.outcome, + 'level_type': course.level_type.name, + 'modified': course.modified.strftime('%Y-%m-%dT%H:%M:%SZ'), + } + + serializer = self.serialize_course(course, request) + self.assertDictEqual(serializer.data, expected) @classmethod - def get_expected_data(cls, course, course_run, request): # pylint: disable=unused-argument + def get_expected_data(cls, course, course_run, seat): return { 'key': course.key, 'title': course.title, 'short_description': course.short_description, 'full_description': course.full_description, 'content_type': 'course', - 'aggregation_key': 'course:{}'.format(course.key), + 'aggregation_key': f'course:{course.key}', 'card_image_url': course.card_image_url, + 'image_url': course.image_url, 'course_runs': [{ 'key': course_run.key, 'enrollment_start': course_run.enrollment_start, 'enrollment_end': course_run.enrollment_end, + 'go_live_date': course_run.go_live_date, 'start': course_run.start, 'end': course_run.end, + 'modified': course_run.modified, + 'availability': course_run.availability, + 'status': course_run.status, + 'pacing_type': course_run.pacing_type, + 'enrollment_mode': course_run.type_legacy, + 'min_effort': course_run.min_effort, + 'max_effort': course_run.max_effort, + 'weeks_to_complete': course_run.weeks_to_complete, + 'estimated_hours': get_course_run_estimated_hours(course_run), + 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price or 0.0, + 'is_enrollable': course_run.is_enrollable, }], 'uuid': str(course.uuid), - 'subjects': [subject.name for subject in course.subjects.all()] + 'subjects': [subject.name for subject in course.subjects.all()], + 'org': course.authoring_organizations, + 'languages': [ + serialize_language(course_run.language) for course_run in course.course_runs.all() + if course_run.language + ], + 'seat_types': [seat.type.slug], + 'organizations': [ + '{key}: {name}'.format( + key=course.sponsoring_organizations.first().key, + name=course.sponsoring_organizations.first().name, + ) + ] } @@ -1510,7 +2149,7 @@ def test_data(self): assert serializer.data == self.get_expected_data(course, course_run, request) @classmethod - def get_expected_data(cls, course, course_run, request): # pylint: disable=unused-argument + def get_expected_data(cls, course, course_run, request): expected_data = CourseWithProgramsSerializerTests.get_expected_data(course, request) expected_data.update({'content_type': 'course'}) return expected_data @@ -1523,7 +2162,7 @@ def test_data(self): request = make_request() course_run = CourseRunFactory(transcript_languages=LanguageTag.objects.filter(code__in=['en-us', 'zh-cn']), authoring_organizations=[OrganizationFactory()]) - SeatFactory.create(course_run=course_run, type='verified', price=10, sku='ABCDEF') + SeatFactory.create(course_run=course_run, type=SeatTypeFactory.verified(), price=10, sku='ABCDEF') program = ProgramFactory(courses=[course_run.course]) self.reindex_courses(program) serializer = self.serialize_course_run(course_run, request) @@ -1543,17 +2182,18 @@ def serialize_course_run(self, course_run, request): return serializer @classmethod - def get_expected_data(cls, course_run, request): # pylint: disable=unused-argument + def get_expected_data(cls, course_run, request): return { - 'transcript_languages': [serialize_language(l) for l in course_run.transcript_languages.all()], + 'transcript_languages': [serialize_language(cr_t_l) for cr_t_l in course_run.transcript_languages.all()], 'min_effort': course_run.min_effort, 'max_effort': course_run.max_effort, 'weeks_to_complete': course_run.weeks_to_complete, 'short_description': course_run.short_description, - 'start': serialize_datetime_without_timezone(course_run.start), - 'end': serialize_datetime_without_timezone(course_run.end), - 'enrollment_start': serialize_datetime_without_timezone(course_run.enrollment_start), - 'enrollment_end': serialize_datetime_without_timezone(course_run.enrollment_end), + 'start': serialize_datetime(course_run.start), + 'end': serialize_datetime(course_run.end), + 'go_live_date': serialize_datetime(course_run.go_live_date), + 'enrollment_start': serialize_datetime(course_run.enrollment_start), + 'enrollment_end': serialize_datetime(course_run.enrollment_end), 'key': course_run.key, 'marketing_url': course_run.marketing_url, 'pacing_type': course_run.pacing_type, @@ -1564,22 +2204,23 @@ def get_expected_data(cls, course_run, request): # pylint: disable=unused-argum 'content_type': 'courserun', 'org': CourseKey.from_string(course_run.key).org, 'number': CourseKey.from_string(course_run.key).course, - 'seat_types': course_run.seat_types, + 'seat_types': [seat.slug for seat in course_run.seat_types], 'image_url': course_run.image_url, - 'type': course_run.type, + 'type': course_run.type_legacy, 'level_type': course_run.level_type.name, 'availability': course_run.availability, 'published': course_run.status == CourseRunStatus.Published, 'partner': course_run.course.partner.short_code, 'program_types': course_run.program_types, - 'logo_image_urls': [org.logo_image_url for org in course_run.authoring_organizations.all()], + 'logo_image_urls': [org.logo_image.url for org in course_run.authoring_organizations.all()], 'authoring_organization_uuids': get_uuids(course_run.authoring_organizations.all()), 'subject_uuids': get_uuids(course_run.subjects.all()), 'staff_uuids': get_uuids(course_run.staff.all()), - 'aggregation_key': 'courserun:{}'.format(course_run.course.key), + 'aggregation_key': f'courserun:{course_run.course.key}', 'has_enrollable_seats': course_run.has_enrollable_seats, 'first_enrollable_paid_seat_sku': course_run.first_enrollable_paid_seat_sku(), 'first_enrollable_paid_seat_price': course_run.first_enrollable_paid_seat_price, + 'is_enrollable': course_run.is_enrollable, } @@ -1598,7 +2239,7 @@ class PersonSearchSerializerTest(ElasticsearchTestMixin, TestCase): serializer_class = PersonSearchSerializer @classmethod - def get_expected_data(cls, person, request): # pylint: disable=unused-argument + def get_expected_data(cls, person, request): return { 'salutation': person.salutation, 'position': [person.position.title, person.position.organization_override], @@ -1608,7 +2249,8 @@ def get_expected_data(cls, person, request): # pylint: disable=unused-argument 'content_type': 'person', 'aggregation_key': 'person:' + str(person.uuid), 'profile_image_url': person.get_profile_image_url, - 'full_name': person.full_name + 'full_name': person.full_name, + 'organizations': [], } def test_data(self): @@ -1667,12 +2309,12 @@ def setUp(self): self.request = make_request() @classmethod - def get_expected_data(cls, program, request): # pylint: disable=unused-argument + def get_expected_data(cls, program, request): return { 'uuid': str(program.uuid), 'title': program.title, 'subtitle': program.subtitle, - 'type': program.type.name, + 'type': program.type.name_t, 'marketing_url': program.marketing_url, 'authoring_organizations': OrganizationSerializer(program.authoring_organizations, many=True).data, 'content_type': 'program', @@ -1687,7 +2329,7 @@ def get_expected_data(cls, program, request): # pylint: disable=unused-argument 'staff_uuids': get_uuids( itertools.chain.from_iterable(course.staff.all() for course in list(program.course_runs)) ), - 'aggregation_key': 'program:{}'.format(program.uuid), + 'aggregation_key': f'program:{program.uuid}', 'weeks_to_complete_min': program.weeks_to_complete_min, 'weeks_to_complete_max': program.weeks_to_complete_max, 'min_hours_effort_per_week': program.min_hours_effort_per_week, @@ -1744,6 +2386,7 @@ class ProgramSearchModelSerializerTest(TestProgramSearchSerializer): def get_expected_data(cls, program, request): expected = ProgramSerializerTests.get_expected_data(program, request) expected.update({'content_type': 'program'}) + expected.update({'marketing_hook': program.marketing_hook}) return expected @@ -1784,7 +2427,7 @@ def get_expected_data(cls, program): return { 'uuid': str(program.uuid), 'title': program.title, - 'type': program.type.name, + 'type': program.type.name_t, 'orgs': [org.key for org in program.authoring_organizations.all()], 'marketing_url': program.marketing_url, } @@ -1810,14 +2453,11 @@ def serialize_program(self, program): return serializer +@override_switch('use_company_name_as_utm_source_value', True) class TestGetUTMSourceForUser(LMSAPIClientMixin, TestCase): def setUp(self): - super(TestGetUTMSourceForUser, self).setUp() - - self.switch, __ = Switch.objects.update_or_create( - name='use_company_name_as_utm_source_value', defaults={'active': True} - ) + super().setUp() self.user = UserFactory.create() self.partner = PartnerFactory.create() @@ -1837,10 +2477,6 @@ def test_with_missing_lms_url(self, mock_access_token): # pylint: disable=unuse Verify that `get_utm_source_for_user` returns default value if `Partner.lms_url` is not set in the database. """ - # Remove lms_url from partner. - self.partner.lms_url = '' - self.partner.save() - assert get_utm_source_for_user(self.partner, self.user) == self.user.username @responses.activate @@ -1850,6 +2486,7 @@ def test_when_api_response_is_not_valid(self, mock_access_token): # pylint: dis Verify that `get_utm_source_for_user` returns default value if LMS API does not return a valid response. """ + self.partner.lms_url = 'http://127.0.0.1:8000' self.mock_api_access_request(self.partner.lms_url, self.user, status=400) assert get_utm_source_for_user(self.partner, self.user) == self.user.username @@ -1859,10 +2496,36 @@ def test_get_utm_source_for_user(self, mock_access_token): # pylint: disable=un """ Verify that `get_utm_source_for_user` returns correct value. """ + self.partner.lms_url = 'http://127.0.0.1:8000' company_name = 'Test Company' - expected_utm_source = slugify('{} {}'.format(self.user.username, company_name)) + expected_utm_source = slugify(f'{self.user.username} {company_name}') self.mock_api_access_request( self.partner.lms_url, self.user, api_access_request_overrides={'company_name': company_name}, ) assert get_utm_source_for_user(self.partner, self.user) == expected_utm_source + + +class CollaboratorSerializerTests(TestCase): + serializer_class = CollaboratorSerializer + + def test_data(self): + self.maxDiff = None + + request = make_request() + + image_field = StdImageSerializerField() + image_field._context = {'request': request} # pylint: disable=protected-access + + collaborator = CollaboratorFactory() + serializer = self.serializer_class(collaborator, context={'request': request}) + image = image_field.to_representation(collaborator.image) + + expected = { + 'name': collaborator.name, + 'image': image, + 'image_url': collaborator.image_url, + 'uuid': str(collaborator.uuid) + } + + self.assertDictEqual(serializer.data, expected) diff --git a/course_discovery/apps/api/tests/test_utils.py b/course_discovery/apps/api/tests/test_utils.py index 44aaba6c68..9623059399 100644 --- a/course_discovery/apps/api/tests/test_utils.py +++ b/course_discovery/apps/api/tests/test_utils.py @@ -1,10 +1,16 @@ +import datetime +from itertools import product +from unittest import mock + import ddt -import mock from django.test import TestCase +from opaque_keys.edx.keys import CourseKey from rest_framework.request import Request from rest_framework.test import APIRequestFactory -from course_discovery.apps.api.utils import cast2int, get_query_param +from course_discovery.apps.api.utils import StudioAPI, cast2int, get_query_param +from course_discovery.apps.core.utils import serialize_datetime +from course_discovery.apps.course_metadata.tests.factories import CourseEditorFactory, CourseRunFactory LOGGER_PATH = 'course_discovery.apps.api.utils.logger.exception' @@ -40,3 +46,119 @@ def test_with_request(self): def test_without_request(self): assert get_query_param(None, 'q') is None + + +@ddt.ddt +class StudioAPITests(TestCase): + def setUp(self): + super().setUp() + self.client = mock.Mock() + self.api = StudioAPI(self.client) + + def make_studio_data(self, run, add_pacing=True, add_schedule=True, team=None): + key = CourseKey.from_string(run.key) + data = { + 'title': run.title, + 'org': key.org, + 'number': key.course, + 'run': key.run, + 'team': team or [], + } + if add_pacing: + data['pacing_type'] = run.pacing_type + if add_schedule: + data['schedule'] = { + 'start': serialize_datetime(run.start), + 'end': serialize_datetime(run.end), + } + return data + + def assert_data_generated_correctly(self, course_run, expected_team_data, creating=False): + course = course_run.course + expected = { + 'title': course_run.title_override or course.title, + 'org': course.organizations.first().key, + 'number': course.number, + 'run': StudioAPI.calculate_course_run_key_run_value(course.number, course_run.start_date_temporary), + 'schedule': { + 'start': serialize_datetime(course_run.start_date_temporary), + 'end': serialize_datetime(course_run.end_date_temporary), + }, + 'team': expected_team_data, + 'pacing_type': course_run.pacing_type_temporary, + } + self.assertEqual(StudioAPI.generate_data_for_studio_api(course_run, creating=creating), expected) + + def test_create_rerun(self): + run1 = CourseRunFactory() + run2 = CourseRunFactory(course=run1.course) + self.api.create_course_rerun_in_studio(run2, run1.key) + + expected_data = self.make_studio_data(run2) + self.assertEqual(self.client.course_runs.call_args_list, [mock.call(run1.key)]) + self.assertEqual(self.client.course_runs.return_value.rerun.post.call_args_list[0][0][0], expected_data) + + def test_create_run(self): + run = CourseRunFactory() + self.api.create_course_run_in_studio(run) + + expected_data = self.make_studio_data(run) + self.assertEqual(self.client.course_runs.post.call_args_list[0][0][0], expected_data) + + def test_update_run(self): + run = CourseRunFactory() + self.api.update_course_run_details_in_studio(run) + + expected_data = self.make_studio_data(run, add_pacing=False, add_schedule=False) + self.assertEqual(self.client.course_runs.call_args_list, [mock.call(run.key)]) + self.assertEqual(self.client.course_runs.return_value.patch.call_args_list[0][0][0], expected_data) + + @ddt.data( + *product(range(1, 5), ['1T2017']), + *product(range(5, 9), ['2T2017']), + *product(range(9, 13), ['3T2017']), + ) + @ddt.unpack + def test_calculate_course_run_key_run_value(self, month, expected): + start = datetime.datetime(2017, month, 1) + self.assertEqual(StudioAPI.calculate_course_run_key_run_value('NONE', start=start), expected) + + def test_generate_data_for_studio_api(self): + run = CourseRunFactory() + editor = CourseEditorFactory(course=run.course) + team = [ + { + 'user': editor.user.username, + 'role': 'instructor', + }, + ] + self.assertEqual(StudioAPI.generate_data_for_studio_api(run, True), self.make_studio_data(run, team=team)) + + def test_generate_data_for_studio_api_without_team(self): + run = CourseRunFactory() + with mock.patch('course_discovery.apps.api.utils.logger.warning') as mock_logger: + self.assertEqual(StudioAPI.generate_data_for_studio_api(run, True), self.make_studio_data(run)) + mock_logger.assert_called_with( + 'No course team admin specified for course [%s]. This may result in a Studio course run ' + 'being created without a course team.', + run.key.split('/')[1] + ) + + def test_calculate_course_run_key_run_value_with_multiple_runs_per_trimester(self): + start = datetime.datetime(2017, 2, 1) + + CourseRunFactory(key='course-v1:TestX+Testing101x+1T2017') + self.assertEqual(StudioAPI.calculate_course_run_key_run_value('TestX', start), '1T2017a') + + CourseRunFactory(key='course-v1:TestX+Testing101x+1T2017a') + self.assertEqual(StudioAPI.calculate_course_run_key_run_value('TestX', start), '1T2017b') + + def test_update_course_run_image_in_studio_without_course_image(self): + run = CourseRunFactory(course__image=None) + with mock.patch('course_discovery.apps.api.utils.logger') as mock_logger: + self.api.update_course_run_image_in_studio(run) + mock_logger.warning.assert_called_with( + 'Card image for course run [%d] cannot be updated. The related course [%d] has no image defined.', + run.id, + run.course.id + ) diff --git a/course_discovery/apps/api/tests/test_views.py b/course_discovery/apps/api/tests/test_views.py index 33bd60a2f7..4c38ab7994 100644 --- a/course_discovery/apps/api/tests/test_views.py +++ b/course_discovery/apps/api/tests/test_views.py @@ -30,14 +30,13 @@ def test_api_docs_redirect(self): Verify that unauthenticated clients are redirected. """ response = self.client.get(self.path) - assert response.status_code == 302 @ddt.ddt class ApiDocsPermissionDeniedHandlerTests(TestCase): def setUp(self): - super(ApiDocsPermissionDeniedHandlerTests, self).setUp() + super().setUp() self.request_path = '/' self.request = RequestFactory().get(self.request_path) diff --git a/course_discovery/apps/api/urls.py b/course_discovery/apps/api/urls.py index 9d14c071a6..70d50c0060 100644 --- a/course_discovery/apps/api/urls.py +++ b/course_discovery/apps/api/urls.py @@ -6,6 +6,8 @@ """ from django.conf.urls import include, url +app_name = 'api' + urlpatterns = [ - url(r'^v1/', include('course_discovery.apps.api.v1.urls', namespace='v1')), + url(r'^v1/', include('course_discovery.apps.api.v1.urls')), ] diff --git a/course_discovery/apps/api/utils.py b/course_discovery/apps/api/utils.py index d066aa3de7..7bc20c34d7 100644 --- a/course_discovery/apps/api/utils.py +++ b/course_discovery/apps/api/utils.py @@ -1,7 +1,13 @@ -import hashlib import logging +import math -import six +from django.db.models.fields.related import ManyToManyField +from django.utils.translation import ugettext as _ +from opaque_keys.edx.keys import CourseKey +from sortedm2m.fields import SortedManyToManyField + +from course_discovery.apps.core.utils import serialize_datetime +from course_discovery.apps.course_metadata.models import CourseRun logger = logging.getLogger(__name__) @@ -38,30 +44,167 @@ def get_query_param(request, name): # This facilitates DRF's schema generation. For more, see # https://github.com/encode/django-rest-framework/blob/3.6.3/rest_framework/schemas.py#L383 if request is None: - return + return None return cast2int(request.query_params.get(name), name) -def get_cache_key(**kwargs): +def reviewable_data_has_changed(obj, new_key_vals, exempt_fields=None): """ - Get MD5 encoded cache key for given arguments. + Check whether serialized data for the object has changed. - Here is the format of key before MD5 encryption. - key1:value1__key2:value2 ... + Args: + obj (Object): Object representing the persisted state + new_key_vals (dict_items): List of (key,value) tuples representing the new state + exempt_fields (list): List of field names where a change does not affect review status - Example: - >>> get_cache_key(site_domain="example.com", resource="catalogs") - # Here is key format for above call - # "site_domain:example.com__resource:catalogs" - a54349175618ff1659dee0978e3149ca + Returns: + bool for whether data for any reviewable fields has changed + """ + changed = False + exempt_fields = exempt_fields or [] + for key, new_value in [x for x in new_key_vals if x[0] not in exempt_fields]: + original_value = getattr(obj, key, None) + if isinstance(new_value, list): + field_class = obj.__class__._meta.get_field(key).__class__ + original_value_elements = original_value.all() + if len(new_value) != original_value_elements.count(): + changed = True + # Just use set compare since none of our fields require duplicates + elif field_class == ManyToManyField and set(new_value) != set(original_value_elements): + changed = True + elif field_class == SortedManyToManyField: + for new_value_element, original_value_element in zip(new_value, original_value_elements): + if new_value_element != original_value_element: + changed = True + elif new_value != original_value: + changed = True + return changed + + +def conditional_decorator(condition, decorator): + """ + Util decorator that allows for only using the given decorator arg if the condition passes + """ + return decorator if condition else lambda x: x - Arguments: - **kwargs: Key word arguments that need to be present in cache key. - Returns: - An MD5 encoded key uniquely identified by the key word arguments. +class StudioAPI: + """ + A convenience class for talking to the Studio API - designed to allow subclassing by the publisher django app, + so that they can use it for their own publisher CourseRun models, which are slightly different than the course + metadata ones. """ - key = '__'.join(['{}:{}'.format(item, value) for item, value in six.iteritems(kwargs)]) - return hashlib.md5(key.encode('utf-8')).hexdigest() + def __init__(self, api_client): + self._api = api_client + + @classmethod + def _get_next_run(cls, root, suffix, existing_runs): + candidate = root + suffix + + if candidate in existing_runs: + # If our candidate is an existing run, use the next letter in the alphabet as the + # run suffix (e.g. 1T2017, 1T2017a, 1T2017b, ...). + suffix = chr(ord(suffix) + 1) if suffix else 'a' + return cls._get_next_run(root, suffix, existing_runs) + + return candidate + + @classmethod + def calculate_course_run_key_run_value(cls, course_num, start): + trimester = math.ceil(start.month / 4.) + run = f'{trimester}T{start.year}' + + related_course_runs = CourseRun.everything.filter(key__contains=course_num).values_list('key', flat=True) + related_course_runs = [CourseKey.from_string(key).run for key in related_course_runs] + + return cls._get_next_run(run, '', related_course_runs) + + @classmethod + def generate_data_for_studio_api(cls, course_run, creating, user=None): + editors = [editor.user for editor in course_run.course.editors.all()] + key = CourseKey.from_string(course_run.key) + + # start, end, and pacing are not sent on updates - Studio is where users edit them + start = course_run.start if creating else None + end = course_run.end if creating else None + pacing = course_run.pacing_type if creating else None + + if user: + editors.append(user) + + if editors: + team = [ + { + 'user': user.username, + 'role': 'instructor', + } + for user in editors + ] + else: + team = [] + logger.warning('No course team admin specified for course [%s]. This may result in a Studio ' + 'course run being created without a course team.', key.course) + + data = { + 'title': course_run.title, + 'org': key.org, + 'number': key.course, + 'run': key.run, + 'team': team, + } + + if pacing: + data['pacing_type'] = pacing + + if start or end: + data['schedule'] = { + 'start': serialize_datetime(start), + 'end': serialize_datetime(end), + } + + return data + + def create_course_rerun_in_studio(self, course_run, old_course_run_key, user=None): + data = self.generate_data_for_studio_api(course_run, creating=True, user=user) + return self._api.course_runs(old_course_run_key).rerun.post(data) + + def create_course_run_in_studio(self, publisher_course_run, user=None): + data = self.generate_data_for_studio_api(publisher_course_run, creating=True, user=user) + return self._api.course_runs.post(data) + + def update_course_run_image_in_studio(self, course_run): + course = course_run.course + image = course.image + + if image: + files = {'card_image': image} + try: + self._api.course_runs(course_run.key).images.post(files=files) + except Exception: # pylint: disable=broad-except + logger.exception( + _('An error occurred while setting the course run image for [{key}] in studio. All other fields ' + 'were successfully saved in Studio.').format(key=course_run.key) + ) + else: + logger.warning( + 'Card image for course run [%d] cannot be updated. The related course [%d] has no image defined.', + course_run.id, + course.id + ) + + def update_course_run_details_in_studio(self, course_run): + data = self.generate_data_for_studio_api(course_run, creating=False) + # NOTE: We use PATCH to avoid overwriting existing team data that may have been manually input in Studio. + return self._api.course_runs(course_run.key).patch(data) + + def push_to_studio(self, course_run, create=False, old_course_run_key=None, user=None): + if create and old_course_run_key: + response = self.create_course_rerun_in_studio(course_run, old_course_run_key, user=user) + elif create: + response = self.create_course_run_in_studio(course_run, user=user) + else: + response = self.update_course_run_details_in_studio(course_run) + + return response diff --git a/course_discovery/apps/api/v1/exceptions.py b/course_discovery/apps/api/v1/exceptions.py new file mode 100644 index 0000000000..7255b8fd26 --- /dev/null +++ b/course_discovery/apps/api/v1/exceptions.py @@ -0,0 +1,13 @@ +from django.utils.translation import ugettext as _ + + +class EditableAndQUnsupported(Exception): + """ + This Exception exists because we were witnessing weird behavior when both editable=1 and + a q parameter is defined (test passing locally, but consistently failing on travis. Also manual + smoke testing giving incorrect results when both were defined). It is possible to dig into this + and figure out how to support it, but it was decided that since we do not have a use case yet, + we would disallow it for now. + """ + def __init__(self): + super().__init__(_('Specifying both editable=1 and a q parameter is not supported.')) diff --git a/course_discovery/apps/api/v1/tests/affiliate_window_product_feed.1.4.dtd b/course_discovery/apps/api/v1/tests/affiliate_window_product_feed.1.4.dtd index 4ffc234bdf..eb3fd1630b 100644 --- a/course_discovery/apps/api/v1/tests/affiliate_window_product_feed.1.4.dtd +++ b/course_discovery/apps/api/v1/tests/affiliate_window_product_feed.1.4.dtd @@ -14,7 +14,7 @@ basic requirements for a product are:pid, name, purl, category and price(actualp). The more data you provide the better an affiliate can promote your products! --> - + + + diff --git a/course_discovery/apps/api/v1/tests/test_cache.py b/course_discovery/apps/api/v1/tests/test_cache.py new file mode 100644 index 0000000000..d949e98d24 --- /dev/null +++ b/course_discovery/apps/api/v1/tests/test_cache.py @@ -0,0 +1,128 @@ +import zlib + +import ddt +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework import permissions, views +from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer +from rest_framework.response import Response +from rest_framework_extensions.test import APIRequestFactory +from waffle.testutils import override_flag + +from course_discovery.apps.api.cache import compressed_cache_response + +factory = APIRequestFactory() + + +@override_settings(USE_API_CACHING=True) +@ddt.ddt +class CompressedCacheResponseTest(TestCase): + def setUp(self): + super().setUp() + self.request = factory.get('') + self.cache_response_key = 'cache_response_key' + + def test_should_handle_getting_uncompressed_response_from_cache(self): + """ Verify that the decorator correctly returns uncompressed responses """ + def key_func(**kwargs): # pylint: disable=unused-argument + return self.cache_response_key + + class TestView(views.APIView): + permission_classes = [permissions.AllowAny] + renderer_classes = [JSONRenderer] + + @compressed_cache_response(key_func=key_func) + def get(self, request, *args, **kwargs): + return Response('test response') + + view_instance = TestView() + view_instance.headers = {} # pylint: disable=attribute-defined-outside-init + uncompressed_cached_response = Response('cached test response') + view_instance.finalize_response(request=self.request, response=uncompressed_cached_response) + uncompressed_cached_response.render() + + response_triple = ( + uncompressed_cached_response.rendered_content, + uncompressed_cached_response.status_code, + uncompressed_cached_response._headers.copy(), # pylint: disable=protected-access + ) + cache.set(self.cache_response_key, response_triple) + + response = view_instance.dispatch(request=self.request) + self.assertEqual(response.content.decode('utf-8'), '"cached test response"') + + def test_should_handle_getting_compressed_response_from_cache(self): + """ Verify that the decorator correctly returns compressed responses """ + def key_func(**kwargs): # pylint: disable=unused-argument + return self.cache_response_key + + class TestView(views.APIView): + permission_classes = [permissions.AllowAny] + renderer_classes = [JSONRenderer] + + @compressed_cache_response(key_func=key_func) + def get(self, request, *args, **kwargs): + return Response('test response') + + view_instance = TestView() + view_instance.headers = {} # pylint: disable=attribute-defined-outside-init + compressed_cached_response = Response('compressed cached test response') + view_instance.finalize_response(request=self.request, response=compressed_cached_response) + compressed_cached_response.render() + + # Rendered content is compressed before response goes into the cache + response_triple = ( + zlib.compress(compressed_cached_response.rendered_content), + compressed_cached_response.status_code, + compressed_cached_response._headers.copy(), # pylint: disable=protected-access + ) + cache.set(self.cache_response_key, response_triple) + + response = view_instance.dispatch(request=self.request) + self.assertEqual(response.content.decode('utf-8'), '"compressed cached test response"') + + def test_should_not_cache_for_non_json_responses(self): + """ Verify that the decorator does not cache if the response is not json """ + def key_func(**kwargs): # pylint: disable=unused-argument + return 'non_json_cache_key' + + class TestView(views.APIView): + permission_classes = [permissions.AllowAny] + renderer_classes = [BrowsableAPIRenderer] # Non-json responses + + @compressed_cache_response(key_func=key_func) + def get(self, request, *args, **kwargs): + return Response('test response') + + view_instance = TestView() + view_instance.headers = {} # pylint: disable=attribute-defined-outside-init + view_instance.dispatch(request=self.request) + + # Verify nothing was cached + self.assertEqual(cache.get('non_json_cache_key'), None) + + @ddt.data(True, False) + def test_should_not_cache_if_waffled(self, waffle_active): + """ Verify that the decorator does not cache the waffle flag is turned off """ + def key_func(**kwargs): # pylint: disable=unused-argument + return self.cache_response_key + + class TestView(views.APIView): + permission_classes = [permissions.AllowAny] + renderer_classes = [JSONRenderer] + + @compressed_cache_response(key_func=key_func) + def get(self, request, *args, **kwargs): + return Response('test response') + + with override_flag('compressed_cache.TestView.get', active=waffle_active): + + view_instance = TestView() + view_instance.headers = {} # pylint: disable=attribute-defined-outside-init + view_instance.dispatch(request=self.request) + + # Verify nothing was cached + if waffle_active: + self.assertIsNot(cache.get(self.cache_response_key), None) + else: + self.assertIs(cache.get(self.cache_response_key), None) diff --git a/course_discovery/apps/api/v1/tests/test_views/mixins.py b/course_discovery/apps/api/v1/tests/test_views/mixins.py index 3f8268d4ee..139319a401 100644 --- a/course_discovery/apps/api/v1/tests/test_views/mixins.py +++ b/course_discovery/apps/api/v1/tests/test_views/mixins.py @@ -3,15 +3,14 @@ import json import responses -from django.conf import settings from haystack.query import SearchQuerySet -from rest_framework.test import APITestCase as RestAPITestCase from rest_framework.test import APIRequestFactory +from rest_framework.test import APITestCase as RestAPITestCase from course_discovery.apps.api import serializers from course_discovery.apps.api.tests.mixins import SiteMixin from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory -from course_discovery.apps.course_metadata.models import CourseRun, Program +from course_discovery.apps.course_metadata.models import Course, CourseRun, Program from course_discovery.apps.course_metadata.tests import factories @@ -42,16 +41,16 @@ def serialize_catalog(self, catalog, many=False, format=None, extra_context=None def serialize_course(self, course, many=False, format=None, extra_context=None): return self._serialize_object(serializers.CourseWithProgramsSerializer, course, many, format, extra_context) + def serialize_course_search(self, course, serializer=None): + obj = self._get_search_result(Course, key=course.key) + return self._serialize_object(serializer or serializers.CourseSearchSerializer, obj) + def serialize_course_run(self, run, many=False, format=None, extra_context=None): return self._serialize_object(serializers.CourseRunWithProgramsSerializer, run, many, format, extra_context) def serialize_minimal_course_run(self, run, many=False, format=None, extra_context=None): return self._serialize_object(serializers.MinimalCourseRunSerializer, run, many, format, extra_context) - def serialize_minimal_publisher_course_run(self, run, many=False, format=None, extra_context=None): - return self._serialize_object(serializers.MinimalPublisherCourseRunSerializer, run, many, format, - extra_context) - def serialize_course_run_search(self, run, serializer=None): obj = self._get_search_result(CourseRun, key=run.key) return self._serialize_object(serializer or serializers.CourseRunSearchSerializer, obj) @@ -83,6 +82,9 @@ def serialize_catalog_flat_course_run(self, course_run, many=False, format=None, serializers.FlattenedCourseRunWithCourseSerializer, course_run, many, format, extra_context ) + def serialize_level_type(self, level_type, many=False, format=None, extra_context=None): + return self._serialize_object(serializers.LevelTypeSerializer, level_type, many, format, extra_context) + def serialize_organization(self, organization, many=False, format=None, extra_context=None): return self._serialize_object(serializers.OrganizationSerializer, organization, many, format, extra_context) @@ -106,27 +108,13 @@ def serialize_program_search(self, program): return serializers.TypeaheadProgramSearchSerializer(obj).data -class OAuth2Mixin(object): - def generate_oauth2_token_header(self, user): - """ Generates a Bearer authorization header to simulate OAuth2 authentication. """ - return 'Bearer {token}'.format(token=user.username) - - def mock_user_info_response(self, user, status=200): - """ Mock the user info endpoint response of the OAuth2 provider. """ - - data = { - 'family_name': user.last_name, - 'preferred_username': user.username, - 'given_name': user.first_name, - 'email': user.email, - } - +class OAuth2Mixin: + def mock_access_token(self): responses.add( - responses.GET, - settings.EDX_DRF_EXTENSIONS['OAUTH2_USER_INFO_URL'], - body=json.dumps(data), - content_type='application/json', - status=status + responses.POST, + self.partner.lms_url + '/oauth2/access_token', + body=json.dumps({'access_token': 'abcd', 'expires_in': 60}), + status=200, ) @@ -172,10 +160,10 @@ def test_stemmed_synonyms(self): class LoginMixin: def setUp(self): - super(LoginMixin, self).setUp() + super().setUp() self.user = UserFactory() self.client.login(username=self.user.username, password=USER_PASSWORD) - if getattr(self, 'request'): + if hasattr(self, 'request'): self.request.user = self.user @@ -186,27 +174,28 @@ class FuzzyInt(int): See: https://lukeplant.me.uk/blog/posts/fuzzy-testing-with-assertnumqueries/ """ def __new__(cls, value, threshold): - obj = super(FuzzyInt, cls).__new__(cls, value) + obj = super().__new__(cls, value) obj.value = value obj.threshold = threshold return obj def __eq__(self, other): - return (self.value - self.threshold) <= other <= (self.value + self.threshold) + return (self.value - self.threshold) <= other <= (self.value + self.threshold) # pylint: disable=no-member def __ne__(self, other): return not self.__eq__(other) def __str__(self): - return 'FuzzyInt(value={}, threshold={})'.format(self.value, self.threshold) + return f'FuzzyInt(value={self.value}, threshold={self.threshold})' # pylint: disable=no-member class APITestCase(SiteMixin, RestAPITestCase): - def assertNumQueries(self, expected, threshold=2): + # pylint: disable=keyword-arg-before-vararg, arguments-differ + def assertNumQueries(self, num, func=None, *args, **kwargs): """ Overridden method to allow a number of queries within a constant range, rather than an exact amount of queries. This allows us to make changes to views and models that may slightly modify the query count without having to update expected counts in tests, while still ensuring that we don't inflate the number of queries by an order of magnitude. """ - return super(APITestCase, self).assertNumQueries(FuzzyInt(expected, threshold)) + return super().assertNumQueries(FuzzyInt(num, kwargs.pop('threshold', 2)), func=func, *args, **kwargs) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_affiliate_window.py b/course_discovery/apps/api/v1/tests/test_views/test_affiliate_window.py index 954df16dce..acc3bc9836 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_affiliate_window.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_affiliate_window.py @@ -1,4 +1,3 @@ -# pylint: disable=redefined-builtin,no-member import datetime import xml.etree.ElementTree as ET from os.path import abspath, dirname, join @@ -6,16 +5,105 @@ import ddt import pytz from lxml import etree +from rest_framework import status from rest_framework.reverse import reverse -from course_discovery.apps.api.serializers import AffiliateWindowSerializer +from course_discovery.apps.api.serializers import AffiliateWindowSerializer, ProgramsAffiliateWindowSerializer from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin from course_discovery.apps.catalogs.tests.factories import CatalogFactory from course_discovery.apps.core.tests.factories import UserFactory +from course_discovery.apps.core.tests.helpers import make_image_file from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.course_metadata.choices import CourseRunStatus -from course_discovery.apps.course_metadata.models import Seat -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, SeatFactory +from course_discovery.apps.course_metadata.models import ProgramType, Seat, SeatType +from course_discovery.apps.course_metadata.tests.factories import ( + CourseRunFactory, ProgramFactory, SeatFactory, SeatTypeFactory +) + + +@ddt.ddt +class ProgramsAffiliateWindowViewSetTests(SerializationMixin, APITestCase): + """ Tests for the ProgramsAffiliateWindowViewSet. """ + def _assert_product_xml(self, content, program): + """ Helper method to verify product data in xml format. """ + assert content.find('pid').text == f'{program.uuid}' + assert content.find('name').text == program.title + assert content.find('desc').text == program.overview + assert content.find('purl').text == program.marketing_url + assert content.find('imgurl').text == program.banner_image.url + assert content.find('category').text == ProgramsAffiliateWindowSerializer.CATEGORY + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.client.force_authenticate(self.user) + self.catalog = CatalogFactory(query='*:*', program_query='*:*', viewers=[self.user]) + + self.enrollment_end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30) + self.course_end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=60) + self.course_run = CourseRunFactory(enrollment_end=self.enrollment_end, end=self.course_end) + self.course = self.course_run.course + + # Generate test programs + self.test_image = make_image_file('test_banner.jpg') + self.masters_program_type = ProgramType.objects.get(slug=ProgramType.MASTERS) + self.microbachelors_program_type = ProgramType.objects.get(slug=ProgramType.MICROBACHELORS) + self.ms_program = ProgramFactory( + type=self.masters_program_type, + courses=[self.course], + banner_image=self.test_image, + ) + self.program = ProgramFactory( + type=self.microbachelors_program_type, + courses=[self.course], + banner_image=self.test_image, + ) + + self.affiliate_url = reverse('api:v1:partners:programs_affiliate_window-detail', kwargs={'pk': self.catalog.id}) + + def test_without_authentication(self): + """ Verify authentication is required when accessing the endpoint. """ + self.client.logout() + response = self.client.get(self.affiliate_url) + self.assertEqual(response.status_code, 401) + + def test_affiliate_with_approved_programs(self): + """Verify that only the expected Program types are returned, No Masters programs""" + response = self.client.get(self.affiliate_url) + assert response.status_code == status.HTTP_200_OK + root = ET.fromstring(response.content) + + # Assert that there is only on Program in the returned data even though 2 + # are created in setup + assert len(root.findall('product')) == 1 + self._assert_product_xml( + root.findall(f'product/[pid="{self.program.uuid}"]')[0], + self.program + ) + + # Add a new program of approved type and verify it is available + mm_program_type = ProgramType.objects.get(slug=ProgramType.MICROMASTERS) + mm_program = ProgramFactory(type=mm_program_type, courses=[self.course], banner_image=self.test_image) + + response = self.client.get(self.affiliate_url) + assert response.status_code == status.HTTP_200_OK + root = ET.fromstring(response.content) + + # Assert that there is only on Program in the returned data even though 2 + # are created in setup + assert len(root.findall('product')) == 2 + self._assert_product_xml( + root.findall(f'product/[pid="{self.program.uuid}"]')[0], + self.program + ) + + self._assert_product_xml( + root.findall(f'product/[pid="{mm_program.uuid}"]')[0], + mm_program + ) + + # Verify that the Masters program is not in the data + assert not root.findall(f'product/[pid="{self.ms_program.uuid}"]') @ddt.ddt @@ -23,7 +111,7 @@ class AffiliateWindowViewSetTests(ElasticsearchTestMixin, SerializationMixin, AP """ Tests for the AffiliateWindowViewSet. """ def setUp(self): - super(AffiliateWindowViewSetTests, self).setUp() + super().setUp() self.user = UserFactory() self.client.force_authenticate(self.user) self.catalog = CatalogFactory(query='*:*', viewers=[self.user]) @@ -32,7 +120,7 @@ def setUp(self): self.course_end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=60) self.course_run = CourseRunFactory(enrollment_end=self.enrollment_end, end=self.course_end) - self.seat_verified = SeatFactory(course_run=self.course_run, type=Seat.VERIFIED) + self.seat_verified = SeatFactory(course_run=self.course_run, type=SeatTypeFactory.verified()) self.course = self.course_run.course self.affiliate_url = reverse('api:v1:partners:affiliate_window-detail', kwargs={'pk': self.catalog.id}) self.refresh_index() @@ -41,7 +129,7 @@ def test_without_authentication(self): """ Verify authentication is required when accessing the endpoint. """ self.client.logout() response = self.client.get(self.affiliate_url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) def test_affiliate_with_supported_seats(self): """ Verify that endpoint returns course runs for verified and professional seats only. """ @@ -51,23 +139,23 @@ def test_affiliate_with_supported_seats(self): root = ET.fromstring(response.content) self.assertEqual(1, len(root.findall('product'))) self.assert_product_xml( - root.findall('product/[pid="{}-{}"]'.format(self.course_run.key, self.seat_verified.type))[0], + root.findall(f'product/[pid="{self.course_run.key}-{self.seat_verified.type.slug}"]')[0], self.seat_verified ) - # Add professional seat. - seat_professional = SeatFactory(course_run=self.course_run, type=Seat.PROFESSIONAL) + # Add professional seat + seat_professional = SeatFactory(course_run=self.course_run, type=SeatTypeFactory.professional()) response = self.client.get(self.affiliate_url) root = ET.fromstring(response.content) self.assertEqual(2, len(root.findall('product'))) self.assert_product_xml( - root.findall('product/[pid="{}-{}"]'.format(self.course_run.key, self.seat_verified.type))[0], + root.findall(f'product/[pid="{self.course_run.key}-{self.seat_verified.type.slug}"]')[0], self.seat_verified ) self.assert_product_xml( - root.findall('product/[pid="{}-{}"]'.format(self.course_run.key, seat_professional.type))[0], + root.findall(f'product/[pid="{self.course_run.key}-{seat_professional.type.slug}"]')[0], seat_professional ) @@ -75,7 +163,7 @@ def test_affiliate_with_supported_seats(self): def test_with_non_supported_seats(self, non_supporting_seat): """ Verify that endpoint returns no data for honor, credit and audit seats. """ - self.seat_verified.type = non_supporting_seat + self.seat_verified.type = SeatType.objects.get_or_create(slug=non_supporting_seat)[0] self.seat_verified.save() response = self.client.get(self.affiliate_url) @@ -100,11 +188,11 @@ def test_with_closed_enrollment(self): def assert_product_xml(self, content, seat): """ Helper method to verify product data in xml format. """ - assert content.find('pid').text == '{}-{}'.format(self.course_run.key, seat.type) + assert content.find('pid').text == f'{self.course_run.key}-{seat.type.slug}' assert content.find('name').text == self.course_run.title assert content.find('desc').text == self.course_run.full_description assert content.find('purl').text == self.course_run.marketing_url - assert content.find('imgurl').text == self.course_run.card_image_url + assert content.find('imgurl').text == self.course_run.image_url assert content.find('price/actualp').text == str(seat.price) assert content.find('currency').text == seat.currency.code assert content.find('category').text == AffiliateWindowSerializer.CATEGORY @@ -128,7 +216,7 @@ def test_permissions(self): # Superusers can view all catalogs self.client.force_authenticate(superuser) - with self.assertNumQueries(5): + with self.assertNumQueries(6, threshold=1): # travis is often 7 response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -138,7 +226,7 @@ def test_permissions(self): self.assertEqual(response.status_code, 403) catalog.viewers = [self.user] - with self.assertNumQueries(8): + with self.assertNumQueries(9, threshold=1): # travis is often 10 response = self.client.get(url) self.assertEqual(response.status_code, 200) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_catalog_queries.py b/course_discovery/apps/api/v1/tests/test_views/test_catalog_queries.py index a954761bc3..de53693455 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_catalog_queries.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_catalog_queries.py @@ -9,7 +9,7 @@ class CatalogQueryViewSetTests(APITestCase): def setUp(self): - super(CatalogQueryViewSetTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) self.client.force_authenticate(self.user) self.course = CourseFactory(partner=self.partner, key='simple_key') @@ -24,7 +24,7 @@ def test_contains_single_course_run(self): 'course_run_ids': self.course_run.key, 'course_uuids': self.course.uuid, }) - url = '{}/?{}'.format(self.url_base, qs) + url = f'{self.url_base}/?{qs}' response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -42,7 +42,7 @@ def test_contains_single_course(self): 'course_run_ids': self.course_run.key, 'course_uuids': self.course.uuid, }) - url = '{}/?{}'.format(self.url_base, qs) + url = f'{self.url_base}/?{qs}' response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -62,7 +62,7 @@ def test_contains_course_and_run(self): 'course_run_ids': self.course_run.key, 'course_uuids': self.course.uuid, }) - url = '{}/?{}'.format(self.url_base, qs) + url = f'{self.url_base}/?{qs}' response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -78,7 +78,7 @@ def test_no_identifiers(self): qs = urllib.parse.urlencode({ 'query': 'id:*' }) - url = '{}/?{}'.format(self.url_base, qs) + url = f'{self.url_base}/?{qs}' response = self.client.get(url) self.assertEqual(response.status_code, 400) self.assertEqual(response.data, self.error_message) @@ -89,7 +89,7 @@ def test_no_query(self): 'course_run_ids': self.course_run.key, 'course_uuids': self.course.uuid, }) - url = '{}/?{}'.format(self.url_base, qs) + url = f'{self.url_base}/?{qs}' response = self.client.get(url) self.assertEqual(response.status_code, 400) self.assertEqual(response.data, self.error_message) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py b/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py index b574607437..0c155392a0 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_catalogs.py @@ -1,4 +1,3 @@ -# pylint: disable=redefined-builtin,no-member import csv import datetime import urllib @@ -7,19 +6,17 @@ import ddt import pytest import pytz -import responses from django.contrib.auth import get_user_model -from django.core.cache import cache from rest_framework.reverse import reverse from course_discovery.apps.api.tests.jwt_utils import generate_jwt_header_for_user -from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, FuzzyInt, OAuth2Mixin, SerializationMixin +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, OAuth2Mixin, SerializationMixin from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.catalogs.tests.factories import CatalogFactory from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.course_metadata.models import Course -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, SeatFactory +from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, SeatFactory, SeatTypeFactory from course_discovery.conftest import get_course_run_states User = get_user_model() @@ -34,7 +31,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi catalog_list_url = reverse('api:v1:catalog-list') def setUp(self): - super(CatalogViewSetTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) self.request.user = self.user self.client.force_authenticate(self.user) @@ -48,7 +45,6 @@ def setUp(self): ) self.course = self.course_run.course self.refresh_index() - cache.clear() def assert_catalog_created(self, **headers): name = 'The Kitchen Sink' @@ -86,7 +82,7 @@ def assert_catalog_contains_query_string(self, query_string_kwargs, course_key): def grant_catalog_permission_to_user(self, user, action, catalog=None): """ Grant the user access to view `self.catalog`. """ catalog = catalog or self.catalog - perm = '{action}_catalog'.format(action=action) + perm = f'{action}_catalog' user.add_obj_perm(perm, catalog) self.assertTrue(user.has_perm('catalogs.' + perm, catalog)) @@ -96,7 +92,7 @@ def test_create_without_authentication(self): Catalog.objects.all().delete() response = self.client.post(self.catalog_list_url, {}, format='json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) self.assertEqual(Catalog.objects.count(), 0) @ddt.data('put', 'patch', 'delete') @@ -106,7 +102,7 @@ def test_modify_without_authentication(self, http_method): url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id}) response = getattr(self.client, http_method)(url, {}, format='json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) def test_create_with_session_authentication(self): """ Verify the endpoint creates a new catalog when the client is authenticated via session authentication. """ @@ -117,12 +113,6 @@ def test_create_with_jwt_authentication(self): self.client.logout() self.assert_catalog_created(HTTP_AUTHORIZATION=generate_jwt_header_for_user(self.user)) - @responses.activate - def test_create_with_oauth2_authentication(self): - self.client.logout() - self.mock_user_info_response(self.user) - self.assert_catalog_created(HTTP_AUTHORIZATION=self.generate_oauth2_token_header(self.user)) - def test_create_with_new_user(self): """ Verify that new users are created if the list of viewers includes the usernames of non-existent users. """ new_viewer_username = 'new-guy' @@ -181,7 +171,7 @@ def test_courses(self, state): # to be included. filtered_course_run = CourseRunFactory(course=course) - with self.assertNumQueries(FuzzyInt(23, 2)): + with self.assertNumQueries(31, threshold=3): response = self.client.get(url) assert response.status_code == 200 @@ -192,7 +182,7 @@ def test_courses(self, state): assert response.data['results'] == self.serialize_catalog_course([course], many=True) # Any course appearing in the response must have at least one serialized run. - assert len(response.data['results'][0]['course_runs']) > 0 + assert response.data['results'][0]['course_runs'] else: response = self.client.get(url) @@ -253,66 +243,68 @@ def test_contains_for_course_run_key(self): self.assert_catalog_contains_query_string(query_string_kwargs, course_run_key) def test_csv(self): - SeatFactory(type='audit', course_run=self.course_run) - SeatFactory(type='verified', course_run=self.course_run) - SeatFactory(type='credit', course_run=self.course_run, credit_provider='ASU', credit_hours=9) - SeatFactory(type='credit', course_run=self.course_run, credit_provider='Hogwarts', credit_hours=4) + SeatFactory(type=SeatTypeFactory.audit(), course_run=self.course_run) + SeatFactory(type=SeatTypeFactory.verified(), course_run=self.course_run) + SeatFactory(type=SeatTypeFactory.credit(), course_run=self.course_run, credit_provider='ASU', credit_hours=9) + SeatFactory(type=SeatTypeFactory.credit(), course_run=self.course_run, credit_provider='Hogwarts', + credit_hours=4) url = reverse('api:v1:catalog-csv', kwargs={'id': self.catalog.id}) - with self.assertNumQueries(20): + with self.assertNumQueries(23): response = self.client.get(url) course_run = self.serialize_catalog_flat_course_run(self.course_run) expected = [ - course_run['key'], - course_run['title'], - course_run['pacing_type'], - course_run['start'], + course_run['announcement'], + course_run['content_language'], + course_run['course_key'], course_run['end'], - course_run['enrollment_start'], course_run['enrollment_end'], - course_run['announcement'], + course_run['enrollment_start'], + course_run['expected_learning_items'], course_run['full_description'], - course_run['short_description'], - course_run['marketing_url'], + '', # image description + '', # image height course_run['image']['src'], - '', - '', - '', - course_run['video']['src'], - course_run['video']['description'], - course_run['video']['image']['src'], - course_run['video']['image']['description'], - str(course_run['video']['image']['height']), - str(course_run['video']['image']['width']), - course_run['content_language'], + '', # image width + course_run['key'], str(course_run['level_type']), + course_run['marketing_url'], str(course_run['max_effort']), str(course_run['min_effort']), - course_run['subjects'], - course_run['expected_learning_items'], - course_run['prerequisites'], + course_run['modified'], course_run['owners'], - course_run['sponsors'], + course_run['pacing_type'], + course_run['prerequisites'], course_run['seats']['audit']['type'], + '{}'.format(course_run['seats']['credit']['credit_hours']), + '{}'.format(course_run['seats']['credit']['credit_provider']), + '{}'.format(course_run['seats']['credit']['currency']), + '{}'.format(str(course_run['seats']['credit']['price'])), + '{}'.format(course_run['seats']['credit']['type']), + '{}'.format(course_run['seats']['credit']['upgrade_deadline']), course_run['seats']['honor']['type'], - course_run['seats']['professional']['type'], - str(course_run['seats']['professional']['price']), + course_run['seats']['masters']['type'], course_run['seats']['professional']['currency'], + str(course_run['seats']['professional']['price']), + course_run['seats']['professional']['type'], course_run['seats']['professional']['upgrade_deadline'], - course_run['seats']['verified']['type'], - str(course_run['seats']['verified']['price']), course_run['seats']['verified']['currency'], + str(course_run['seats']['verified']['price']), + course_run['seats']['verified']['type'], course_run['seats']['verified']['upgrade_deadline'], - '{}'.format(course_run['seats']['credit']['type']), - '{}'.format(str(course_run['seats']['credit']['price'])), - '{}'.format(course_run['seats']['credit']['currency']), - '{}'.format(course_run['seats']['credit']['upgrade_deadline']), - '{}'.format(course_run['seats']['credit']['credit_provider']), - '{}'.format(course_run['seats']['credit']['credit_hours']), - course_run['modified'], - course_run['course_key'], + course_run['short_description'], + course_run['sponsors'], + course_run['start'], + course_run['subjects'], + course_run['title'], + course_run['video']['description'], + course_run['video']['image']['description'], + str(course_run['video']['image']['height']), + course_run['video']['image']['src'], + str(course_run['video']['image']['width']), + course_run['video']['src'], ] # collect streamed content @@ -326,7 +318,7 @@ def test_csv(self): content = list(reader) self.assertEqual(response.status_code, 200) - self.assertEqual(set(expected), set(content[1])) + self.assertEqual(expected, content[1]) def test_get(self): """ Verify the endpoint returns the details for a single catalog. """ @@ -454,7 +446,7 @@ def test_username_filter_as_staff_user(self): user = UserFactory(is_staff=False, is_superuser=False) catalog = CatalogFactory() - path = '{root}?username={username}'.format(root=self.catalog_list_url, username=user.username) + path = f'{self.catalog_list_url}?username={user.username}' response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertListEqual(response.data['results'], []) @@ -468,8 +460,8 @@ def test_username_filter_as_staff_user(self): def test_username_filter_as_staff_user_with_invalid_username(self): """ Verify HTTP 404 is returned if the given username does not correspond to an actual user. """ username = 'jack' - path = '{root}?username={username}'.format(root=self.catalog_list_url, username=username) + path = f'{self.catalog_list_url}?username={username}' response = self.client.get(path) self.assertEqual(response.status_code, 404) - expected = {'detail': 'No user with the username [{username}] exists.'.format(username=username)} + expected = {'detail': f'No user with the username [{username}] exists.'} self.assertDictEqual(response.data, expected) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_collaborators.py b/course_discovery/apps/api/v1/tests/test_views/test_collaborators.py new file mode 100644 index 0000000000..588fdec53b --- /dev/null +++ b/course_discovery/apps/api/v1/tests/test_views/test_collaborators.py @@ -0,0 +1,67 @@ +from rest_framework.reverse import reverse + +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, OAuth2Mixin, SerializationMixin +from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory +from course_discovery.apps.course_metadata.tests.factories import CollaboratorFactory + + +class CollaboratorViewSetTests(OAuth2Mixin, SerializationMixin, APITestCase): + """ Tests for the collaborator resource. """ + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True) + self.request.user = self.user + self.client.login(username=self.user.username, password=USER_PASSWORD) + self.name = 'Test User 1' + self.collaborator = CollaboratorFactory(name=self.name) + + def tearDown(self): + super().tearDown() + self.client.logout() + + def test_get(self): + url = reverse('api:v1:collaborator-list') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_add(self): + self.mock_access_token() + url = reverse('api:v1:collaborator-list') + data = { + 'name': 'Collaborator 1', + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 201) + + def test_add_fails_when_no_image(self): + self.mock_access_token() + url = reverse('api:v1:collaborator-list') + data = { + 'name': 'Collaborator 1', + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 400) + + def test_modify(self): + self.mock_access_token() + url = reverse('api:v1:collaborator-list') + data = { + 'name': 'Collaborator 1', + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 201) + collab = response.json() + patch_url = reverse('api:v1:collaborator-detail', kwargs={'uuid': collab['uuid']}) + data = { + 'uuid': collab['uuid'], + 'name': 'Collaborator 2' + } + response2 = self.client.patch(patch_url, data, format='json') + modified_collab = response2.json() + self.assertEqual(modified_collab['name'], 'Collaborator 2') diff --git a/course_discovery/apps/api/v1/tests/test_views/test_comments.py b/course_discovery/apps/api/v1/tests/test_views/test_comments.py new file mode 100644 index 0000000000..2ff74e8afb --- /dev/null +++ b/course_discovery/apps/api/v1/tests/test_views/test_comments.py @@ -0,0 +1,224 @@ +import datetime +from unittest import mock + +import factory +from django.db.models.signals import m2m_changed, post_save +from rest_framework.reverse import reverse + +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, OAuth2Mixin +from course_discovery.apps.core.tests.factories import USER_PASSWORD, SalesforceConfigurationFactory, UserFactory +from course_discovery.apps.course_metadata.salesforce import SalesforceMissingCaseException, SalesforceUtil +from course_discovery.apps.course_metadata.tests.factories import CourseFactoryNoSignals, OrganizationFactoryNoSignals + + +class CommentViewSetTests(OAuth2Mixin, APITestCase): + + @factory.django.mute_signals(m2m_changed) + def setUp(self): + super().setUp() + self.salesforce_config = SalesforceConfigurationFactory(partner=self.partner) + self.user = UserFactory(is_staff=True) + self.request.user = self.user + self.request.site.partner = self.partner + self.client.login(username=self.user.username, password=USER_PASSWORD) + self.course = CourseFactoryNoSignals(partner=self.partner, title='Fake Test', key='edX+Fake101', draft=True) + self.org = OrganizationFactoryNoSignals(key='edX', partner=self.partner) + self.course.authoring_organizations.add(self.org) + + def tearDown(self): + super().tearDown() + # Zero out the instances that are created during testing + SalesforceUtil.instances = {} + + def test_list_no_salesforce_case_id_set(self): + user_orgs_path = 'course_discovery.apps.course_metadata.models.Organization.user_organizations' + + with mock.patch('course_discovery.apps.course_metadata.salesforce.Salesforce'): + with mock.patch(user_orgs_path, return_value=[self.org]): + url = '{}?course_uuid={}'.format(reverse('api:v1:comment-list'), self.course.uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + + def test_list_salesforce_case_id_set(self): + self.course.salesforce_id = 'TestSalesforceId' + with factory.django.mute_signals(post_save): + self.course.save() + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + get_comments_path = 'course_discovery.apps.api.v1.views.comments.SalesforceUtil.get_comments_for_course' + user_orgs_path = 'course_discovery.apps.course_metadata.models.Organization.user_organizations' + return_value = [ + { + 'user': { + 'first_name': 'TestFirst', + 'last_name': 'TestLast', + 'email': 'test@test.com', + 'username': 'test', + }, + 'course_run_key': None, + 'created': '2000-01-01T00:00:00.000+0000', + 'comment': 'This is a test comment', + } + ] + with mock.patch(salesforce_path): + with mock.patch(user_orgs_path, return_value=[self.org]): + with mock.patch(get_comments_path, return_value=return_value) as mock_get_comments: + url = '{}?course_uuid={}'.format(reverse('api:v1:comment-list'), self.course.uuid) + response = self.client.get(url) + mock_get_comments.assert_called_with(self.course) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, return_value) + + def test_list_400s_without_course_uuid(self): + with mock.patch('course_discovery.apps.course_metadata.salesforce.Salesforce'): + url = reverse('api:v1:comment-list') + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + + def test_list_404s_without_finding_course(self): + fake_uuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' # Needs to resemble a uuid to pass validation + with mock.patch('course_discovery.apps.course_metadata.salesforce.Salesforce'): + url = '{}?course_uuid={}'.format(reverse('api:v1:comment-list'), fake_uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_list_403s_without_permissions(self): + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + user_orgs_path = 'course_discovery.apps.course_metadata.models.Organization.user_organizations' + self.user.is_staff = False + self.user.save() + + with mock.patch(salesforce_path): + with mock.patch(user_orgs_path, return_value=[]): + url = '{}?course_uuid={}'.format(reverse('api:v1:comment-list'), self.course.uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_list_200s_as_staff(self): + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + user_orgs_path = 'course_discovery.apps.course_metadata.models.Organization.user_organizations' + + with mock.patch(salesforce_path): + with mock.patch(user_orgs_path, return_value=[]): + url = '{}?course_uuid={}'.format(reverse('api:v1:comment-list'), self.course.uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_create(self): + body = { + 'course_uuid': self.course.uuid, + 'comment': 'Test comment', + 'course_run_key': 'test-key', + } + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + create_comment_path = ('course_discovery.apps.api.v1.views.comments.' + 'SalesforceUtil.create_comment_for_course_case') + + with mock.patch(salesforce_path): + with mock.patch(create_comment_path, return_value={ + 'user': { + 'username': self.user.username, + 'email': self.user.email, + 'first_name': self.user.first_name, + 'last_name': self.user.last_name, + }, + 'comment': 'Comment body', + 'created': datetime.datetime.now(datetime.timezone.utc).isoformat(), + }) as mock_create_comment: + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + mock_create_comment.assert_called_with( + self.course, + self.request.user, + body.get('comment'), + course_run_key=body.get('course_run_key'), + ) + self.assertEqual(response.status_code, 201) + + def test_create_400s_without_data(self): + body = {} + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + + with mock.patch(salesforce_path): + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + self.assertEqual(response.status_code, 400) + + def test_create_403s_without_permissions(self): + body = { + 'course_uuid': self.course.uuid, + 'comment': 'Test comment', + 'course_run_key': 'test-key', + } + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + is_editable_path = 'course_discovery.apps.api.v1.views.comments.CourseEditor.is_course_editable' + + with mock.patch(salesforce_path): + with mock.patch(is_editable_path, return_value=False): + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + self.assertEqual(response.status_code, 403) + + def test_create_404s_without_finding_course(self): + body = { + 'course_uuid': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', # Needs to resemble a uuid to pass validation + 'comment': 'Test comment', + 'course_run_key': 'test-key', + } + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + + with mock.patch(salesforce_path): + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + self.assertEqual(response.status_code, 404) + + def test_create_404s_without_a_config(self): + body = { + 'course_uuid': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', # Needs to resemble a uuid to pass validation + 'comment': 'Test comment', + 'course_run_key': 'test-key', + } + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + + with mock.patch(salesforce_path): + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + self.assertEqual(response.status_code, 404) + + def test_create_500s_without_a_successful_case_create(self): + body = { + 'course_uuid': self.course.uuid, + 'comment': 'Test comment', + 'course_run_key': 'test-key', + } + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + create_comment_path = ('course_discovery.apps.api.v1.views.comments.' + 'SalesforceUtil.create_comment_for_course_case') + + with mock.patch(salesforce_path): + with mock.patch(create_comment_path, side_effect=SalesforceMissingCaseException('Error')): + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + self.assertEqual(response.status_code, 500) + + def test_list_404s_without_a_config(self): + self.salesforce_config.delete() + body = { + 'course_uuid': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', # Needs to resemble a uuid to pass validation + 'comment': 'Test comment', + 'course_run_key': 'test-key', + } + + salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + + with mock.patch(salesforce_path): + url = reverse('api:v1:comment-list') + response = self.client.post(url, body, format='json') + self.assertEqual(response.status_code, 404) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_course_editors.py b/course_discovery/apps/api/v1/tests/test_views/test_course_editors.py new file mode 100644 index 0000000000..e4b680e05b --- /dev/null +++ b/course_discovery/apps/api/v1/tests/test_views/test_course_editors.py @@ -0,0 +1,118 @@ +import ddt +from django.urls import reverse + +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin +from course_discovery.apps.core.models import Partner +from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory +from course_discovery.apps.course_metadata.models import CourseEditor +from course_discovery.apps.course_metadata.tests.factories import CourseEditorFactory, CourseFactory +from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory + + +@ddt.ddt +class CourseEditorsViewSetTests(SerializationMixin, APITestCase): + list_path = reverse('api:v1:course_editor-list') + + def setUp(self): + super().setUp() + self.staff_user = UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=self.staff_user.username, password=USER_PASSWORD) + self.user = UserFactory() + partner = Partner.objects.first() + self.course = CourseFactory(draft=True, partner=partner) + self.org_ext = OrganizationExtensionFactory() + self.course.authoring_organizations.add(self.org_ext.organization) + + def test_list(self): + """Verify GET endpoint returns list of editors""" + CourseEditorFactory() + response = self.client.get(self.list_path) + + assert len(response.data['results']) == 1 + + # Test for non staff user + self.client.login(username=self.user.username, password=USER_PASSWORD) + response = self.client.get(self.list_path) + + self.assertFalse(response.data['results']) + + def test_course_query_param(self): + """Verify GET endpoint with course query param returns editors relative to that course""" + CourseEditorFactory(course=self.course) + CourseEditorFactory() + + response = self.client.get(self.list_path) + + assert len(response.data['results']) == 2 + + response = self.client.get(self.list_path, {'course': self.course.uuid}) + + assert len(response.data['results']) == 1 + assert response.data['results'][0]['course'] == self.course.uuid + + @ddt.data( + (True, True), # Staff User on Draft Course + (True, False), # Staff User on Official Course + (False, True), # Non-staff User on Draft Course + (False, False), # Non-staff User on Official Course + ) + @ddt.unpack + def test_create_for_self_and_draft_course(self, is_staff, is_draft): + """Verify can make self an editor. Test cases: as staff and non-staff, on official and draft course""" + + self.user.is_staff = is_staff + self.user.save() + partner = Partner.objects.first() + course = CourseFactory(draft=is_draft, partner=partner) + self.user.groups.add(self.org_ext.group) + course.authoring_organizations.add(self.org_ext.organization) + + self.client.login(username=self.user.username, password=USER_PASSWORD) + self.client.post(self.list_path, {'course': course.uuid}, format='json') + course_editor = CourseEditor.objects.first() + + assert course_editor.course == course + assert course_editor.user == self.user + + def test_create_for_self_as_non_staff_with_invalid_course(self): + """Verify non staff user cannot make them self an editor of a course they dont belong to""" + + self.client.login(username=self.user.username, password=USER_PASSWORD) + + response = self.client.post(self.list_path, {'course': self.course.uuid}, format='json') + + assert response.status_code == 403 + + def test_create_for_other_user_as_staff(self): + """Verify staff user can make another user an editor""" + + self.user.groups.add(self.org_ext.group) + self.client.post(self.list_path, {'course': self.course.uuid, 'user_id': self.user.id}, format='json') + course_editor = CourseEditor.objects.first() + + assert course_editor.course == self.course + assert course_editor.user == self.user + + def test_create_for_other_user_as_non_staff(self): + """Verify non staff can make another user an editor""" + + user2 = UserFactory() + + self.user.groups.add(self.org_ext.group) + user2.groups.add(self.org_ext.group) + + self.client.login(username=self.user.username, password=USER_PASSWORD) + self.client.post(self.list_path, {'course': self.course.uuid, 'user_id': user2.id}, format='json') + course_editor = CourseEditor.objects.first() + + assert course_editor.course == self.course + assert course_editor.user == user2 + + def test_create_for_invalid_other_user(self): + """Verify a user can't be made an editor of a course if both are not under the same organization""" + + response = self.client.post( + self.list_path, {'course': self.course.uuid, 'user_id': self.user.id}, format='json' + ) + + assert response.status_code == 403 diff --git a/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py b/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py index f7a9178836..8c28249b96 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_course_runs.py @@ -1,32 +1,69 @@ -# pylint: disable=no-member import datetime import urllib +from unittest import mock import ddt +import pytest import pytz +import responses +from django.contrib.auth.models import Group from django.db.models.functions import Lower from rest_framework.reverse import reverse from rest_framework.test import APIRequestFactory -from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin +from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, OAuth2Mixin, SerializationMixin from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin -from course_discovery.apps.course_metadata.choices import ProgramStatus -from course_discovery.apps.course_metadata.models import CourseRun -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory, SeatFactory +from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus +from course_discovery.apps.course_metadata.models import CourseRun, CourseRunType, Seat, SeatType +from course_discovery.apps.course_metadata.tests.factories import ( + CourseEditorFactory, CourseFactory, CourseRunFactory, CourseRunTypeFactory, CourseTypeFactory, OrganizationFactory, + PersonFactory, ProgramFactory, SeatFactory, TrackFactory +) +from course_discovery.apps.ietf_language_tags.models import LanguageTag +from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory @ddt.ddt -class CourseRunViewSetTests(SerializationMixin, ElasticsearchTestMixin, APITestCase): +class CourseRunViewSetTests(SerializationMixin, ElasticsearchTestMixin, OAuth2Mixin, APITestCase): def setUp(self): - super(CourseRunViewSetTests, self).setUp() - self.user = UserFactory(is_staff=True, is_superuser=True) + super().setUp() + self.user = UserFactory(is_staff=True) self.client.force_authenticate(self.user) self.course_run = CourseRunFactory(course__partner=self.partner) - self.course_run_2 = CourseRunFactory(course__partner=self.partner) + self.course_run_2 = CourseRunFactory(course__key='Test+Course', course__partner=self.partner) + self.draft_course = CourseFactory(partner=self.partner, draft=True) + self.draft_course_run = CourseRunFactory(course=self.draft_course, draft=True) + self.draft_course_run.course.authoring_organizations.add(OrganizationFactory(key='course-id')) + self.course_run_type = CourseRunTypeFactory(tracks=[TrackFactory()]) + self.verified_type = CourseRunType.objects.get(slug=CourseRunType.VERIFIED_AUDIT) self.refresh_index() self.request = APIRequestFactory().get('/') self.request.user = self.user + self.partner.lms_url = 'http://127.0.0.1:8000' + self.partner.save() + + def mock_patch_to_studio(self, key, access_token=True, status=200, body=None): + if access_token: + self.mock_access_token() + studio_url = '{root}/api/v1/course_runs/{key}/'.format(root=self.partner.studio_url.strip('/'), key=key) + responses.add(responses.PATCH, studio_url, status=status, body=body) + responses.add(responses.POST, f'{studio_url}images/', status=status, body=body) + + def mock_post_to_studio(self, key, access_token=True, rerun_key=None): + if access_token: + self.mock_access_token() + studio_url = '{root}/api/v1/course_runs/'.format(root=self.partner.studio_url.strip('/')) + if rerun_key: + responses.add(responses.POST, f'{studio_url}{rerun_key}/rerun/', status=200) + else: + responses.add(responses.POST, studio_url, status=200) + responses.add(responses.POST, f'{studio_url}{key}/images/', status=200) + + def mock_ecommerce_publication(self): + url = f'{self.partner.ecommerce_api_url}publication/' + responses.add(responses.POST, url, json={}, status=200) def test_get(self): """ Verify the endpoint returns the details for a single course. """ @@ -92,9 +129,353 @@ def test_get_include_unpublished_programs(self): assert response.data == \ self.serialize_course_run(self.course_run, extra_context={'include_unpublished_programs': True}) + @responses.activate + def test_create_minimum(self): + """ Verify the endpoint supports creating a course_run with the least info. """ + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + + # Send nothing - expect complaints + response = self.client.post(url, {}, format='json') + self.assertEqual(response.status_code, 400) + self.assertDictEqual(response.data, { + 'course': ['This field is required.'], + }) + + # Send minimum requested + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + }, format='json') + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=new_key) + self.assertDictEqual(response.data, self.serialize_course_run(new_course_run)) + self.assertEqual(new_course_run.pacing_type, 'instructor_paced') # default we provide + self.assertEqual(str(new_course_run.end), '2001-01-01 00:00:00+00:00') # spot check that input made it + self.assertTrue(new_course_run.draft) + + new_seat = Seat.everything.get(course_run=new_course_run) + self.assertEqual(new_seat.type, self.course_run_type.tracks.first().seat_type) + self.assertEqual(new_seat.price, 0.00) + self.assertTrue(new_seat.draft) + + @responses.activate + def test_create_without_course_key_for_reruns(self): + """ Verify the endpoint supports creating a course_run without a specified course key_for_reruns. """ + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + + data = { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + } + + self.assertNotEqual(course.key, course.key_for_reruns) # sanity check + + # Try first with a boring old key_for_reruns + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data['key'], new_key) + CourseRun.everything.get(key=response.data['key']) + + # Now try without a key_for_reruns set + course.key_for_reruns = '' + course.save() + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data['key'], 'course-v1:{}+1T2000'.format(course.key.replace('/', '+'))) + CourseRun.everything.get(key=response.data['key']) + + @ddt.data(True, False) + @responses.activate + def test_create_sets_canonical_course_run(self, has_canonical_run): + """ Verify the endpoint supports setting an empty canonical course run. """ + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + url = reverse('api:v1:course_run-list') + + self.assertIsNone(course.canonical_course_run) # sanity check + if has_canonical_run: + self.mock_post_to_studio(new_key, rerun_key=self.draft_course_run.key) + course.canonical_course_run = self.draft_course_run + course.save() + rerun = self.draft_course_run.key + else: + self.mock_post_to_studio(new_key) + rerun = None + + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + 'rerun': rerun, + }, format='json') + + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=new_key) + + course.refresh_from_db() + if has_canonical_run: + # Shouldn't change existing canonical course run + self.assertEqual(course.canonical_course_run, self.draft_course_run) + else: + self.assertEqual(course.canonical_course_run, new_course_run) + + @responses.activate + def test_create_sets_additional_fields(self): + """ Verify that instructors, languages, min & max effort, and weeks to complete are set on a rerun. """ + self.draft_course_run.staff.add(PersonFactory()) + self.draft_course_run.transcript_languages.add(self.draft_course_run.language) + self.draft_course_run.save() + + # Create rerun based on draft course + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + url = reverse('api:v1:course_run-list') + + self.mock_post_to_studio(new_key, rerun_key=self.draft_course_run.key) + + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + 'rerun': self.draft_course_run.key, + }, format='json') + + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=new_key, draft=True) + + self.assertEqual(new_course_run.max_effort, self.draft_course_run.max_effort) + self.assertEqual(new_course_run.min_effort, self.draft_course_run.min_effort) + self.assertEqual(new_course_run.weeks_to_complete, self.draft_course_run.weeks_to_complete) + self.assertEqual(list(new_course_run.staff.all()), list(self.draft_course_run.staff.all())) + self.assertEqual(new_course_run.language, self.draft_course_run.language) + self.assertEqual(list(new_course_run.transcript_languages.all()), + list(self.draft_course_run.transcript_languages.all())) + + @ddt.data(True, False, "bogus") + @responses.activate + def test_create_draft_ignored(self, draft): + """ Verify the endpoint supports creating a course_run, but always as a draft. """ + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + + # Send minimum + draft: True/False/bogus + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + 'draft': draft, + }, format='json') + + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=new_key) + self.assertDictEqual(response.data, self.serialize_course_run(new_course_run)) + self.assertTrue(new_course_run.draft) + + @responses.activate + def test_create_using_type_with_price(self): + """ Verify the endpoint supports creating a course_run and sets the seats price to the given price """ + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + 'prices': {self.course_run_type.tracks.first().seat_type.slug: 77.32}, + }, format='json') + + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=new_key) + self.assertDictEqual(response.data, self.serialize_course_run(new_course_run)) + self.assertTrue(new_course_run.draft) + + new_seat = Seat.everything.get(course_run=new_course_run) + self.assertEqual(new_seat.type, self.course_run_type.tracks.first().seat_type) + self.assertEqual(float(new_seat.price), 77.32) + self.assertTrue(new_seat.draft) + + @responses.activate + def test_create_using_type_with_no_track_seat_types(self): + """ + Verify the endpoint supports creating a course_run with no seats + There will be no seats if the run_type has only Tracks with no seat types defined + """ + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + run_type = CourseRunTypeFactory(tracks=[TrackFactory(seat_type=None)]) + + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(run_type.uuid), + }, format='json') + + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=new_key) + self.assertDictEqual(response.data, self.serialize_course_run(new_course_run)) + self.assertTrue(new_course_run.draft) + + self.assertEqual(Seat.everything.filter(course_run=new_course_run).count(), 0) + + @responses.activate + def test_create_with_term(self): + """ Verify the endpoint supports creating a course_run when specifying a key (if allowed). """ + course = self.draft_course_run.course + date_key = f'course-v1:{course.key_for_reruns}+1T2000' + desired_term = 'HowdyDoing' + url = reverse('api:v1:course_run-list') + + data = { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + 'term': desired_term, + } + + # If org doesn't specifically allow it, incoming key is ignored + self.mock_post_to_studio(date_key) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=date_key) + self.assertDictEqual(response.data, self.serialize_course_run(new_course_run)) + + # Turn on this feature for this org, notice that we can now specify the course key we want + org_ext = OrganizationExtensionFactory(organization=course.authoring_organizations.first()) + org_ext.organization.auto_generate_course_run_keys = False + org_ext.organization.save() + self.mock_post_to_studio(desired_term, access_token=False, rerun_key=new_course_run.key) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 201) + new_course_run = CourseRun.everything.get(key=f'course-v1:{course.key_for_reruns}+{desired_term}') + self.assertDictEqual(response.data, self.serialize_course_run(new_course_run)) + + def test_create_if_in_org(self): + """ Verify the endpoint supports creating a course_run with organization permissions. """ + url = reverse('api:v1:course_run-list') + course = self.draft_course_run.course + data = {'course': course.key} + + self.user.is_staff = False + self.user.save() + + # Not in org, not allowed to POST + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 403) + + # Add to org + org_ext = OrganizationExtensionFactory(organization=course.authoring_organizations.first()) + self.user.groups.add(org_ext.group) + + # now allowed to POST + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 400) # missing start, but at least we got that far + + def test_create_fails_with_all_missing_fields(self): + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + + # Send nothing - expect missing course complaint + response = self.client.post(url, {}, format='json') + self.assertEqual(response.status_code, 400) + self.assertDictEqual(response.data, { + 'course': ['This field is required.'], + }) + + def test_create_fails_with_partial_missing_fields(self): + course = self.draft_course_run.course + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + self.mock_post_to_studio(new_key) + url = reverse('api:v1:course_run-list') + + data = { + 'course': course.key, + } + + # Send just course key - expect complaints + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, 400) + self.assertDictEqual(response.data, { + 'start': ['This field is required.'], + 'end': ['This field is required.'], + 'run_type': ['This field is required.'], + }) + + @responses.activate + def test_create_succeeds_with_failed_image_update_to_studio(self): + course = self.draft_course_run.course + course.canonical_course_run = self.draft_course_run + course.save() + new_key = f'course-v1:{course.key_for_reruns}+1T2000' + url = reverse('api:v1:course_run-list') + + self.mock_access_token() + studio_url = '{root}/api/v1/course_runs/'.format(root=self.partner.studio_url.strip('/')) + responses.add(responses.POST, f'{studio_url}{self.draft_course_run.key}/rerun/') + responses.add(responses.POST, f'{studio_url}{new_key}/images/', status=400) + + with mock.patch('course_discovery.apps.api.utils.logger.exception') as mock_logger: + response = self.client.post(url, { + 'course': course.key, + 'start': '2000-01-01T00:00:00Z', + 'end': '2001-01-01T00:00:00Z', + 'run_type': str(self.course_run_type.uuid), + 'rerun': self.draft_course_run.key, + }, format='json') + self.assertEqual(response.status_code, 201) + + self.assertEqual(mock_logger.call_count, 1) + self.assertEqual(mock_logger.call_args_list[0], mock.call( + 'An error occurred while setting the course run image for [{key}] in studio. All other fields ' + 'were successfully saved in Studio.'.format(key=new_key) + )) + + @responses.activate + def test_update_operates_on_drafts(self): + self.assertFalse(CourseRun.everything.filter(key=self.course_run.key, draft=True).exists()) # sanity check + self.mock_patch_to_studio(self.course_run.key) + expected_original_max_effort = self.course_run.max_effort + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.course_run.key}) + response = self.client.patch(url, {'max_effort': 777}, format='json') + self.assertEqual(response.status_code, 200) + + course_run = CourseRun.everything.get(key=self.course_run.key, draft=True) + self.assertEqual(course_run.max_effort, 777) + + self.course_run.refresh_from_db() + self.assertFalse(self.course_run.draft) + self.assertEqual(self.course_run.max_effort, expected_original_max_effort) + + @responses.activate def test_partial_update(self): """ Verify the endpoint supports partially updating a course_run's fields, provided user has permission. """ - url = reverse('api:v1:course_run-detail', kwargs={'key': self.course_run.key}) + self.mock_patch_to_studio(self.draft_course_run.key) + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) expected_min_effort = 867 expected_max_effort = 5309 @@ -108,20 +489,494 @@ def test_partial_update(self): assert response.status_code == 200 # refresh and make sure we have the new effort levels - self.course_run.refresh_from_db() + self.draft_course_run.refresh_from_db() + + assert self.draft_course_run.max_effort == expected_max_effort + assert self.draft_course_run.min_effort == expected_min_effort + + def test_partial_update_no_studio_url(self): + """ Verify we skip pushing when no studio url is set. """ + orignal_partner_studio_url = self.partner.studio_url + self.partner.studio_url = None + self.partner.save() + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + + with mock.patch('course_discovery.apps.api.v1.views.course_runs.log.info') as mock_logger: + # Just pick any date that will be ahead of the ones in the Factory + response = self.client.patch(url, {'start': '2019-01-01T00:00:00Z'}, format='json') + + self.assertEqual(response.status_code, 200, f"Status {response.status_code}: {response.content}") + + self.assertIn(mock.call( + 'Not pushing course run info for %s to Studio as partner %s has no studio_url set.', + self.draft_course_run.key, + self.partner.short_code, + ), mock_logger.call_args_list) + + self.assertIn(mock.call( + 'Not updating course run image for %s to Studio as partner %s has no studio_url set.', + self.draft_course_run.key, + self.partner.short_code, + ), mock_logger.call_args_list) - assert self.course_run.max_effort == expected_max_effort - assert self.course_run.min_effort == expected_min_effort + # reset the shared self.partner attribute + self.partner.studio_url = orignal_partner_studio_url + self.partner.save() def test_partial_update_bad_permission(self): """ Verify partially updating will fail if user doesn't have permission. """ user = UserFactory(is_staff=False, is_superuser=False) self.client.force_authenticate(user) - url = reverse('api:v1:course_run-detail', kwargs={'key': self.course_run.key}) + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.patch(url, {}, format='json') + assert response.status_code == 403 + + @ddt.data( + ( + {'start': '2010-01-01T00:00:00Z', 'end': '2000-01-01T00:00:00Z'}, + 'Start date cannot be after the End date', + ), + ( + {'term': 'BlargHello'}, + 'Term cannot be changed', + ), + ( + {'course': 'Test+Course'}, + 'Course cannot be changed', + ), + ( + {'min_effort': 10000}, + 'Minimum effort cannot be greater than Maximum effort', + ), + ( + {'min_effort': 10000, 'max_effort': 10000}, + 'Minimum effort and Maximum effort cannot be the same', + ), + ) + @ddt.unpack + def test_partial_update_common_errors(self, data, error): + """ Verify partially updating will fail depending on various validation checks. """ + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.patch(url, data, format='json') + self.assertContains(response, error, status_code=400) + + def test_partial_update_staff(self): + """ Verify partially updating allows staff updates. """ + self.mock_patch_to_studio(self.draft_course_run.key) + + p1 = PersonFactory() + p2 = PersonFactory() + PersonFactory() + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.patch(url, {'staff': [p2.uuid, p1.uuid]}, format='json') + self.assertEqual(response.status_code, 200) + + self.draft_course_run.refresh_from_db() + self.assertListEqual(list(self.draft_course_run.staff.all()), [p2, p1]) + + @responses.activate + def test_partial_update_video(self): + """ Verify partially updating allows video updates. """ + self.mock_patch_to_studio(self.draft_course_run.key) + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.patch(url, {'video': {'src': 'https://example.com/blarg'}}, format='json') + self.assertEqual(response.status_code, 200) + + self.draft_course_run.refresh_from_db() + self.assertEqual(self.draft_course_run.video.src, 'https://example.com/blarg') + + @responses.activate + def test_update_if_editor(self): + """ Verify the endpoint supports updating a course_run with editor permissions. """ + self.mock_patch_to_studio(self.draft_course_run.key) + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + + self.user.is_staff = False + self.user.save() + + # Not an editor, not allowed to patch + response = self.client.patch(url, {}, format='json') + self.assertEqual(response.status_code, 403) + + # Add as editor + org_ext = OrganizationExtensionFactory( + organization=self.draft_course_run.course.authoring_organizations.first() + ) + self.user.groups.add(org_ext.group) + CourseEditorFactory(user=self.user, course=self.draft_course_run.course) + + # now allowed to patch + response = self.client.patch(url, {}, format='json') + self.assertEqual(response.status_code, 200) + + @responses.activate + def test_studio_update_failure(self): + """ Verify we bubble up error correctly if studio is giving us static. """ + self.mock_patch_to_studio(self.draft_course_run.key, status=400, body=b'Nope') + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.patch(url, {'title': 'New Title'}, format='json') + self.assertContains(response, 'Failed to set course run data: Nope', status_code=400) + + self.draft_course_run.refresh_from_db() + self.assertEqual(self.draft_course_run.title_override, None) # prove we didn't touch the course run object + + @responses.activate + def test_full_update(self): + """ Verify full updating is allowed. """ + self.mock_patch_to_studio(self.draft_course_run.key) + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.put(url, { + 'course': self.draft_course_run.course.key, # required, so we need for a put + 'start': self.draft_course_run.start, # required, so we need for a put + 'end': self.draft_course_run.end, # required, so we need for a put + 'run_type': str(self.draft_course_run.type.uuid), # required, so we need for a put + 'title': 'New Title', + }, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + + self.draft_course_run.refresh_from_db() + self.assertEqual(self.draft_course_run.title_override, 'New Title') + + @ddt.data( + CourseRunStatus.LegalReview, + CourseRunStatus.InternalReview, + ) + def test_patch_put_restrict_when_reviewing(self, status): + self.draft_course_run.status = status + self.draft_course_run.save() + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.put(url, { + 'course': self.draft_course_run.course.key, # required, so we need for a put + 'start': self.draft_course_run.start, # required, so we need for a put + 'end': self.draft_course_run.end, # required, so we need for a put + 'run_type': str(self.draft_course_run.type.uuid), # required, so we need for a put + }, format='json') + assert response.status_code == 403 response = self.client.patch(url, {}, format='json') assert response.status_code == 403 + @responses.activate + def test_patch_put_does_not_change_status(self): + self.mock_patch_to_studio(self.draft_course_run.key) + self.mock_ecommerce_publication() + self.draft_course_run.status = CourseRunStatus.Reviewed + self.draft_course_run.save() + official_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=False) + assert official_course_run.status == CourseRunStatus.Reviewed + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.put(url, { + 'course': self.draft_course_run.course.key, # required, so we need for a put + 'start': self.draft_course_run.start, # required, so we need for a put + 'end': self.draft_course_run.end, # required, so we need for a put + 'run_type': str(self.draft_course_run.type.uuid), # required, so we need for a put + }, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + self.draft_course_run.refresh_from_db() + draft_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=True) + assert draft_course_run.status == CourseRunStatus.Reviewed + assert draft_course_run.official_version.status == CourseRunStatus.Reviewed + + @responses.activate + def test_patch_put_reset_status(self): + self.mock_patch_to_studio(self.draft_course_run.key) + self.mock_ecommerce_publication() + self.draft_course_run.status = CourseRunStatus.Reviewed + self.draft_course_run.save() + official_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=False) + assert official_course_run.status == CourseRunStatus.Reviewed + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.put(url, { + 'course': self.draft_course_run.course.key, # required, so we need for a put + 'start': self.draft_course_run.start, # required, so we need for a put + 'end': self.draft_course_run.end, # required, so we need for a put + 'run_type': str(self.draft_course_run.type.uuid), # required, so we need for a put + 'full_description': 'Some new description', # required to cause a diff to update status + }, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + self.draft_course_run.refresh_from_db() + draft_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=True) + assert draft_course_run.status == CourseRunStatus.Unpublished + assert draft_course_run.official_version.status == CourseRunStatus.Unpublished + + @responses.activate + def test_patch_put_non_review_fields_does_not_reset_status(self): + """ + Tests that exempt fields do not reset the draft and official course runs to + the unpublished status. Also ensures that the official version is updated with + the changes to the exempt fields. + """ + self.mock_patch_to_studio(self.draft_course_run.key) + self.mock_ecommerce_publication() + self.draft_course_run.status = CourseRunStatus.Reviewed + self.draft_course_run.go_live_date = datetime.datetime(2031, 1, 1, tzinfo=pytz.UTC) + self.draft_course_run.save() + official_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=False) + assert official_course_run.status == CourseRunStatus.Reviewed + + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + # alter fields that do not require re-approval + response = self.client.put(url, { + 'course': self.draft_course_run.course.key, # required, so we need for a put + 'start': self.draft_course_run.start + datetime.timedelta(days=1), # required, so we need for a put + 'end': self.draft_course_run.end + datetime.timedelta(days=1), # required, so we need for a put + 'run_type': str(self.draft_course_run.type.uuid), # required, so we need for a put + 'go_live_date': self.draft_course_run.go_live_date + datetime.timedelta(days=1), + 'min_effort': self.draft_course_run.min_effort + 1, + 'max_effort': self.draft_course_run.max_effort + 1, + 'weeks_to_complete': self.draft_course_run.weeks_to_complete + 1, + }, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + self.draft_course_run.refresh_from_db() + draft_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=True) + assert draft_course_run.status == CourseRunStatus.Reviewed + assert draft_course_run.official_version.status == CourseRunStatus.Reviewed + assert draft_course_run.go_live_date == draft_course_run.official_version.go_live_date + + @ddt.data( + ({ + 'original_status': CourseRunStatus.Unpublished, + 'new_status': CourseRunStatus.LegalReview, + }), + ({ + 'original_status': CourseRunStatus.Reviewed, + 'new_status': CourseRunStatus.Reviewed, + }), + ({ + 'original_status': CourseRunStatus.Reviewed, + 'new_status': CourseRunStatus.LegalReview, + 'non_exempt_data': { + 'expected_program_name': 'example name', + } + }), + ) + @responses.activate + def test_patch_put_draft_false(self, update_transaction): + """ Verify that setting draft to False moves status correctly. Test Cases: Unpublished Course Run, + Reviewed Course Run, Reviewed Course Run Requiring Legal Review""" + self.mock_patch_to_studio(self.draft_course_run.key) + if update_transaction['original_status'] == CourseRunStatus.Reviewed: + self.mock_ecommerce_publication() + self.draft_course_run.status = update_transaction['original_status'] + self.draft_course_run.save() + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + body = { + 'course': self.draft_course_run.course.key, # required, so we need for a put + 'start': self.draft_course_run.start, # required, so we need for a put + 'end': self.draft_course_run.end, # required, so we need for a put + 'run_type': str(self.draft_course_run.type.uuid), # required, so we need for a put + 'draft': False, + } + if 'non_exempt_data' in update_transaction.keys(): + body.update(update_transaction['non_exempt_data']) + response = self.client.put(url, body, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + draft_course_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=True) + assert draft_course_run.status == update_transaction['new_status'] + + @responses.activate + def test_patch_published(self): + """ Verify that draft rows can be updated and re-published with draft=False. """ + self.mock_patch_to_studio(self.draft_course_run.key) + self.mock_ecommerce_publication() + self.draft_course_run.min_effort = 0 + self.draft_course_run.max_effort = 1 + self.draft_course_run.status = CourseRunStatus.Reviewed # Triggers creation of official versions + self.draft_course_run.save() + + official_run = CourseRun.everything.get(key=self.draft_course_run.key, draft=False) + draft_run = official_run.draft_version + official_run.status = CourseRunStatus.Published + draft_run.status = CourseRunStatus.Published + official_run.save() + draft_run.save() + + # Edit; should only touch draft + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + updated_min_effort = 867 + updated_max_effort = 5309 + data = { + 'max_effort': updated_max_effort, + 'min_effort': updated_min_effort, + } + response = self.client.patch(url, data, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + + draft_run.refresh_from_db() + assert draft_run.status == CourseRunStatus.Published + assert draft_run.min_effort == updated_min_effort + assert draft_run.max_effort == updated_max_effort + + official_run.refresh_from_db() + assert official_run.status == CourseRunStatus.Published + assert official_run.min_effort != updated_min_effort + assert official_run.max_effort != updated_max_effort + + # Re-publish; should update official with old and new changes. + updated_end = datetime.datetime(2021, 1, 1, tzinfo=pytz.UTC) + response = self.client.patch(url, {'end': updated_end, 'draft': False}, format='json') + assert response.status_code == 200, f"Status {response.status_code}: {response.content}" + + official_run.refresh_from_db() + draft_run.refresh_from_db() + assert official_run.status == CourseRunStatus.Published + assert official_run.min_effort == updated_min_effort + assert official_run.max_effort == updated_max_effort + assert draft_run.end == updated_end + assert official_run.end == updated_end + + def create_course_and_run_types(self, seat_type): + tracks = [] + entitlement_types = [] + if seat_type: + entitlement_types.append(SeatType.objects.get(slug=seat_type)) + tracks.append(TrackFactory(seat_type=entitlement_types[0])) + if seat_type == Seat.VERIFIED or not seat_type: + audit_type_obj = SeatType.objects.get(slug=Seat.AUDIT) + tracks.append(TrackFactory(seat_type=audit_type_obj)) + + run_type = CourseRunTypeFactory(tracks=tracks) + course_type = CourseTypeFactory( + entitlement_types=entitlement_types, + course_run_types=[run_type], + ) + return course_type, run_type + + @ddt.data( + ('audit', 'audit', 0.00), + ('audit', 'verified', 77), + ('audit', 'professional', 132), + ('verified', 'audit', 0.00), + ('verified', 'verified', 77), + ('verified', 'professional', 132), + ('professional', 'audit', 0.00), + ('professional', 'verified', 77), + ('professional', 'professional', 132), + ) + @ddt.unpack + @responses.activate + def test_patch_updating_seats_using_type(self, original_seat_type, seat_type, price): + """ + Verify that draft seats are updated when the type being passed in changes. + """ + # First create a course and course run using the original seat type to inform the + # CourseType and CourseRunType + original_course_type, original_run_type = self.create_course_and_run_types(original_seat_type) + creation_data = { + 'title': 'Course title', + 'number': 'test101', + 'org': OrganizationFactory(key='test-key').key, + 'type': str(original_course_type.uuid), + 'prices': {} if original_seat_type == 'audit' else {original_seat_type: 49}, + 'course_run': { + 'start': '2001-01-01T00:00:00Z', + 'end': datetime.datetime.now() + datetime.timedelta(days=1), + 'run_type': str(original_run_type.uuid), + } + } + + run_key = 'course-v1:{org}+{number}+1T2001'.format(org=creation_data['org'], number=creation_data['number']) + self.mock_access_token() + self.mock_post_to_studio(run_key) + + url = reverse('api:v1:course-list') + response = self.client.post(url, creation_data, format='json') + self.assertEqual(response.status_code, 201) + + self.mock_patch_to_studio(run_key) + url = reverse('api:v1:course_run-detail', kwargs={'key': run_key}) + + __, updated_run_type = self.create_course_and_run_types(seat_type) + data = { + 'run_type': str(updated_run_type.uuid), + 'prices': {seat_type: price}, + } + + # Update this course_run with the new info + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, 200) + + draft_course_run = CourseRun.everything.last() + num_seats = Seat.everything.count() + if seat_type == 'verified': + self.assertEqual(num_seats, 2) + audit_seat = Seat.everything.get(course_run=draft_course_run, type__slug='audit') + self.assertEqual(audit_seat.price, 0.00) + self.assertTrue(audit_seat.draft) + else: + self.assertEqual(num_seats, 1) + seat = Seat.everything.get(course_run=draft_course_run, type__slug=seat_type) + self.assertEqual(seat.price, price) + # This is probably not a great way of verifying this with the first, it just so happens + # that if there are two tracks (verified and audit), the verified track is first + self.assertEqual(seat.type, updated_run_type.tracks.first().seat_type) + self.assertTrue(seat.draft) + + @responses.activate + def test_patch_updating_seats_only_affects_active_course_runs_using_type(self): + """ + Verify that draft seats are updated when the type being passed in changes. + """ + # First create a course and course run using the original seat type to inform the + # CourseType and CourseRunType + course_type, run_type = self.create_course_and_run_types(Seat.VERIFIED) + creation_data = { + 'title': 'Course title', + 'number': 'test101', + 'org': OrganizationFactory(key='test-key').key, + 'type': str(course_type.uuid), + 'prices': {Seat.VERIFIED: 49}, + 'course_run': { + 'start': '2001-01-01T00:00:00Z', + 'end': datetime.datetime.now() + datetime.timedelta(days=-1), + 'run_type': str(run_type.uuid), + 'min_effort': 1, + } + } + + run_key = 'course-v1:{org}+{number}+1T2001'.format(org=creation_data['org'], number=creation_data['number']) + self.mock_access_token() + self.mock_post_to_studio(run_key) + + url = reverse('api:v1:course-list') + response = self.client.post(url, creation_data, format='json') + self.assertEqual(response.status_code, 201) + + draft_course_run = CourseRun.everything.last() + self.assertEqual(draft_course_run.min_effort, 1) + seat = Seat.everything.get(course_run=draft_course_run, type=Seat.VERIFIED) + self.assertEqual(seat.price, 49) + + self.mock_patch_to_studio(run_key) + url = reverse('api:v1:course_run-detail', kwargs={'key': run_key}) + + # We are changing the min_effort on the archived run which is going to send the run_type + # and an updated price along with it. The updated price should not go to this course run since it + # is not active. + data = { + 'min_effort': 5, + 'run_type': str(run_type.uuid), + 'prices': {Seat.VERIFIED: 77}, + } + + # Update this course_run with the new info + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, 200) + + draft_course_run.refresh_from_db() + seat.refresh_from_db() + # Min effort was still updated + self.assertEqual(draft_course_run.min_effort, 5) + # Price did not update to 49 + self.assertEqual(seat.price, 49) + def test_list(self): """ Verify the endpoint returns a list of all course runs. """ url = reverse('api:v1:course_run-list') @@ -155,7 +1010,7 @@ def test_list_query(self): query = 'title:Some random title' url = '{root}?q={query}'.format(root=reverse('api:v1:course_run-list'), query=query) - with self.assertNumQueries(39): + with self.assertNumQueries(42, threshold=2): response = self.client.get(url) actual_sorted = sorted(response.data['results'], key=lambda course_run: course_run['key']) @@ -291,3 +1146,124 @@ def test_contains_missing_parameter(self, params): response = self.client.get(url) assert response.status_code == 400 + + def test_options(self): + url = reverse('api:v1:course_run-detail', kwargs={'key': self.course_run.key}) + response = self.client.options(url) + self.assertEqual(response.status_code, 200) + + data = response.data['actions']['PUT'] + self.assertEqual(data['level_type']['choices'], + [{'display_name': self.course_run.level_type.name_t, + 'value': self.course_run.level_type.name_t}, + {'display_name': self.course_run_2.level_type.name_t, + 'value': self.course_run_2.level_type.name_t}, + {'display_name': self.draft_course_run.level_type.name_t, + 'value': self.draft_course_run.level_type.name_t}]) + self.assertEqual(data['content_language']['choices'], + [{'display_name': x.name, 'value': x.code} for x in + LanguageTag.objects.all().order_by('name')]) + self.assertGreater(LanguageTag.objects.count(), 0) + + def test_editable_list_gives_drafts(self): + # We delete self.course_run_2 and self.draft_course_run here so we can test that specifically + # draft and extra are the only ones showing up. + self.course_run_2.delete() + self.draft_course_run.delete() + + draft = CourseRunFactory( + course__partner=self.partner, uuid=self.course_run.uuid, key=self.course_run.key, draft=True + ) + self.course_run.draft_version = draft + self.course_run.save() + extra = CourseRunFactory(course__partner=self.partner) + + response = self.client.get(reverse('api:v1:course_run-list') + '?editable=1') + actual_sorted = sorted(response.data['results'], key=lambda course_run: course_run['key']) + expected_sorted = sorted(self.serialize_course_run([draft, extra], many=True), + key=lambda course_run: course_run['key']) + self.assertEqual(response.status_code, 200) + self.assertEqual(actual_sorted, expected_sorted) + + @responses.activate + def test_editable_list_is_denied_as_normal_user(self): + """ Verify that GET with editable=1 can't be reached by a normal unprivileged user. """ + self.user.is_staff = False + self.user.save() + + response = self.client.get(reverse('api:v1:course_run-list') + '?editable=1') + self.assertEqual(response.status_code, 403) + + def test_editable_get_gives_drafts(self): + draft = CourseRunFactory( + course__partner=self.partner, uuid=self.course_run.uuid, key=self.course_run.key, draft=True + ) + self.course_run.draft_version = draft + self.course_run.save() + extra = CourseRunFactory(course__partner=self.partner) + + response = self.client.get( + reverse('api:v1:course_run-detail', kwargs={'key': self.course_run.key}) + '?editable=1' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.serialize_course_run(draft, many=False)) + + response = self.client.get(reverse('api:v1:course_run-detail', kwargs={'key': extra.key}) + '?editable=1') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.serialize_course_run(extra, many=False)) + + def test_list_query_with_editable_raises_exception(self): + """ Verify the endpoint raises an exception if both a q param and editable=1 are passed in """ + query = 'title:Some random title' + url = '{root}?q={query}&editable=1'.format(root=reverse('api:v1:course_run-list'), query=query) + + with pytest.raises(EditableAndQUnsupported) as exc: + self.client.get(url) + + self.assertEqual(str(exc.value), 'Specifying both editable=1 and a q parameter is not supported.') + + @ddt.data( + ({ + 'staff': True, + 'body': { + 'status': 'review_by_internal', + 'has_ofac_restrictions': True, + 'ofac_comment': 'United States' + }, + 'original_status': 'review_by_legal', + 'status_code': 200 + }), + ({ + 'staff': True, + 'body': { + 'status': 'review_by_internal', + 'has_ofac_restrictions': True, + 'ofac_comment': 'United States', + 'invalid_field': 'invalid value' + }, + 'original_status': 'review_by_legal', + 'status_code': 400 + }), + ({ + 'staff': True, + 'body': { + 'status': 'review_by_internal', + 'has_ofac_restrictions': True, + 'ofac_comment': 'United States' + }, + 'original_status': 'unpublished', + 'status_code': 400, + }), + ) + def test_change_status_and_ofac_info(self, patch_transaction): + """Verify status, ofac restrictions, and ofac comment can be updated. Test cases: valid body, invalid body""" + self.user.is_staff = patch_transaction['staff'] + self.user.save() + self.draft_course_run.status = patch_transaction['original_status'] + self.draft_course_run.save() + group = Group.objects.get(name='Internal Users') + self.user.groups.add(group) + url = reverse('api:v1:course_run-detail', kwargs={'key': self.draft_course_run.key}) + response = self.client.patch(url, patch_transaction['body'], format='json') + + self.assertEqual(response.status_code, patch_transaction['status_code']) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_courses.py b/course_discovery/apps/api/v1/tests/test_views/test_courses.py index aededc203d..ed9db472a8 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_courses.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_courses.py @@ -1,56 +1,82 @@ import datetime +import json +from unittest import mock import ddt import pytest import pytz +import responses +from django.conf import settings +from django.db import IntegrityError from django.db.models.functions import Lower +from mock import mock from rest_framework.reverse import reverse +from testfixtures import LogCapture -from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin +from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, OAuth2Mixin, SerializationMixin +from course_discovery.apps.api.v1.views.courses import logger as course_logger from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus -from course_discovery.apps.course_metadata.models import Course +from course_discovery.apps.course_metadata.models import ( + Course, CourseEditor, CourseEntitlement, CourseRun, CourseRunType, CourseType, Seat +) from course_discovery.apps.course_metadata.tests.factories import ( - CourseFactory, CourseRunFactory, ProgramFactory, SeatFactory + CourseEditorFactory, CourseEntitlementFactory, CourseFactory, CourseRunFactory, LevelTypeFactory, + OrganizationFactory, ProgramFactory, SeatFactory, SeatTypeFactory, SubjectFactory ) +from course_discovery.apps.course_metadata.utils import ensure_draft_world +from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory @ddt.ddt @pytest.mark.usefixtures('django_cache') -class CourseViewSetTests(SerializationMixin, APITestCase): +class CourseViewSetTests(OAuth2Mixin, SerializationMixin, APITestCase): def setUp(self): - super(CourseViewSetTests, self).setUp() - self.user = UserFactory(is_staff=True, is_superuser=True) + super().setUp() + self.user = UserFactory(is_staff=True) self.request.user = self.user self.client.login(username=self.user.username, password=USER_PASSWORD) - self.course = CourseFactory(partner=self.partner) + self.audit_type = CourseType.objects.get(slug=CourseType.AUDIT) + self.verified_type = CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT) + self.course = CourseFactory(partner=self.partner, title='Fake Test', key='edX+Fake101', type=self.audit_type) + self.org = OrganizationFactory(key='edX', partner=self.partner) + self.course.authoring_organizations.add(self.org) + + def tearDown(self): + super().tearDown() + self.client.logout() + + def mock_ecommerce_publication(self): + url = f'{self.course.partner.ecommerce_api_url}publication/' + responses.add(responses.POST, url, json={}, status=200) def test_get(self): """ Verify the endpoint returns the details for a single course. """ url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) - with self.assertNumQueries(27): + with self.assertNumQueries(37): response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, self.serialize_course(self.course)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.serialize_course(self.course)) def test_get_uuid(self): """ Verify the endpoint returns the details for a single course with UUID. """ url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) - with self.assertNumQueries(27): + with self.assertNumQueries(37): response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, self.serialize_course(self.course)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.serialize_course(self.course)) def test_get_exclude_deleted_programs(self): """ Verify the endpoint returns no deleted associated programs """ ProgramFactory(courses=[self.course], status=ProgramStatus.Deleted) url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) - with self.assertNumQueries(18): + with self.assertNumQueries(37): response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('programs'), []) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.get('programs'), []) def test_get_include_deleted_programs(self): """ @@ -60,13 +86,13 @@ def test_get_include_deleted_programs(self): ProgramFactory(courses=[self.course], status=ProgramStatus.Deleted) url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) url += '?include_deleted_programs=1' - with self.assertNumQueries(31): + with self.assertNumQueries(40): response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.data, - self.serialize_course(self.course, extra_context={'include_deleted_programs': True}) - ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + self.serialize_course(self.course, extra_context={'include_deleted_programs': True}) + ) def test_get_include_hidden_course_runs(self): """ @@ -123,7 +149,7 @@ def test_marketable_course_runs_only(self, marketable_course_runs_only): SeatFactory(course_run=closed_course_run) url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) - url = '{}?marketable_course_runs_only={}'.format(url, marketable_course_runs_only) + url = f'{url}?marketable_course_runs_only={marketable_course_runs_only}' response = self.client.get(url) assert response.status_code == 200 @@ -180,7 +206,7 @@ def test_get_include_published_course_run(self, published_course_runs_only): unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished, course=self.course) url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) - url = '{}?published_course_runs_only={}'.format(url, published_course_runs_only) + url = f'{url}?published_course_runs_only={published_course_runs_only}' response = self.client.get(url) @@ -196,13 +222,13 @@ def test_list(self): """ Verify the endpoint returns a list of all courses. """ url = reverse('api:v1:course-list') - with self.assertNumQueries(32): + with self.assertNumQueries(25): response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertListEqual( - response.data['results'], - self.serialize_course(Course.objects.all().order_by(Lower('key')), many=True) - ) + self.assertEqual(response.status_code, 200) + self.assertListEqual( + response.data['results'], + self.serialize_course(Course.objects.all().order_by(Lower('key')), many=True) + ) def test_list_query(self): """ Verify the endpoint returns a filtered list of courses """ @@ -212,9 +238,11 @@ def test_list_query(self): query = 'title:' + title url = '{root}?q={query}'.format(root=reverse('api:v1:course-list'), query=query) - with self.assertNumQueries(57): + # Known to be flaky prior to the addition of tearDown() + # and logout() code which is the same number of additional queries + with self.assertNumQueries(42): response = self.client.get(url) - self.assertListEqual(response.data['results'], self.serialize_course(courses, many=True)) + self.assertListEqual(response.data['results'], self.serialize_course(courses, many=True)) def test_list_key_filter(self): """ Verify the endpoint returns a list of courses filtered by the specified keys. """ @@ -223,9 +251,9 @@ def test_list_key_filter(self): keys = ','.join([course.key for course in courses]) url = '{root}?keys={keys}'.format(root=reverse('api:v1:course-list'), keys=keys) - with self.assertNumQueries(56): + with self.assertNumQueries(42): response = self.client.get(url) - self.assertListEqual(response.data['results'], self.serialize_course(courses, many=True)) + self.assertListEqual(response.data['results'], self.serialize_course(courses, many=True)) def test_list_uuid_filter(self): """ Verify the endpoint returns a list of courses filtered by the specified uuid. """ @@ -234,9 +262,9 @@ def test_list_uuid_filter(self): uuids = ','.join([str(course.uuid) for course in courses]) url = '{root}?uuids={uuids}'.format(root=reverse('api:v1:course-list'), uuids=uuids) - with self.assertNumQueries(56): + with self.assertNumQueries(42): response = self.client.get(url) - self.assertListEqual(response.data['results'], self.serialize_course(courses, many=True)) + self.assertListEqual(response.data['results'], self.serialize_course(courses, many=True)) def test_list_exclude_utm(self): """ Verify the endpoint returns marketing URLs without UTM parameters. """ @@ -248,3 +276,1307 @@ def test_list_exclude_utm(self): response.data['results'], self.serialize_course([self.course], many=True, extra_context=context) ) + + def test_list_pubq_by_title(self): + """ Verify the endpoint returns a list of courses filtered by title when specified with pubq and editable """ + url = reverse('api:v1:course-list') + '?editable=1&pubq=ThisIsASpecificTestString' + + self.course.title = 'ThisIsASpecificTestStringTitle' + self.course.save() + ensure_draft_world(self.course) + + # Create a random test course with a title without the phrase "Test" in it + CourseFactory(partner=self.partner, key=self.course.key + 'Z', title='RandomString') + + # There should be 3 courses, the specific key course, the draft, and the FakeKey + courses = Course.everything.all() + self.assertEqual(len(courses), 3) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['uuid'], str(self.course.uuid)) + + def test_list_pubq_by_key(self): + """ Verify the endpoint returns a list of courses filtered by key when specified with pubq and editable """ + url = reverse('api:v1:course-list') + '?editable=1&pubq=ThisIsASpecificTestString' + + self.course.title = 'ThisIsASpecificTestStringKey' + self.course.save() + ensure_draft_world(self.course) + + # Create a random test course with a key without the phrase "Test" in it + CourseFactory(partner=self.partner, key='FakeKey') + + # There should be 3 courses, the specific key course, the draft, and the FakeKey + courses = Course.everything.all() + self.assertEqual(len(courses), 3) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['uuid'], str(self.course.uuid)) + + def test_list_course_run_statuses(self): + """ Verify the endpoint returns a list of courses by course run status """ + url = reverse('api:v1:course-list') + '?editable=1&course_run_statuses=unpublished' + CourseRunFactory(status=CourseRunStatus.Unpublished, course=self.course) + ensure_draft_world(self.course) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['uuid'], str(self.course.uuid)) + + course2 = CourseFactory() + url2 = reverse('api:v1:course-list') + '?editable=1&course_run_statuses=in_review' + CourseRunFactory(status=CourseRunStatus.LegalReview, course=course2) + ensure_draft_world(course2) + + response2 = self.client.get(url2) + self.assertEqual(response2.status_code, 200) + self.assertEqual(len(response2.data['results']), 1) + self.assertEqual(response2.data['results'][0]['uuid'], str(course2.uuid)) + + def test_unsubmitted_status(self): + """ Verify we support composite status 'unsubmitted' (unpublished & unarchived). """ + self.course.delete() + now = datetime.datetime.now(pytz.UTC) + no_end_run = ensure_draft_world(CourseRunFactory(status=CourseRunStatus.Unpublished, end=None)) + _past_end_run = ensure_draft_world(CourseRunFactory(status=CourseRunStatus.Unpublished, + end=now - datetime.timedelta(days=1))) + future_end_run = ensure_draft_world(CourseRunFactory(status=CourseRunStatus.Unpublished, + end=now + datetime.timedelta(days=1))) + + response = self.client.get(reverse('api:v1:course-list') + '?editable=1&course_run_statuses=unsubmitted') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 2) + self.assertEqual({c['uuid'] for c in response.data['results']}, + {str(run.course.uuid) for run in [future_end_run, no_end_run]}) + + def test_no_archived_statuses(self): + """ Verify that we skip archived statuses in a course serialization of statuses. """ + now = datetime.datetime.now(pytz.UTC) + past_end_run = CourseRunFactory(course=self.course, status=CourseRunStatus.Unpublished, + end=now - datetime.timedelta(days=1)) + _published_run = CourseRunFactory(course=self.course, status=CourseRunStatus.Published) + + response = self.client.get(reverse('api:v1:course-list')) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['uuid'], str(self.course.uuid)) + self.assertEqual(response.data['results'][0]['course_run_statuses'], ['published']) # no 'unpublished' status + + # Now test with no end date - we should see unarchived appear + past_end_run.end = None + past_end_run.save() + response = self.client.get(reverse('api:v1:course-list')) + self.assertEqual(response.data['results'][0]['course_run_statuses'], ['published', 'unpublished']) + + @ddt.data( + ('get', False, False, True), + ('options', False, False, True), + ('post', False, False, False), + ('post', False, True, True), + ('post', True, False, True), + ) + @ddt.unpack + def test_editor_access_list_endpoint(self, method, is_staff, in_org, allowed): + """ Verify we check editor access correctly when hitting the courses endpoint. """ + self.user.is_staff = is_staff + self.user.save() + + if in_org: + org_ext = OrganizationExtensionFactory(organization=self.org) + self.user.groups.add(org_ext.group) + + response = getattr(self.client, method)(reverse('api:v1:course-list'), {'org': self.org.key}, format='json') + + if not allowed: + self.assertEqual(response.status_code, 403) + else: + self.assertNotEqual(response.status_code, 403) + + @ddt.data( + ('get', False, True, False, True), + ('options', False, False, False, True), + ('put', False, False, False, False), # no access + ('put', True, False, False, True), # is staff + ('patch', False, True, False, False), # is in org + ('patch', False, False, True, False), # is editor but not in org + ('put', False, True, True, True), # editor and in org + ) + @ddt.unpack + def test_editor_access_detail_endpoint(self, method, is_staff, in_org, is_editor, allowed): + """ Verify we check editor access correctly when hitting the course object endpoint. """ + self.user.is_staff = is_staff + self.user.save() + + # Add another editor, because we have some logic that allows access anyway if a course has no valid editors. + # That code path is checked in test_course_without_editors below. + org_ext = OrganizationExtensionFactory(organization=self.org) + user2 = UserFactory() + user2.groups.add(org_ext.group) + CourseEditorFactory(user=user2, course=self.course) + + if in_org: + # Editors must be in the org to get editor access + self.user.groups.add(org_ext.group) + + if is_editor: + CourseEditorFactory(user=self.user, course=self.course) + + response = getattr(self.client, method)(reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})) + # We'll probably fail with 400 because we didn't include the right data - but at least confirm we got in + self.assertEqual(response.status_code not in [403, 404], allowed, response.status_code) + + def test_editable_list_gives_drafts(self): + draft = CourseFactory(partner=self.partner, uuid=self.course.uuid, key=self.course.key, draft=True) + draft_course_run = CourseRunFactory( + status=CourseRunStatus.Published, + end=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10), + course=draft, + draft=True, + ) + self.course.draft_version = draft + self.course.save() + extra = CourseFactory(partner=self.partner, key=self.course.key + 'Z') # set key so it sorts later + + response = self.client.get(reverse('api:v1:course-list') + '?editable=1') + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.data['results'], self.serialize_course([draft, extra], many=True)) + self.assertEqual(len(response.data['results'][0]['course_runs']), 1) + self.assertEqual(response.data['results'][0]['course_runs'][0]['uuid'], str(draft_course_run.uuid)) + + def test_editable_list_shows_all_courses_in_org(self): + """ Even ones we're not an editor for. """ + # Add the real editor for this course + org_ext = OrganizationExtensionFactory(organization=self.org) + user2 = UserFactory() + user2.groups.add(org_ext.group) + CourseEditorFactory(user=user2, course=self.course) + + self.user.groups.add(org_ext.group) + self.user.is_staff = False + self.user.save() + + self.assertFalse(CourseEditor.is_course_editable(self.user, self.course)) # sanity check + + response = self.client.get(reverse('api:v1:course-list') + '?editable=1') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['results'], self.serialize_course([self.course], many=True)) + + @responses.activate + def test_editable_list_is_denied_as_normal_user(self): + """ Verify that GET with editable=1 can't be reached by a normal unprivileged user. """ + self.user.is_staff = False + self.user.save() + + response = self.client.get(reverse('api:v1:course-list') + '?editable=1') + self.assertEqual(response.status_code, 403) + + def test_editable_get_gives_drafts(self): + draft = CourseFactory(partner=self.partner, uuid=self.course.uuid, key=self.course.key, draft=True) + self.course.draft_version = draft + self.course.save() + extra = CourseFactory(partner=self.partner) + + response = self.client.get(reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + '?editable=1') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.serialize_course(draft, many=False)) + + response = self.client.get(reverse('api:v1:course-detail', kwargs={'key': extra.uuid}) + '?editable=1') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, self.serialize_course(extra, many=False)) + + def test_editable_get_shows_editable_status(self): + # Add the real editor for this course + org_ext = OrganizationExtensionFactory(organization=self.org) + user2 = UserFactory() + user2.groups.add(org_ext.group) + editor = CourseEditorFactory(user=user2, course=self.course) + + self.user.groups.add(org_ext.group) + self.user.is_staff = False + self.user.save() + + self.assertFalse(CourseEditor.is_course_editable(self.user, self.course)) # sanity check + response = self.client.get(reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + '?editable=1') + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data['editable']) + + editor.delete() + self.assertTrue(CourseEditor.is_course_editable(self.user, self.course)) # sanity check + response = self.client.get(reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + '?editable=1') + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data['editable']) + + def test_list_query_with_editable_raises_exception(self): + """ Verify the endpoint raises an exception if both a q param and editable=1 are passed in """ + query = 'title:Some random title' + url = '{root}?q={query}&editable=1'.format(root=reverse('api:v1:course-list'), query=query) + + with pytest.raises(EditableAndQUnsupported) as exc: + self.client.get(url) + + self.assertEqual(str(exc.value), 'Specifying both editable=1 and a q parameter is not supported.') + + def test_course_without_editors(self): + """ Verify we can modify a course with no editors if we're in its authoring org. """ + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + self.user.is_staff = False + self.user.save() + self.course.draft = True + self.course.save() + + # Try without being in the organization nor an editor + self.assertEqual(self.client.patch(url).status_code, 403) + + # Add to authoring org, and we should be let in + org_ext = OrganizationExtensionFactory(organization=self.org) + self.user.groups.add(org_ext.group) + self.assertNotEqual(self.client.patch(url).status_code, 403) + + # Now add a random other user as an editor to the course, so that we will no longer be granted access. + editor = UserFactory() + CourseEditorFactory(user=editor, course=self.course) + editor.groups.add(org_ext.group) + self.assertEqual(self.client.patch(url).status_code, 404) + + # But if the editor is no longer valid (even though they exist), we're back to having access. + editor.groups.remove(org_ext.group) + self.assertNotEqual(self.client.patch(url).status_code, 403) + + # And finally, for a sanity check, confirm we have access when we become an editor also + CourseEditorFactory(user=self.user, course=self.course) + self.assertNotEqual(self.client.patch(url).status_code, 403) + + def test_delete_not_allowed(self): + """ Verify we don't allow deleting a course from the API. """ + response = self.client.delete(reverse('api:v1:course-detail', kwargs={'key': self.course.uuid})) + self.assertEqual(response.status_code, 405) + + def test_create_without_authentication(self): + """ Verify authentication is required when creating a course. """ + self.client.logout() + Course.objects.all().delete() + + url = reverse('api:v1:course-list') + response = self.client.post(url) + assert response.status_code == 401 + assert Course.objects.count() == 0 + + def create_course(self, data=None, update=True): + url = reverse('api:v1:course-list') + if update: + course_data = { + 'title': 'Course title', + 'number': 'test101', + 'org': self.org.key, + 'type': str(self.audit_type.uuid), + } + course_data.update(data or {}) + else: + course_data = data or {} + return self.client.post(url, course_data, format='json') + + def create_course_and_course_run(self, data=None, update=True): + if update: + course_data = { + 'title': 'Course title', + 'number': 'test101', + 'org': self.org.key, + 'type': str(self.audit_type.uuid), + 'course_run': { + 'start': '2001-01-01T00:00:00Z', + 'end': datetime.datetime.now() + datetime.timedelta(days=1), + 'run_type': str(CourseRunType.objects.get(slug=CourseRunType.AUDIT).uuid), + } + } + course_data.update(data or {}) + else: + course_data = data or {} + + responses.add( + responses.POST, + settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL + '/access_token', + body=json.dumps({'access_token': 'abcd', 'expires_in': 60}), + status=200, + ) + studio_url = '{root}/api/v1/course_runs/'.format(root=self.partner.studio_url.strip('/')) + responses.add(responses.POST, studio_url, status=200) + key = 'course-v1:{org}+{number}+1T2001'.format(org=course_data['org'], number=course_data['number']) + responses.add(responses.POST, f'{studio_url}{key}/images/', status=200) + return self.create_course(course_data, update) + + def test_multiple_authoring_orgs_get_pulled_in_order(self): + org1 = OrganizationFactory(key='org1', partner=self.partner) + org2 = OrganizationFactory(key='org2', partner=self.partner) + org3 = OrganizationFactory(key='org3', partner=self.partner) + + course = CourseFactory(partner=self.partner, + title='Mult Org Course', + key='edX+6688', + type=self.audit_type, + authoring_organizations=[org1, org2, org3]) + + self.mock_access_token() + + url = reverse('api:v1:course-detail', kwargs={'key': course.key}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.data['owners'][0]['key'], 'org1') + self.assertEqual(response.data['owners'][1]['key'], 'org2') + self.assertEqual(response.data['owners'][2]['key'], 'org3') + + course.authoring_organizations.clear() + for org in [org2, org3, org1]: + course.authoring_organizations.add(org) + + url = reverse('api:v1:course-detail', kwargs={'key': course.key}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.data['owners'][0]['key'], 'org2') + self.assertEqual(response.data['owners'][1]['key'], 'org3') + self.assertEqual(response.data['owners'][2]['key'], 'org1') + + def test_create_makes_draft(self): + """ When creating a course, it should start as a draft. """ + self.mock_access_token() + response = self.create_course({'type': str(self.verified_type.uuid), 'prices': {'verified': 77}}) + self.assertEqual(response.status_code, 201) + + course = Course.everything.last() + self.assertTrue(course.draft) + self.assertTrue(course.entitlements.first().draft) + + def test_create_makes_editor(self): + """ When creating a course, it should set the current user as the only editor for that course. """ + self.mock_access_token() + response = self.create_course() + self.assertEqual(response.status_code, 201) + + course = Course.everything.last() + + CourseEditor.objects.get(user=self.user, course=course) + self.assertEqual(CourseEditor.objects.count(), 1) + + def test_create_makes_course_and_course_run(self): + """ + When creating a course and supplying a course_run, it should create both the course + and course run as drafts. When mode = 'audit', an audit seat should also be created. + """ + response = self.create_course_and_course_run() + self.assertEqual(response.status_code, 201) + + course = Course.everything.last() + self.assertTrue(course.draft) + self.assertIsNone(course.entitlements.first()) + course_run = CourseRun.everything.last() + self.assertTrue(course_run.draft) + self.assertEqual(course_run.course, course) + + # Creating with mode = 'audit' should also create an audit seat + self.assertEqual(1, Seat.everything.count()) + seat = course_run.seats.first() + self.assertEqual(seat.type.slug, Seat.AUDIT) + self.assertEqual(seat.price, 0.00) + + def test_create_with_course_run_makes_verified_seat(self): + """ + When creating a course and supplying a course_run, it should create both the course + and course run as drafts. When mode = 'verified', a verified seat and an audit seat should be created. + """ + self.mock_access_token() + data = { + 'number': 'test101', + 'org': self.org.key, + 'type': str(CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT).uuid), + 'prices': {'verified': 77.77}, + 'course_run': { + 'start': '2001-01-01T00:00:00Z', + 'end': datetime.datetime.now() + datetime.timedelta(days=1), + 'run_type': str(CourseRunType.objects.get(slug=CourseRunType.VERIFIED_AUDIT).uuid), + } + } + response = self.create_course_and_course_run(data) + self.assertEqual(response.status_code, 201) + + course_run = CourseRun.everything.last() + + self.assertEqual(Seat.everything.count(), 2) + verified_seat = Seat.everything.get(course_run=course_run, type='verified') + self.assertEqual(float(verified_seat.price), 77.77) + audit_seat = Seat.everything.get(course_run=course_run, type='audit') + self.assertEqual(audit_seat.price, 0.00) + self.assertTrue(audit_seat.draft) + + def test_create_auto_creates_slug_if_not_set(self): + self.mock_access_token() + response = self.create_course() + self.assertEqual(response.status_code, 201) + course = Course.everything.last() + course.refresh_from_db() + self.assertEqual(course.active_url_slug, 'course-title') + + def test_add_collaborator_uuid_list(self): + self.mock_access_token() + collaborator = { + 'name': 'Collaborator 1', + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + collaborator_url = reverse('api:v1:collaborator-list') + collab_post_response = self.client.post(collaborator_url, collaborator, format='json') + self.assertEqual(collab_post_response.status_code, 201) + get_collab_response = self.client.get(collaborator_url) + collab_json = get_collab_response.json() + self.assertEqual(len(collab_json['results']), 1) + collaborator_to_use = collab_json['results'][0] + response = self.create_course({'collaborators': [collaborator_to_use['uuid']]}) + self.assertEqual(response.status_code, 201) + course = response.json() + self.assertEqual(course['collaborators'][0]['name'], 'Collaborator 1') + + def test_modify_collaborator_uuid_list(self): + self.mock_access_token() + collaborator = { + 'name': 'Collaborator 1', + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + collaborator2 = { + 'name': 'Collaborator 2', + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + collaborator_url = reverse('api:v1:collaborator-list') + collab_post_response = self.client.post(collaborator_url, collaborator, format='json') + collab_post_response2 = self.client.post(collaborator_url, collaborator2, format='json') + self.assertEqual(collab_post_response.status_code, 201) + get_collab_response = self.client.get(collaborator_url) + collab_json = get_collab_response.json() + self.assertEqual(len(collab_json['results']), 2) + collaborator_to_use = collab_json['results'][0] + response = self.create_course({'collaborators': [collaborator_to_use['uuid']]}) + self.assertEqual(response.status_code, 201) + course = response.json() + collab1 = collab_post_response.json() + collab2 = collab_post_response2.json() + self.assertEqual(course['collaborators'][0]['uuid'], collaborator_to_use['uuid']) + course_url = reverse('api:v1:course-detail', kwargs={'key': course['uuid']}) + modify_course_data = {'collaborators': [collab1['uuid'], collab2['uuid']]} + response = self.client.patch(course_url, modify_course_data, format='json') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()['collaborators']), 2) + + def test_create_saves_manual_url_slug(self): + self.mock_access_token() + response = self.create_course({'url_slug': 'manual'}) + self.assertEqual(response.status_code, 201) + course = Course.everything.last() + self.assertEqual(course.active_url_slug, 'manual') + + def test_create_increments_auto_url_slug(self): + self.mock_access_token() + response = self.create_course() + self.assertEqual(response.status_code, 201) + course = Course.everything.last() + self.assertEqual(course.active_url_slug, 'course-title') + + response = self.create_course({'number': 'a123'}) + self.assertEqual(response.status_code, 201) + course = Course.everything.last() + self.assertEqual(course.active_url_slug, 'course-title-2') + + def test_create_with_course_type_verified(self): + self.mock_access_token() + data = { + 'title': 'Test Course', + 'number': 'test101', + 'org': self.org.key, + 'type': str(self.verified_type.uuid), + 'prices': {'verified': 77}, + } + response = self.create_course(data, update=False) + + course = Course.everything.last() + self.assertDictEqual(response.data, self.serialize_course(course)) + self.assertEqual(response.status_code, 201) + self.assertEqual(course.title, data['title']) + self.assertEqual(course.type, self.verified_type) + + self.assertEqual(1, CourseEntitlement.everything.count()) + entitlement = course.entitlements.last() + self.assertEqual(entitlement.mode.slug, Seat.VERIFIED) + self.assertEqual(entitlement.price, 77) + + def test_create_with_course_type_audit(self): + self.mock_access_token() + data = { + 'title': 'Test Course', + 'number': 'test101', + 'org': self.org.key, + 'type': str(self.audit_type.uuid), + } + response = self.create_course(data, update=False) + + course = Course.everything.last() + self.assertDictEqual(response.data, self.serialize_course(course)) + self.assertEqual(response.status_code, 201) + self.assertEqual(course.title, data['title']) + self.assertEqual(course.type, self.audit_type) + self.assertEqual(0, CourseEntitlement.everything.count()) + + def test_create_fails_if_manual_slug_exists(self): + self.mock_access_token() + response = self.create_course() + self.assertEqual(response.status_code, 201) + course = Course.everything.last() + self.assertEqual(course.active_url_slug, 'course-title') + + response = self.create_course({'url_slug': 'course-title', 'number': 'a123'}) + self.assertEqual(response.status_code, 400) + expected_error_message = 'Failed to set data: Course creation was unsuccessful. ' \ + 'The course URL slug ‘[course-title]’ is already in use. ' \ + 'Please update this field and try again.' + self.assertEqual(response.data, expected_error_message) + + def test_create_fails_if_official_version_exists(self): + """ When creating a course, it should not create one if an official version already exists. """ + self.mock_access_token() + response = self.create_course({'number': 'Fake101'}) + self.assertEqual(response.status_code, 400) + expected_error_message = 'Failed to set data: A course with key [{key}] already exists.' + self.assertEqual(response.data, expected_error_message.format(key=self.course.key)) + + def test_create_fails_with_missing_field(self): + response = self.create_course( + { + 'title': 'Course title', + 'org': self.org.key, + 'type': str(self.audit_type.uuid), + }, + update=False + ) + self.assertEqual(response.status_code, 400) + expected_error_message = 'Incorrect data sent. Missing value for: [number].' + self.assertEqual(response.data, expected_error_message) + + def test_create_fails_with_nonexistent_org(self): + response = self.create_course({'org': 'fake org'}) + self.assertEqual(response.status_code, 400) + expected_error_message = 'Incorrect data sent. Organization [fake org] does not exist.' + self.assertEqual(response.data, expected_error_message) + + def test_create_fails_with_nonexistent_course_type(self): + data = { + 'title': 'Test Course', + 'number': 'test101', + 'org': self.org.key, + 'type': '00000000-0000-0000-0000-000000000000', + } + response = self.create_course(data, update=False) + self.assertEqual(response.status_code, 400) + expected_error_message = 'Incorrect data sent. Course Type [' + data['type'] + '] does not exist.' + self.assertEqual(response.data, expected_error_message) + + def test_create_fails_invalid_course_number(self): + response = self.create_course({'number': 'a b c'}) + self.assertEqual(response.status_code, 400) + expected_error_message = 'Failed to set data: Special characters not allowed in Course Number.' + self.assertEqual(response.data, expected_error_message) + + @ddt.data( + ( + {'title': 'Course title', 'number': 'test101', 'org': 'fake org', + 'type': '00000000-0000-0000-0000-000000000000'}, + 'Incorrect data sent. Organization [fake org] does not exist. ' + 'Course Type [00000000-0000-0000-0000-000000000000] does not exist.' + ), + ( + {'title': 'Course title', 'org': 'edX', 'type': '00000000-0000-0000-0000-000000000000'}, + 'Incorrect data sent. Missing value for: [number]. ' + 'Course Type [00000000-0000-0000-0000-000000000000] does not exist.' + ), + ( + {'title': 'Course title', 'org': 'fake org', 'type': 'audit'}, + 'Incorrect data sent. Missing value for: [number]. Organization [fake org] does not exist.' + ), + ( + {'number': 'test101', 'org': 'fake org', 'type': '00000000-0000-0000-0000-000000000000'}, + 'Incorrect data sent. Missing value for: [title]. Organization [fake org] does not exist. ' + 'Course Type [00000000-0000-0000-0000-000000000000] does not exist.' + ), + ) + @ddt.unpack + def test_create_fails_with_multiple_errors(self, course_data, expected_error_message): + if course_data.get('type') == 'audit': + course_data['type'] = str(self.audit_type.uuid) + response = self.create_course(course_data, update=False) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, expected_error_message) + + def test_create_fails_if_run_creation_fails(self): + ''' + For clarity, this only applies for when the course run endpoint receives an error response + from Studio. Other errors (PermissionDenied, ValidationError, Http404) are all caught and + raised to the course endpoint, but some errors just create a response. + ''' + studio_url = '{root}/api/v1/course_runs/'.format(root=self.partner.studio_url.strip('/')) + responses.add(responses.POST, studio_url, status=400, body=b'Nope') + response = self.create_course_and_course_run() + self.assertEqual(response.status_code, 400) + expected_error_message = 'Failed to set data: Failed to set course run data: Nope' + self.assertEqual(response.data, expected_error_message) + + def test_create_with_api_exception(self): + with mock.patch( + # We are using get_course_key because it is called prior to trying to contact the + # e-commerce service and still gives the effect of an api exception. + 'course_discovery.apps.api.v1.views.courses.CourseViewSet.get_course_key', + side_effect=IntegrityError('Error') + ): + with LogCapture(course_logger.name) as log_capture: + response = self.create_course() + self.assertEqual(response.status_code, 400) + log_capture.check( + ( + course_logger.name, + 'ERROR', + 'Failed to set data: Error', + ) + ) + + def test_update_without_authentication(self): + """ Verify authentication is required when updating a course. """ + self.client.logout() + Course.objects.all().delete() + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + response = self.client.patch(url) + assert response.status_code == 401 + assert Course.objects.count() == 0 + + @ddt.data('put', 'patch') + @responses.activate + def test_update_success(self, method): + self.mock_access_token() + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + course_data = { + 'title': 'Course title', + 'url_slug': 'manual', + 'partner': self.partner.id, + 'key': self.course.key, + 'type': str(self.audit_type.uuid), + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + 'video': {'src': 'https://link.to.video.for.testing/watch?t_s=5'}, + } + + response = getattr(self.client, method)(url, course_data, format='json') + self.assertEqual(response.status_code, 200) + + course = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertEqual(course.title, 'Course title') + self.assertEqual(course.active_url_slug, 'manual') + self.assertDictEqual(response.data, self.serialize_course(course)) + + @responses.activate + def test_update_with_level_type(self): + beginner = LevelTypeFactory() + beginner.set_current_language('en') + beginner.name_t = 'Beginner' + beginner.save() + self.mock_access_token() + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + course_data = { + 'level_type': 'Beginner' + } + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 200) + course = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertEqual(course.level_type, beginner) + + @responses.activate + def test_update_success_with_course_type_verified(self): + self.mock_access_token() + verified_mode = SeatTypeFactory.verified() + CourseEntitlementFactory(course=self.course, mode=verified_mode) + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + course_data = { + 'title': 'Course title', + 'key': self.course.key, + 'type': str(self.verified_type.uuid), + 'prices': {'verified': '77.32'}, + } + + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 200) + + course = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertDictEqual(response.data, self.serialize_course(course)) + self.assertEqual(course.title, 'Course title') + entitlement = course.entitlements.first() + self.assertEqual(float(entitlement.price), 77.32) + self.assertEqual(entitlement.mode.slug, Seat.VERIFIED) + + @responses.activate + def test_update_success_with_course_type_audit(self): + self.mock_access_token() + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + course_data = { + 'title': 'Course title', + 'key': self.course.key, + 'type': str(self.audit_type.uuid), + } + + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 200) + + course = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertDictEqual(response.data, self.serialize_course(course)) + self.assertEqual(course.title, 'Course title') + self.assertEqual(0, course.entitlements.count()) + + def test_update_keeps_url_slug_if_removed_from_form(self): + self.mock_access_token() + self.course.set_active_url_slug('fake-test') + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + course_data = { + 'url_slug': '' + } + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 200) + course = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertEqual(course.active_url_slug, 'fake-test') + + @responses.activate + def test_update_operates_on_drafts(self): + self.mock_access_token() + CourseEntitlementFactory(course=self.course) + self.assertFalse(Course.everything.filter(uuid=self.course.uuid, draft=True).exists()) # sanity check + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + response = self.client.patch(url, {'title': 'Title'}, format='json') + self.assertEqual(response.status_code, 200) + + course = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertTrue(course.entitlements.first().draft) + self.assertEqual(course.title, 'Title') + + self.course.refresh_from_db() + self.assertFalse(self.course.draft) + self.assertFalse(self.course.entitlements.first().draft) + self.assertEqual(self.course.title, 'Fake Test') + self.assertDictEqual(response.data, self.serialize_course(course)) + + @responses.activate + def test_patch_resets_run_status(self): + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed # Triggers creation of official versions + draft_course_run.save() + official_course_run = draft_course_run.official_version + self.assertEqual(official_course_run.status, CourseRunStatus.Reviewed) + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + patch_data = { + 'title': 'Title EDIT', + 'topics': ['tag1', 'tag2'], + } + response = self.client.patch(url, patch_data, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + draft_course_run.refresh_from_db() + official_course_run.refresh_from_db() + self.assertEqual(draft_course_run.status, CourseRunStatus.Unpublished) + self.assertEqual(official_course_run.status, CourseRunStatus.Unpublished) + + @responses.activate + def test_patch_non_review_fields_does_not_reset_run_status(self): + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed # Triggers creation of official versions + draft_course_run.save() + official_course_run = draft_course_run.official_version + self.assertEqual(official_course_run.status, CourseRunStatus.Reviewed) + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + patch_data = { + # The API is expecting the image to be base64 encoded. We are simulating that here. + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + 'video': {'src': 'https://new-videos-r-us/watch?t_s=5'}, + } + response = self.client.patch(url, patch_data, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + draft_course_run.refresh_from_db() + official_course_run.refresh_from_db() + self.assertEqual(draft_course_run.status, CourseRunStatus.Reviewed) + self.assertEqual(official_course_run.status, CourseRunStatus.Reviewed) + + @responses.activate + def test_patch_published(self): + """ + Verify that draft rows can be updated and re-published with draft=False. This should also + update and publish the official version. + """ + self.mock_access_token() + self.mock_ecommerce_publication() + data = { + 'type': str(CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT).uuid), + 'prices': { + 'verified': 40, + }, + } + self.create_course_and_course_run(data) + + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed # Triggers creation of official versions + draft_course_run.save() + # Only updates to official when there is a Published Course Run + draft_course_run.status = CourseRunStatus.Published + draft_course_run.save() + + # Edit; should only touch draft + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + updated_short_desc = '

New short desc

' + data = { + 'short_description': updated_short_desc, + } + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, 200) + + official_course = Course.everything.get(uuid=draft_course.uuid, draft=False) + draft_course = official_course.draft_version + + self.assertEqual(draft_course.short_description, updated_short_desc) + self.assertNotEqual(official_course.short_description, updated_short_desc) + + # Re-publish; should update official with new and old information + updated_full_desc = '

New long desc

' + response = self.client.patch(url, {'full_description': updated_full_desc, 'draft': False}, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + official_course.refresh_from_db() + self.assertEqual(draft_course.short_description, updated_short_desc) + self.assertEqual(official_course.short_description, updated_short_desc) + self.assertEqual(draft_course.full_description, updated_full_desc) + self.assertEqual(official_course.full_description, updated_full_desc) + + response = self.client.patch(url, {'prices': {'verified': 1000}, 'draft': False}, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + official_course.refresh_from_db() + self.assertEqual(draft_course.entitlements.first().price, 1000) + self.assertEqual(official_course.entitlements.first().price, 1000) + + def test_patch_publish_saves_old_url_in_history(self): + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed + draft_course_run.save() + draft_course_run.status = CourseRunStatus.Published + draft_course_run.save() + + official_course = Course.everything.get(uuid=draft_course.uuid, draft=False) + draft_course = official_course.draft_version + + self.assertEqual(official_course.active_url_slug, 'course-title') + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + + response = self.client.patch(url, {'url_slug': 'manual', 'draft': False}, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + official_course.refresh_from_db() + + self.assertEqual(official_course.active_url_slug, 'manual') + url_history = official_course.url_slug_history.all().values('url_slug') + url_history_strings = [history_item['url_slug'] for history_item in url_history] + self.assertIn('course-title', url_history_strings) + + def test_unpublished_url_slugs_not_added_to_history(self): + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed + draft_course_run.save() + draft_course_run.status = CourseRunStatus.Published + draft_course_run.save() + + official_course = Course.everything.get(uuid=draft_course.uuid, draft=False) + draft_course = official_course.draft_version + + self.assertEqual(official_course.active_url_slug, 'course-title') + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + + # add new slug to draft but don't publish + response = self.client.patch(url, {'url_slug': 'unpublished', 'draft': True}, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + official_course.refresh_from_db() + self.assertEqual(draft_course.active_url_slug, 'unpublished') + self.assertEqual(official_course.active_url_slug, 'course-title') + url_history = official_course.url_slug_history.all().values('url_slug') + url_history_strings = [history_item['url_slug'] for history_item in url_history] + self.assertNotIn('manual', url_history_strings) + + # add new slug and publish at the same time + response = self.client.patch(url, {'url_slug': 'published', 'draft': False}, format='json') + self.assertEqual(response.status_code, 200) + official_course.refresh_from_db() + self.assertEqual(official_course.active_url_slug, 'published') + self.assertEqual(official_course.url_slug_history.count(), 2) + + # unpublished slug not in history, previously published slug is + url_history = official_course.url_slug_history.all().values('url_slug') + url_history_strings = [history_item['url_slug'] for history_item in url_history] + self.assertNotIn('unpublished', url_history_strings) + self.assertIn('course-title', url_history_strings) + + # unpublished slug is now available to other courses + self.create_course({'url_slug': 'unpublished', 'number': 'a123'}) + new_course = Course.everything.last() + self.assertEqual(new_course.active_url_slug, 'unpublished') + + @responses.activate + def test_patch_published_switch_audit_to_verified(self): + """ + Verify that draft rows can be updated and re-published with draft=False. This should also + update and publish the official version. + """ + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed # Triggers creation of official versions + draft_course_run.save() + # Only updates to official when there is a Published Course Run + draft_course_run.status = CourseRunStatus.Published + draft_course_run.save() + + official_course = Course.everything.get(uuid=draft_course.uuid, draft=False) + draft_course = official_course.draft_version + + self.assertEqual(CourseEntitlement.everything.count(), 0) + + # Republish with a verified slug + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + updates = { + 'type': str(CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT).uuid), + 'prices': { + 'verified': 1000, + }, + 'draft': False, + } + response = self.client.patch(url, updates, format='json') + self.assertEqual(response.status_code, 200) + + draft_course.refresh_from_db() + official_course.refresh_from_db() + self.assertEqual(CourseEntitlement.everything.count(), 2) + self.assertEqual(draft_course.entitlements.first().price, 1000) + self.assertEqual(draft_course.entitlements.first().mode.slug, Seat.VERIFIED) + self.assertEqual(official_course.entitlements.first().price, 1000) + self.assertEqual(official_course.entitlements.first().mode.slug, Seat.VERIFIED) + + @responses.activate + def test_patch_draft_switch_verified_to_audit(self): + """ + Verify that draft rows can be updated from a "Verified and Audit" Course with + a Verified Entitlement to an "Audit Only" course with no entitlements + """ + self.mock_access_token() + self.mock_ecommerce_publication() + data = {'type': str(CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT).uuid)} + self.create_course_and_course_run(data) + + draft_course = Course.everything.last() + + self.assertEqual(CourseEntitlement.everything.count(), 1) + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + updates = { + 'type': str(CourseType.objects.get(slug=CourseType.AUDIT).uuid), + } + response = self.client.patch(url, updates, format='json') + self.assertEqual(response.status_code, 200) + self.assertEqual(CourseEntitlement.everything.count(), 0) + + @responses.activate + def test_patch_creates_draft_entitlement_if_possible(self): + """ + If an official course exists and does not have an entitlement, during the ensure_draft_world call, + we attempt to create an entitlement based on the seat data from the course runs. As long as all seat + data from active course runs (see Course.active_course_runs) match, we will create an entitlement. + """ + self.mock_access_token() + future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + run = CourseRunFactory(course=self.course, end=future, enrollment_end=None) + seat = SeatFactory(course_run=run, type=SeatTypeFactory.verified()) + self.assertFalse(Course.everything.filter(uuid=self.course.uuid, draft=True).exists()) # sanity check + self.assertIsNone(self.course.entitlements.first()) + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + response = self.client.patch(url, {'title': 'Title'}, format='json') + self.assertEqual(response.status_code, 200) + + course = Course.everything.get(uuid=self.course.uuid, draft=True) + + self.assertEqual(course.entitlements.count(), 1) + entitlement = course.entitlements.first() + self.assertEqual(entitlement.mode.slug, Seat.VERIFIED) + self.assertEqual(entitlement.price, seat.price) + self.assertEqual(entitlement.currency, seat.currency) + self.assertTrue(entitlement.draft) + + # The official version of the course should still not have any entitlements + self.assertIsNone(self.course.entitlements.first()) + + @ddt.unpack + def test_cannot_change_type_after_review(self): + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + response = self.client.patch(url, { + 'type': str(CourseType.objects.get(slug=CourseType.PROFESSIONAL).uuid), + 'prices': { + Seat.PROFESSIONAL: 1000, + }, + }, format='json') + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + 'Switching entitlement types after being reviewed is not supported. Please reach out to your ' + + 'project coordinator for additional help if necessary.' + ) + + def test_update_fails_if_manual_slug_exists(self): + self.mock_access_token() + response = self.create_course() + self.assertEqual(response.status_code, 201) + course = Course.everything.last() + self.assertEqual(course.active_url_slug, 'course-title') + + course_data = {'url_slug': 'course-title'} + url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 400) + expected_error_message = 'Failed to set data: Course edit was unsuccessful. ' \ + 'The course URL slug ‘[course-title]’ is already in use. ' \ + 'Please update this field and try again.' + self.assertEqual(response.data, expected_error_message) + + def test_update_fails_if_manual_slug_in_other_course_history(self): + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed + draft_course_run.save() + draft_course_run.status = CourseRunStatus.Published + draft_course_run.save() + + official_course = Course.everything.get(uuid=draft_course.uuid, draft=False) + draft_course = official_course.draft_version + + self.assertEqual(official_course.active_url_slug, 'course-title') + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + + response = self.client.patch(url, {'url_slug': 'manual', 'draft': False}, format='json') + self.assertEqual(response.status_code, 200) + + # at this point history of the created course should contain 'course-title' + course_data = {'url_slug': 'course-title'} + url = reverse('api:v1:course-detail', kwargs={'key': self.course.key}) + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 400) + expected_error_message = 'Failed to set data: Course edit was unsuccessful. ' \ + 'The course URL slug ‘[course-title]’ is already in use. ' \ + 'Please update this field and try again.' + self.assertEqual(response.data, expected_error_message) + + def test_update_succeeds_if_reusing_previous_slug_on_same_course(self): + self.mock_access_token() + self.mock_ecommerce_publication() + self.create_course_and_course_run() + draft_course = Course.everything.last() + draft_course_run = CourseRun.everything.last() + draft_course_run.status = CourseRunStatus.Reviewed + draft_course_run.save() + draft_course_run.status = CourseRunStatus.Published + draft_course_run.save() + + official_course = Course.everything.get(uuid=draft_course.uuid, draft=False) + draft_course = official_course.draft_version + + self.assertEqual(official_course.active_url_slug, 'course-title') + + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + + response = self.client.patch(url, {'url_slug': 'manual', 'draft': False}, format='json') + self.assertEqual(response.status_code, 200) + draft_course.refresh_from_db() + self.assertEqual(draft_course.active_url_slug, 'manual') + + # at this point history of the created course should contain 'course-title' + course_data = {'url_slug': 'course-title'} + url = reverse('api:v1:course-detail', kwargs={'key': draft_course.uuid}) + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 200) + draft_course.refresh_from_db() + self.assertEqual(draft_course.active_url_slug, 'course-title') + + def test_update_with_api_exception(self): + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + course_data = { + 'title': 'Course title', + 'type': str(self.verified_type.uuid), + 'prices': { + 'verified': 1000, + }, + } + + with mock.patch( + 'course_discovery.apps.api.v1.views.courses.CourseViewSet.update_entitlement', + side_effect=IntegrityError('Nope') + ): + with LogCapture(course_logger.name) as log_capture: + response = self.client.patch(url, course_data, format='json') + self.assertEqual(response.status_code, 400) + log_capture.check_present( + ( + course_logger.name, + 'ERROR', + 'Failed to set data: Nope', + ) + ) + + def test_update_fails_with_nonexistent_course_type(self): + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + + with LogCapture(course_logger.name) as log_capture: + response = self.client.patch(url, {'type': '00000000-0000-0000-0000-000000000000'}, format='json') + self.assertEqual(response.status_code, 400) + log_capture.check_present( + ( + course_logger.name, + 'ERROR', + ("Failed to set data: {'type': [ErrorDetail(string='Object with " + "uuid=00000000-0000-0000-0000-000000000000 does not exist.', code='does_not_exist')]}"), + ) + ) + + @responses.activate + def test_options(self): + self.mock_access_token() + SubjectFactory(name='Subject1') + CourseEntitlementFactory(course=self.course, mode=SeatTypeFactory.verified()) + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + with self.assertNumQueries(40, threshold=0): + response = self.client.options(url) + self.assertEqual(response.status_code, 200) + + data = response.json()['actions']['PUT'] + self.assertEqual(data['level_type']['choices'], + [{'display_name': self.course.level_type.name_t, 'value': self.course.level_type.name_t}]) + self.assertEqual(data['entitlements']['child']['children']['mode']['choices'], + [{'display_name': 'Audit', 'value': 'audit'}, + {'display_name': 'Credit', 'value': 'credit'}, + {'display_name': 'Honor', 'value': 'honor'}, + {'display_name': 'Professional', 'value': 'professional'}, + {'display_name': 'Verified', 'value': 'verified'}]) + self.assertEqual(data['subjects']['child']['choices'], + [{'display_name': 'Subject1', 'value': 'subject1'}]) + self.assertNotIn('choices', data['partner']) # we don't whitelist partner to show its choices + + # Check that tracks come out alright + credit_type = CourseType.objects.get(slug=CourseType.CREDIT_VERIFIED_AUDIT) + credit_options = None + for options in data['type']['type_options']: + if options['uuid'] == str(credit_type.uuid): + credit_options = options + break + self.assertIsNotNone(credit_options) + self.assertEqual({t['mode']['slug'] for t in credit_options['tracks']}, + {'verified', 'credit', 'audit'}) + + @responses.activate + @ddt.data(True, False) + def test_retrieve_will_create_entitlement(self, has_entitlement): + """ When retrieving a course, test that an entitlement gets created if needed """ + self.mock_access_token() + + self.assertFalse(self.course.entitlements.exists()) # sanity check + + run = CourseRunFactory(course=self.course) + SeatFactory(type=SeatTypeFactory.verified(), course_run=run, price=40) + if has_entitlement: + CourseEntitlementFactory(course=self.course, price=40, mode=SeatTypeFactory.verified()) + + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + + # First, without editable=1, to confirm we never do anything there + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.course.entitlements.exists(), has_entitlement) + + # Now with editable=1 for real + response = self.client.get(url, {'editable': 1}) + + self.assertEqual(response.status_code, 200) + self.assertIn('entitlements', response.json()) + self.assertEqual(len(response.json()['entitlements']), 1) + self.assertTrue(self.course.entitlements.exists()) + self.assertEqual(self.course.entitlements.first().mode.slug, Seat.VERIFIED) + self.assertEqual(self.course.entitlements.first().price, 40) + + @responses.activate + def test_html_stripped(self): + self.mock_access_token() + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + response = self.client.patch(url, {'full_description': '

Desc

'}, format='json') + self.assertEqual(response.status_code, 200) + draft = Course.everything.get(uuid=self.course.uuid, draft=True) + self.assertEqual(draft.full_description, '

Desc

') + + @responses.activate + def test_html_restricted(self): + self.mock_access_token() + url = reverse('api:v1:course-detail', kwargs={'key': self.course.uuid}) + response = self.client.patch(url, {'full_description': '

Header

'}, format='json') + self.assertContains(response, 'Invalid HTML received: h1 tag is not allowed', status_code=400) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_currency.py b/course_discovery/apps/api/v1/tests/test_views/test_currency.py index 4087612542..5dc8fefdde 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_currency.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_currency.py @@ -1,9 +1,10 @@ -import mock +from unittest import mock + import pytest from django.core.cache import cache from django.urls import reverse -from course_discovery.apps.api.v1.views.currency import CurrencyView, exchange_rate_cache_key +from course_discovery.apps.api.v1.views.currency import CurrencyView @pytest.mark.usefixtures('django_cache') @@ -11,12 +12,9 @@ class TestCurrencyCurrencyView: list_path = reverse('api:v1:currency') - def setup_method(self, method): # pylint: disable=unused-argument - cache.delete(exchange_rate_cache_key()) - def test_authentication_required(self, client): response = client.get(self.list_path) - assert response.status_code == 403 + assert response.status_code == 401 def test_get(self, admin_client, django_cache, responses, settings): # pylint: disable=unused-argument settings.OPENEXCHANGERATES_API_KEY = 'test' @@ -38,18 +36,20 @@ def test_get(self, admin_client, django_cache, responses, settings): # pylint: response = admin_client.get(self.list_path) - assert all(item in response.data.items() for item in expected.items()) + assert all(item in response.json().items() for item in expected.items()) assert len(responses.calls) == 1 # Subsequent requests should hit the cache - response = admin_client.get(self.list_path) - assert all(item in response.data.items() for item in expected.items()) - assert len(responses.calls) == 1 + # FIXME: this block is flaky in Travis. It is reliable locally, but occasionally in our CI environment, + # this call won't be cached. I couldn't figure it out. But if you can, please re-enable this check. + # response = admin_client.get(self.list_path) + # assert all(item in response.json().items() for item in expected.items()) + # assert len(responses.calls) == 1 # Clearing the cache should result in the external service being called again - cache.delete(exchange_rate_cache_key()) + cache.clear() response = admin_client.get(self.list_path) - assert all(item in response.data.items() for item in expected.items()) + assert all(item in response.json().items() for item in expected.items()) assert len(responses.calls) == 2 def test_get_without_api_key(self, admin_client, settings): @@ -59,7 +59,7 @@ def test_get_without_api_key(self, admin_client, settings): response = admin_client.get(self.list_path) mock_logger.assert_called_with('Unable to retrieve exchange rate data. No API key is set.') assert response.status_code == 200 - assert response.data == {} + assert response.json() == {} def test_get_with_external_error(self, admin_client, responses, settings): settings.OPENEXCHANGERATES_API_KEY = 'test' @@ -74,4 +74,4 @@ def test_get_with_external_error(self, admin_client, responses, settings): CurrencyView.EXTERNAL_API_URL, status, b'{}' ) assert response.status_code == 200 - assert response.data == {} + assert response.json() == {} diff --git a/course_discovery/apps/api/v1/tests/test_views/test_level_types.py b/course_discovery/apps/api/v1/tests/test_views/test_level_types.py new file mode 100644 index 0000000000..868617fbf8 --- /dev/null +++ b/course_discovery/apps/api/v1/tests/test_views/test_level_types.py @@ -0,0 +1,49 @@ +from django.urls import reverse + +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin +from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory +from course_discovery.apps.course_metadata.models import LevelType +from course_discovery.apps.course_metadata.tests.factories import LevelTypeFactory + + +class LevelTypeViewSetTests(SerializationMixin, APITestCase): + list_path = reverse('api:v1:level_type-list') + + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True, is_superuser=True) + self.client.login(username=self.user.username, password=USER_PASSWORD) + + def test_authentication(self): + """ Verify the endpoint requires the user to be authenticated. """ + response = self.client.get(self.list_path) + assert response.status_code == 200 + + self.client.logout() + response = self.client.get(self.list_path) + assert response.status_code == 401 + + def test_list(self): + """ Verify the endpoint returns a list of all program types. """ + LevelTypeFactory.create_batch(4) + expected = LevelType.objects.all() + with self.assertNumQueries(6): + response = self.client.get(self.list_path) + + assert response.status_code == 200 + assert response.data['results'] == self.serialize_level_type(expected, many=True) + + def test_retrieve(self): + """ The request should return details for a single level type. """ + level_type = LevelTypeFactory() + level_type.set_current_language('en') + level_type.name_t = level_type.name + level_type.save() + url = reverse('api:v1:level_type-detail', kwargs={'name': level_type.name}) + print(level_type.__dict__) + + with self.assertNumQueries(5): + response = self.client.get(url) + + assert response.status_code == 200 + assert response.data == self.serialize_level_type(level_type) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_mixins.py b/course_discovery/apps/api/v1/tests/test_views/test_mixins.py index f3a8b2807c..db01b7048b 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_mixins.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_mixins.py @@ -4,7 +4,7 @@ def test_fuzzy_int_equality(): fuzzy_int = FuzzyInt(10, 4) - for i in range(0, 6): + for i in range(6): assert i != fuzzy_int for i in range(6, 15): diff --git a/course_discovery/apps/api/v1/tests/test_views/test_organizations.py b/course_discovery/apps/api/v1/tests/test_views/test_organizations.py index 9e799a4472..81a220f9eb 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_organizations.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_organizations.py @@ -1,18 +1,22 @@ import uuid from django.urls import reverse +from guardian.shortcuts import assign_perm from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory from course_discovery.apps.course_metadata.tests.factories import Organization, OrganizationFactory +from course_discovery.apps.publisher.models import OrganizationExtension +from course_discovery.apps.publisher.tests import factories as publisher_factories class OrganizationViewSetTests(SerializationMixin, APITestCase): list_path = reverse('api:v1:organization-list') def setUp(self): - super(OrganizationViewSetTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) + self.non_staff_user = UserFactory() self.request.user = self.user self.client.login(username=self.user.username, password=USER_PASSWORD) @@ -23,7 +27,7 @@ def test_authentication(self): self.client.logout() response = self.client.get(self.list_path) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) def assert_response_data_valid(self, response, organizations, many=True): """ Asserts the response data (only) contains the expected organizations. """ @@ -39,7 +43,7 @@ def assert_list_uuid_filter(self, organizations, expected_query_count): organizations = sorted(organizations, key=lambda o: o.created) with self.assertNumQueries(expected_query_count): uuids = ','.join([organization.uuid.hex for organization in organizations]) - url = '{root}?uuids={uuids}'.format(root=self.list_path, uuids=uuids) + url = f'{self.list_path}?uuids={uuids}' response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -49,7 +53,7 @@ def assert_list_tag_filter(self, organizations, tags, expected_query_count=7): """ Asserts the list endpoint supports filtering by tags. """ with self.assertNumQueries(expected_query_count): tags = ','.join(tags) - url = '{root}?tags={tags}'.format(root=self.list_path, tags=tags) + url = f'{self.list_path}?tags={tags}' response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -65,6 +69,33 @@ def test_list(self): self.assertEqual(response.status_code, 200) self.assert_response_data_valid(response, Organization.objects.all()) + def test_list_not_staff(self): + """ Verify the endpoint returns a list of all organizations. """ + org1 = OrganizationFactory.create(partner=self.partner) + org2 = OrganizationFactory.create(partner=self.partner) + OrganizationFactory.create(partner=self.partner) + + extension1 = publisher_factories.OrganizationExtensionFactory(organization=org1) + publisher_factories.OrganizationExtensionFactory(organization=org2) + assign_perm(OrganizationExtension.VIEW_COURSE, extension1.group, extension1) + + self.non_staff_user.groups.add(extension1.group) + + # Check Staff user get all groups + response = self.client.get(self.list_path) + + self.assertEqual(response.status_code, 200) + self.assert_response_data_valid(response, Organization.objects.all()) + + # Check non staff user gets 1 group + self.client.logout() + self.client.login(username=self.non_staff_user.username, password=USER_PASSWORD) + + response = self.client.get(self.list_path) + + self.assertEqual(response.status_code, 200) + self.assert_response_data_valid(response, [org1]) + def test_list_uuid_filter(self): """ Verify the endpoint returns a list of organizations filtered by UUID. """ @@ -104,6 +135,39 @@ def test_retrieve(self): self.assertEqual(response.status_code, 200) self.assert_response_data_valid(response, organization, many=False) + def test_retrieve_not_staff(self): + """ Verify the endpoint returns a list of all organizations. """ + org1 = OrganizationFactory.create(partner=self.partner) + org2 = OrganizationFactory.create(partner=self.partner) + OrganizationFactory.create(partner=self.partner) + url = reverse('api:v1:organization-detail', kwargs={'uuid': org2.uuid}) + + extension1 = publisher_factories.OrganizationExtensionFactory(organization=org1) + publisher_factories.OrganizationExtensionFactory(organization=org2) + + assign_perm(OrganizationExtension.VIEW_COURSE, extension1.group, extension1) + self.non_staff_user.groups.add(extension1.group) + + # Check Staff user get all groups + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assert_response_data_valid(response, org2, many=False) + + # Check non staff user gets 1 group + self.client.logout() + self.client.login(username=self.non_staff_user.username, password=USER_PASSWORD) + + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + + url = reverse('api:v1:organization-detail', kwargs={'uuid': org1.uuid}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assert_response_data_valid(response, org1, many=False) + def test_retrieve_not_found(self): """ Verify the endpoint returns HTTP 404 if the specified UUID does not match an organization. """ url = reverse('api:v1:organization-detail', kwargs={'uuid': uuid.uuid4()}) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_people.py b/course_discovery/apps/api/v1/tests/test_views/test_people.py index 1bd7cb0f48..996b619b9c 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_people.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_people.py @@ -1,30 +1,37 @@ -# pylint: disable=redefined-builtin,no-member +from unittest import mock + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission from django.db import IntegrityError -from mock import mock from rest_framework.reverse import reverse from testfixtures import LogCapture +from waffle.testutils import override_switch from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin from course_discovery.apps.api.v1.views.people import logger as people_logger from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory from course_discovery.apps.course_metadata.models import Person, Position from course_discovery.apps.course_metadata.people import MarketingSitePeople -from course_discovery.apps.course_metadata.tests import toggle_switch from course_discovery.apps.course_metadata.tests.factories import ( - OrganizationFactory, PersonAreaOfExpertiseFactory, PersonFactory, PersonSocialNetworkFactory, PositionFactory + CourseFactory, CourseRunFactory, OrganizationFactory, PersonAreaOfExpertiseFactory, PersonFactory, + PersonSocialNetworkFactory, PositionFactory ) User = get_user_model() +@override_switch('publish_person_to_marketing_site', True) class PersonViewSetTests(SerializationMixin, APITestCase): """ Tests for the person resource. """ people_list_url = reverse('api:v1:person-list') + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._original_partner_marketing_site_api_username = cls.partner.marketing_site_api_username + def setUp(self): - super(PersonViewSetTests, self).setUp() + super().setUp() self.user = UserFactory() self.request.user = self.user self.target_permissions = Permission.objects.filter( @@ -34,11 +41,10 @@ def setUp(self): self.internal_test_group.permissions.add(*self.target_permissions) self.user.groups.add(self.internal_test_group) self.client.login(username=self.user.username, password=USER_PASSWORD) - with mock.patch.object(MarketingSitePeople, 'update_or_publish_person'): + with override_switch('publish_person_to_marketing_site', False): self.person = PersonFactory(partner=self.partner) self.organization = OrganizationFactory(partner=self.partner) PositionFactory(person=self.person, organization=self.organization) - toggle_switch('publish_person_to_marketing_site', True) self.expected_node = { 'resource': 'node', 'id': '28691', @@ -46,6 +52,9 @@ def setUp(self): 'uri': 'https://stage.edx.org/node/28691' } + self.partner.marketing_site_api_username = self._original_partner_marketing_site_api_username + self.partner.save() + def person_exists(self, data): return Person.objects.filter( given_name=data['given_name'], family_name=data['family_name'], bio=data['bio'] @@ -146,7 +155,7 @@ def test_create_without_authentication(self): Person.objects.all().delete() response = self.client.post(self.people_list_url) - assert response.status_code == 403 + assert response.status_code == 401 assert Person.objects.count() == 0 def test_create_without_permission(self): @@ -168,17 +177,18 @@ def test_get_single_person_with_publisher_user(self): self.assertDictEqual(response.data, self.serialize_person(self.person)) def test_get_without_authentication(self): - """ Verify the endpoint shows auth error when the details for a single person unauthenticated """ + """ Verify the endpoint allows viewing the details for a single person unauthenticated """ self.client.logout() url = reverse('api:v1:person-detail', kwargs={'uuid': self.person.uuid}) response = self.client.get(url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.data, self.serialize_person(self.person)) def test_list_with_publisher_user(self): """ Verify the endpoint returns a list of all people with the publisher user """ response = self.client.get(self.people_list_url) self.assertEqual(response.status_code, 200) - self.assertListEqual(response.data['results'], self.serialize_person(Person.objects.all(), many=True)) + self.assertCountEqual(response.data['results'], self.serialize_person(Person.objects.all(), many=True)) def test_list_different_partner(self): """ Verify the endpoint only shows people for the current partner. """ @@ -187,20 +197,77 @@ def test_list_different_partner(self): response = self.client.get(self.people_list_url) self.assertEqual(response.status_code, 200) # Make sure the list does not include the new person above - self.assertListEqual(response.data['results'], self.serialize_person([self.person], many=True)) + self.assertCountEqual(response.data['results'], self.serialize_person([self.person], many=True)) def test_list_filter_by_slug(self): """ Verify the endpoint allows people to be filtered by slug. """ with mock.patch.object(MarketingSitePeople, 'update_or_publish_person'): person = PersonFactory(partner=self.partner) - url = '{root}?slug={slug}'.format(root=self.people_list_url, slug=person.slug) + url = f'{self.people_list_url}?slug={person.slug}' response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertListEqual(response.data['results'], self.serialize_person([person], many=True)) + self.assertCountEqual(response.data['results'], self.serialize_person([person], many=True)) + + def test_list_filter_by_slug_unauthenticated(self): + """ Verify the endpoint allows people to be filtered by slug, unauthenticated """ + self.client.logout() + with mock.patch.object(MarketingSitePeople, 'update_or_publish_person'): + person = PersonFactory(partner=self.partner) + url = f'{self.people_list_url}?slug={person.slug}' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertCountEqual(response.data['results'], self.serialize_person([person], many=True)) + + def test_with_no_org(self): + org1 = OrganizationFactory() + course = CourseFactory(authoring_organizations=[org1]) + with mock.patch.object(MarketingSitePeople, 'update_or_publish_person'): + person1 = PersonFactory(partner=self.partner) + PersonFactory(partner=self.partner) + CourseRunFactory(staff=[person1], course=course) + url = f'{self.people_list_url}?org=' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 0) + + def test_list_with_org_single(self): + org1 = OrganizationFactory() + course = CourseFactory(authoring_organizations=[org1]) + with mock.patch.object(MarketingSitePeople, 'update_or_publish_person'): + person1 = PersonFactory(partner=self.partner) + PersonFactory(partner=self.partner) + PersonFactory(partner=self.partner) + CourseRunFactory(staff=[person1], course=course) + url = f'{self.people_list_url}?org={org1.key}' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'], self.serialize_person([person1], many=True)) + + def test_list_with_org_multiple(self): + org1 = OrganizationFactory() + org2 = OrganizationFactory() + course1 = CourseFactory(authoring_organizations=[org1]) + course2 = CourseFactory(authoring_organizations=[org2]) + with mock.patch.object(MarketingSitePeople, 'update_or_publish_person'): + person1 = PersonFactory(partner=self.partner) + person2 = PersonFactory(partner=self.partner) + PersonFactory(partner=self.partner) + CourseRunFactory(staff=[person1], course=course1) + CourseRunFactory(staff=[person2], course=course2) + url = '{url}?org={org1_key}&org={org2_key}'.format( + url=self.people_list_url, + org1_key=org1.key, + org2_key=org2.key, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 2) + self.assertCountEqual(response.data['results'], self.serialize_person([person1, person2], many=True)) + @override_switch('publish_person_to_marketing_site', False) def test_create_without_waffle_switch(self): """ Verify endpoint proceeds if waffle switch is disabled. """ - toggle_switch('publish_person_to_marketing_site', False) data = self._person_data() with mock.patch.object(MarketingSitePeople, 'update_or_publish_person') as cm: response = self.client.post(self.people_list_url, data, format='json') @@ -212,12 +279,12 @@ def _person_data(self): return { 'given_name': "Robert", 'family_name': "Ford", - 'bio': "The maze is not for him.", + 'bio': "

The maze is not for him.

", 'position': { 'title': "Park Director", 'organization': self.organization.id }, - 'major_works': 'Delores\nTeddy\nMaive', + 'major_works': '

Delores
\nTeddy
\nMaive

', 'urls_detailed': [ { 'id': '1', @@ -264,12 +331,12 @@ def _update_person_data(self): return { 'given_name': "updated", 'family_name': "name", - 'bio': "updated bio", + 'bio': "

updated bio

", 'position': { 'title': "new title", 'organization': self.organization.id }, - 'major_works': 'new works', + 'major_works': '

new works

', 'urls_detailed': [ { 'id': '1', @@ -354,10 +421,10 @@ def test_update_with_api_exception(self): ) ) + @override_switch('publish_person_to_marketing_site', False) def test_update_without_waffle_switch(self): """ Verify update endpoint proceeds if waffle switch is disabled. """ url = reverse('api:v1:person-detail', kwargs={'uuid': self.person.uuid}) - toggle_switch('publish_person_to_marketing_site', False) data = self._update_person_data() with mock.patch.object(MarketingSitePeople, 'update_or_publish_person') as cm: response = self.client.patch(url, data, format='json') @@ -433,3 +500,36 @@ def test_update_without_position(self): updated_person = Person.objects.get(id=self.person.id) self.assertEqual(updated_person.position.title, data['position']['title']) + + def test_update_with_org_restrictions(self): + url = reverse('api:v1:person-detail', kwargs={'uuid': self.person.uuid}) + url = url + '?org=invalid-org' + + data = self._update_person_data() + with mock.patch.object(MarketingSitePeople, 'update_or_publish_person', return_value={}): + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, 404) + + # Verify that the person wasn't updated. + updated_person = Person.objects.get(id=self.person.id) + self.assertEqual(updated_person.given_name, self.person.given_name) + + def test_options_org_choices(self): + """ Verify that an OPTIONS request will provide a list of organizations. """ + + self.organization.key = 'bbb' + self.organization.name = 'Test' + self.organization.save() + org2 = OrganizationFactory(partner=self.partner, name='', key='aaa') + + response = self.client.options(self.people_list_url) + self.assertEqual(response.status_code, 200) + + data = response.data['actions']['POST'] + self.assertEqual( + data['position']['children']['organization']['choices'], + [ + {'display_name': 'aaa', 'value': org2.id}, + {'display_name': 'bbb: Test', 'value': self.organization.id}, + ] + ) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_program_types.py b/course_discovery/apps/api/v1/tests/test_views/test_program_types.py index b398eb6b1d..4568fe372e 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_program_types.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_program_types.py @@ -10,7 +10,7 @@ class ProgramTypeViewSetTests(SerializationMixin, APITestCase): list_path = reverse('api:v1:program_type-list') def setUp(self): - super(ProgramTypeViewSetTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) self.client.login(username=self.user.username, password=USER_PASSWORD) @@ -21,7 +21,7 @@ def test_authentication(self): self.client.logout() response = self.client.get(self.list_path) - assert response.status_code == 403 + assert response.status_code == 401 def test_list(self): """ Verify the endpoint returns a list of all program types. """ diff --git a/course_discovery/apps/api/v1/tests/test_views/test_programs.py b/course_discovery/apps/api/v1/tests/test_views/test_programs.py index 4480d09416..88f45686b1 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_programs.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_programs.py @@ -1,3 +1,4 @@ +import json import urllib.parse import pytest @@ -12,8 +13,9 @@ from course_discovery.apps.course_metadata.choices import ProgramStatus from course_discovery.apps.course_metadata.models import Program from course_discovery.apps.course_metadata.tests.factories import ( - CorporateEndorsementFactory, CourseFactory, CourseRunFactory, EndorsementFactory, ExpectedLearningItemFactory, - JobOutlookItemFactory, OrganizationFactory, PersonFactory, ProgramFactory, VideoFactory + CorporateEndorsementFactory, CourseFactory, CourseRunFactory, CurriculumCourseMembershipFactory, CurriculumFactory, + CurriculumProgramMembershipFactory, EndorsementFactory, ExpectedLearningItemFactory, JobOutlookItemFactory, + OrganizationFactory, PersonFactory, ProgramFactory, ProgramTypeFactory, VideoFactory ) @@ -42,15 +44,32 @@ def setup(self, client, django_assert_num_queries, partner): self.partner = partner self.request = request - def create_program(self): + def _program_data(self): + course_runs = CourseRunFactory.create_batch(3) + organizations = OrganizationFactory.create_batch(3) + program_type = ProgramTypeFactory() + return { + "title": "Test Program", + "type": program_type.slug, + "status": "active", + "marketing_slug": "edX-test-program", + "course_runs": [course_run.key for course_run in course_runs], + "min_hours_effort_per_week": 10, + "max_hours_effort_per_week": 20, + "authoring_organizations": [organization.key for organization in organizations], + "credit_backing_organizations": [organization.key for organization in organizations], + } + + def create_program(self, courses=None): organizations = [OrganizationFactory(partner=self.partner)] person = PersonFactory() - course = CourseFactory(partner=self.partner) - CourseRunFactory(course=course, staff=[person]) + if courses is None: + courses = [CourseFactory(partner=self.partner)] + CourseRunFactory(course=courses[0], staff=[person]) program = ProgramFactory( - courses=[course], + courses=courses, authoring_organizations=organizations, credit_backing_organizations=organizations, corporate_endorsements=CorporateEndorsementFactory.create_batch(1), @@ -64,6 +83,21 @@ def create_program(self): ) return program + def create_curriculum(self, parent_program): + person = PersonFactory() + course = CourseFactory(partner=self.partner) + CourseRunFactory(course=course, staff=[person]) + CourseRunFactory(course=course, staff=[person]) + + curriculum = CurriculumFactory( + program=parent_program + ) + CurriculumCourseMembershipFactory( + course=course, + curriculum=curriculum + ) + return curriculum + def assert_retrieve_success(self, program, querystring=None): """ Verify the retrieve endpoint succesfully returns a serialized program. """ url = reverse('api:v1:program-detail', kwargs={'uuid': program.uuid}) @@ -82,13 +116,13 @@ def test_authentication(self): self.client.logout() response = self.client.get(self.list_path) - assert response.status_code == 403 + assert response.status_code == 401 def test_retrieve(self, django_assert_num_queries): """ Verify the endpoint returns the details for a single program. """ program = self.create_program() - with django_assert_num_queries(FuzzyInt(60, 2)): + with django_assert_num_queries(FuzzyInt(57, 2)): response = self.assert_retrieve_success(program) # property does not have the right values while being indexed del program._course_run_weeks_to_complete @@ -98,6 +132,37 @@ def test_retrieve(self, django_assert_num_queries): response = self.assert_retrieve_success(program, querystring={'use_full_course_serializer': 1}) assert response.data == self.serialize_program(program, extra_context={'use_full_course_serializer': 1}) + def test_retrieve_basic_curriculum(self, django_assert_num_queries): + program = self.create_program(courses=[]) + self.create_curriculum(program) + + # Notes on this query count: + # 37 queries to get program without a curriculum and no courses + # +2 for curriculum details (related courses, related programs) + # +8 for course details on 1 or more courses across all sibling curricula + with django_assert_num_queries(47): + response = self.assert_retrieve_success(program) + assert response.data == self.serialize_program(program) + + def test_retrieve_curriculum_with_child_programs(self, django_assert_num_queries): + parent_program = self.create_program(courses=[]) + curriculum = self.create_curriculum(parent_program) + + child_program1 = self.create_program() + child_program2 = self.create_program() + CurriculumProgramMembershipFactory( + program=child_program1, + curriculum=curriculum + ) + CurriculumProgramMembershipFactory( + program=child_program2, + curriculum=curriculum + ) + + with django_assert_num_queries(FuzzyInt(63, 2)): + response = self.assert_retrieve_success(parent_program) + assert response.data == self.serialize_program(parent_program) + @pytest.mark.parametrize('order_courses_by_start_date', (True, False,)) def test_retrieve_with_sorting_flag(self, order_courses_by_start_date, django_assert_num_queries): """ Verify the number of queries is the same with sorting flag set to true. """ @@ -110,16 +175,16 @@ def test_retrieve_with_sorting_flag(self, order_courses_by_start_date, django_as partner=self.partner) # property does not have the right values while being indexed del program._course_run_weeks_to_complete - with django_assert_num_queries(FuzzyInt(42, 2)): + with django_assert_num_queries(FuzzyInt(40, 1)): # travis is often 43 response = self.assert_retrieve_success(program) assert response.data == self.serialize_program(program) - assert course_list == list(program.courses.all()) # pylint: disable=no-member + assert course_list == list(program.courses.all()) def test_retrieve_without_course_runs(self, django_assert_num_queries): """ Verify the endpoint returns data for a program even if the program's courses have no course runs. """ course = CourseFactory(partner=self.partner) program = ProgramFactory(courses=[course], partner=self.partner) - with django_assert_num_queries(FuzzyInt(27, 2)): + with django_assert_num_queries(FuzzyInt(32, 2)): response = self.assert_retrieve_success(program) assert response.data == self.serialize_program(program) @@ -145,9 +210,8 @@ def assert_list_results(self, url, expected, expected_query_count, extra_context def test_list(self): """ Verify the endpoint returns a list of all programs. """ expected = [self.create_program() for __ in range(3)] - expected.reverse() - self.assert_list_results(self.list_path, expected, 28) + self.assert_list_results(self.list_path, expected, 19) def test_uuids_only(self): """ @@ -174,9 +238,9 @@ def test_uuids_only(self): def test_filter_by_type(self): """ Verify that the endpoint filters programs to those of a given type. """ program_type_name = 'foo' - program = ProgramFactory(type__name=program_type_name, partner=self.partner) + program = ProgramFactory(type__name_t=program_type_name, partner=self.partner) url = self.list_path + '?type=' + program_type_name - self.assert_list_results(url, [program], 11) + self.assert_list_results(url, [program], 12) url = self.list_path + '?type=bar' self.assert_list_results(url, [], 5) @@ -184,32 +248,30 @@ def test_filter_by_type(self): def test_filter_by_types(self): """ Verify that the endpoint filters programs to those matching the provided ProgramType slugs. """ expected = ProgramFactory.create_batch(2, partner=self.partner) - expected.reverse() type_slugs = [p.type.slug for p in expected] url = self.list_path + '?types=' + ','.join(type_slugs) # Create a third program, which should be filtered out. ProgramFactory(partner=self.partner) - self.assert_list_results(url, expected, 12) + self.assert_list_results(url, expected, 14) def test_filter_by_uuids(self): """ Verify that the endpoint filters programs to those matching the provided UUIDs. """ expected = ProgramFactory.create_batch(2, partner=self.partner) - expected.reverse() uuids = [str(p.uuid) for p in expected] url = self.list_path + '?uuids=' + ','.join(uuids) # Create a third program, which should be filtered out. ProgramFactory(partner=self.partner) - self.assert_list_results(url, expected, 12) + self.assert_list_results(url, expected, 14) @pytest.mark.parametrize( 'status,is_marketable,expected_query_count', ( (ProgramStatus.Unpublished, False, 5), - (ProgramStatus.Active, True, 13), + (ProgramStatus.Active, True, 14), ) ) def test_filter_by_marketable(self, status, is_marketable, expected_query_count): @@ -217,7 +279,6 @@ def test_filter_by_marketable(self, status, is_marketable, expected_query_count) url = self.list_path + '?marketable=1' ProgramFactory(marketing_slug='', partner=self.partner) programs = ProgramFactory.create_batch(3, status=status, partner=self.partner) - programs.reverse() expected = programs if is_marketable else [] assert list(Program.objects.marketable()) == expected @@ -229,13 +290,13 @@ def test_filter_by_status(self): retired = ProgramFactory(status=ProgramStatus.Retired, partner=self.partner) url = self.list_path + '?status=active' - self.assert_list_results(url, [active], 11) + self.assert_list_results(url, [active], 12) url = self.list_path + '?status=retired' - self.assert_list_results(url, [retired], 11) + self.assert_list_results(url, [retired], 12) url = self.list_path + '?status=active&status=retired' - self.assert_list_results(url, [retired, active], 12) + self.assert_list_results(url, [active, retired], 14) def test_filter_by_hidden(self): """ Endpoint should filter programs by their hidden attribute value. """ @@ -243,16 +304,16 @@ def test_filter_by_hidden(self): not_hidden = ProgramFactory(hidden=False, partner=self.partner) url = self.list_path + '?hidden=True' - self.assert_list_results(url, [hidden], 11) + self.assert_list_results(url, [hidden], 12) url = self.list_path + '?hidden=False' - self.assert_list_results(url, [not_hidden], 11) + self.assert_list_results(url, [not_hidden], 12) url = self.list_path + '?hidden=1' - self.assert_list_results(url, [hidden], 11) + self.assert_list_results(url, [hidden], 12) url = self.list_path + '?hidden=0' - self.assert_list_results(url, [not_hidden], 11) + self.assert_list_results(url, [not_hidden], 12) def test_filter_by_marketing_slug(self): """ The endpoint should support filtering programs by marketing slug. """ @@ -261,21 +322,131 @@ def test_filter_by_marketing_slug(self): # This program should not be included in the results below because it never matches the filter. self.create_program() - url = '{root}?marketing_slug={slug}'.format(root=self.list_path, slug=SLUG) + url = f'{self.list_path}?marketing_slug={SLUG}' self.assert_list_results(url, [], 5) program = self.create_program() program.marketing_slug = SLUG program.save() - self.assert_list_results(url, [program], 20) + self.assert_list_results(url, [program], 19) def test_list_exclude_utm(self): """ Verify the endpoint returns marketing URLs without UTM parameters. """ url = self.list_path + '?exclude_utm=1' program = self.create_program() - self.assert_list_results(url, [program], 19, extra_context={'exclude_utm': 1}) + self.assert_list_results(url, [program], 18, extra_context={'exclude_utm': 1}) def test_minimal_serializer_use(self): """ Verify that the list view uses the minimal serializer. """ assert ProgramViewSet(action='list').get_serializer_class() == MinimalProgramSerializer + + def test_create_using_api(self): + """ + Verify endpoint successfully creates a program. + """ + response = self.client.post(self.list_path, self._program_data(), format='json') + assert response.status_code == 201 + program = Program.objects.last() + assert program.title == response.data['title'] + assert program.status == response.data['status'] + assert program.courses.count() == 3 + assert program.authoring_organizations.count() == 3 + assert program.credit_backing_organizations.count() == 3 + + def test_update_using_api(self): + """ + Verify endpoint successfully updates a program. + """ + program_data = self._program_data() + + response = self.client.post(self.list_path, program_data, format='json') + assert response.status_code == 201 + program = Program.objects.last() + assert program.courses.count() == 3 + assert program.authoring_organizations.count() == 3 + assert program.credit_backing_organizations.count() == 3 + + program_detail_url = reverse('api:v1:program-detail', kwargs={'uuid': str(program.uuid)}) + program.title = '{orignal_title} Test Update'.format(orignal_title=program_data['title']) + program_data['status'] = 'unpublished' + + course_runs = CourseRunFactory.create_batch(2) + course_runs = [course_run.key for course_run in course_runs] + program_data['course_runs'] = program_data['course_runs'] + course_runs + + organizations = OrganizationFactory.create_batch(3) + organizations = [organization.key for organization in organizations] + program_data['authoring_organizations'] = program_data['authoring_organizations'] + organizations + program_data['credit_backing_organizations'] = program_data['credit_backing_organizations'] + organizations + + data = json.dumps(program_data) + response = self.client.patch(program_detail_url, data, content_type='application/json') + assert response.status_code == 200 + program = Program.objects.last() + assert program.title == response.data['title'] + assert program.status == response.data['status'] + assert program.courses.count() == 5 + assert program.authoring_organizations.count() == 6 + assert program.credit_backing_organizations.count() == 6 + + course_runs = CourseRunFactory.create_batch(2) + course_runs = [course_run.key for course_run in course_runs] + course_runs.append(program_data['course_runs'][0]) + program_data['course_runs'] = course_runs + + organizations = OrganizationFactory.create_batch(3) + organizations = [organization.key for organization in organizations] + organizations.append(program_data['authoring_organizations'][0]) + program_data['authoring_organizations'] = organizations + program_data['credit_backing_organizations'] = organizations + + data = json.dumps(program_data) + response = self.client.patch(program_detail_url, data, content_type='application/json') + assert response.status_code == 200 + program = Program.objects.last() + assert program.courses.count() == 3 + assert program.authoring_organizations.count() == 4 + assert program.credit_backing_organizations.count() == 4 + def test_update_card_image(self): + program = self.create_program() + image_dict = { + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + update_url = reverse('api:v1:program-update-card-image', kwargs={'uuid': program.uuid}) + response = self.client.post(update_url, image_dict, format='json') + assert response.status_code == 200 + + def test_update_card_image_authentication(self): + program = self.create_program() + self.client.logout() + image_dict = { + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + update_url = reverse('api:v1:program-update-card-image', kwargs={'uuid': program.uuid}) + response = self.client.post(update_url, image_dict, format='json') + assert response.status_code == 401 + + def test_update_card_image_authentication_notstaff(self): + program = self.create_program() + self.client.logout() + user = UserFactory(is_staff=False) + self.client.login(username=user.username, password=USER_PASSWORD) + image_dict = { + 'image': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY' + '42YAAAAASUVORK5CYII=', + } + update_url = reverse('api:v1:program-update-card-image', kwargs={'uuid': program.uuid}) + response = self.client.post(update_url, image_dict, format='json') + assert response.status_code == 403 + + def test_update_card_malformed_image(self): + program = self.create_program() + image_dict = { + 'image': 'ARandomString', + } + update_url = reverse('api:v1:program-update-card-image', kwargs={'uuid': program.uuid}) + response = self.client.post(update_url, image_dict, format='json') + assert response.status_code == 400 diff --git a/course_discovery/apps/api/v1/tests/test_views/test_search.py b/course_discovery/apps/api/v1/tests/test_views/test_search.py index 225be9118b..8a41bd8d24 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_search.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_search.py @@ -1,20 +1,24 @@ import datetime +import json import urllib.parse import ddt import pytz +from django.test import TestCase from django.urls import reverse +from rest_framework.renderers import JSONRenderer from course_discovery.apps.api import serializers from course_discovery.apps.api.v1.tests.test_views import mixins -from course_discovery.apps.api.v1.views.search import TypeaheadSearchView -from course_discovery.apps.core.tests.factories import PartnerFactory +from course_discovery.apps.api.v1.views.search import BrowsableAPIRendererWithoutForms, TypeaheadSearchView +from course_discovery.apps.core.tests.factories import USER_PASSWORD, PartnerFactory, UserFactory from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.models import CourseRun from course_discovery.apps.course_metadata.tests.factories import ( - CourseFactory, CourseRunFactory, OrganizationFactory, ProgramFactory + CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, PositionFactory, ProgramFactory ) +from course_discovery.apps.publisher.tests import factories as publisher_factories @ddt.ddt @@ -28,12 +32,12 @@ class CourseRunSearchViewSetTests(mixins.SerializationMixin, mixins.LoginMixin, def get_response(self, query=None, path=None): qs = urllib.parse.urlencode({'q': query}) if query else '' path = path or self.list_path - url = '{path}?{qs}'.format(path=path, qs=qs) + url = f'{path}?{qs}' return self.client.get(url) def build_facet_url(self, params): - return 'http://testserver.fake{path}?{query}'.format( - path=self.faceted_path, query=urllib.parse.urlencode(params) + return 'http://{domain}{path}?{query}'.format( + domain=self.site.domain, path=self.faceted_path, query=urllib.parse.urlencode(params) ) def assert_successful_search(self, path=None, serializer=None): @@ -44,7 +48,7 @@ def assert_successful_search(self, path=None, serializer=None): response = self.get_response('software', path=path) assert response.status_code == 200 - response_data = response.json() + response_data = response.data # Validate the search results expected = { @@ -85,7 +89,7 @@ def test_authentication(self, path): """ Verify the endpoint requires authentication. """ self.client.logout() response = self.get_response(path=path) - assert response.status_code == 403 + assert response.status_code == 401 @ddt.data( (list_path, serializers.CourseRunSearchSerializer,), @@ -110,13 +114,13 @@ def test_faceted_search(self): def test_invalid_query_facet(self): """ Verify the endpoint returns HTTP 400 if an invalid facet is requested. """ facet = 'not-a-facet' - url = '{path}?selected_query_facets={facet}'.format(path=self.faceted_path, facet=facet) + url = f'{self.faceted_path}?selected_query_facets={facet}' response = self.client.get(url) assert response.status_code == 400 response_data = response.json() - expected = {'detail': 'The selected query facet [{facet}] is not valid.'.format(facet=facet)} + expected = {'detail': f'The selected query facet [{facet}] is not valid.'} assert response_data == expected def test_availability_faceting(self): @@ -157,13 +161,13 @@ def test_availability_faceting(self): @ddt.data( (list_path, serializers.CourseRunSearchSerializer, - ['results', 0, 'program_types', 0], ProgramStatus.Deleted, 8), + ['results', 0, 'program_types', 0], ProgramStatus.Deleted, 5), (list_path, serializers.CourseRunSearchSerializer, - ['results', 0, 'program_types', 0], ProgramStatus.Unpublished, 8), + ['results', 0, 'program_types', 0], ProgramStatus.Unpublished, 5), (detailed_path, serializers.CourseRunSearchModelSerializer, - ['results', 0, 'programs', 0, 'type'], ProgramStatus.Deleted, 40), + ['results', 0, 'programs', 0, 'type'], ProgramStatus.Deleted, 22), (detailed_path, serializers.CourseRunSearchModelSerializer, - ['results', 0, 'programs', 0, 'type'], ProgramStatus.Unpublished, 42), + ['results', 0, 'programs', 0, 'type'], ProgramStatus.Unpublished, 23), ) @ddt.unpack def test_exclude_unavailable_program_types(self, path, serializer, result_location_keys, program_status, @@ -175,31 +179,31 @@ def test_exclude_unavailable_program_types(self, path, serializer, result_locati ProgramFactory(courses=[course_run.course], status=program_status) self.reindex_courses(active_program) - with self.assertNumQueries(expected_queries): + with self.assertNumQueries(expected_queries, threshold=1): # travis sometimes adds a query response = self.get_response('software', path=path) - assert response.status_code == 200 - response_data = response.json() + assert response.status_code == 200 + response_data = response.data - # Validate the search results - expected = { - 'count': 1, - 'results': [ - self.serialize_course_run_search(course_run, serializer=serializer) - ] - } - self.assertDictContainsSubset(expected, response_data) + # Validate the search results + expected = { + 'count': 1, + 'results': [ + self.serialize_course_run_search(course_run, serializer=serializer) + ] + } + self.assertDictContainsSubset(expected, response_data) - # Check that the program is indeed the active one. - for key in result_location_keys: - response_data = response_data[key] - assert response_data == active_program.type.name + # Check that the program is indeed the active one. + for key in result_location_keys: + response_data = response_data[key] + assert response_data == active_program.type.name @ddt.data( ([{'title': 'Software Testing', 'excluded': True}], 6), ([{'title': 'Software Testing', 'excluded': True}, {'title': 'Software Testing 2', 'excluded': True}], 7), ([{'title': 'Software Testing', 'excluded': False}, {'title': 'Software Testing 2', 'excluded': False}], 7), ([{'title': 'Software Testing', 'excluded': True}, {'title': 'Software Testing 2', 'excluded': True}, - {'title': 'Software Testing 3', 'excluded': False}], 8), + {'title': 'Software Testing 3', 'excluded': False}], 5), ) @ddt.unpack def test_excluded_course_run(self, course_runs, expected_queries): @@ -244,15 +248,15 @@ def test_excluded_course_run(self, course_runs, expected_queries): @ddt.ddt class AggregateSearchViewSetTests(mixins.SerializationMixin, mixins.LoginMixin, ElasticsearchTestMixin, mixins.SynonymTestMixin, mixins.APITestCase): - path = reverse('api:v1:search-all-facets') - def get_response(self, query=None): + def get_response(self, query=None, endpoint='api:v1:search-all-facets'): qs = '' if query: qs = urllib.parse.urlencode(query) - url = '{path}?{qs}'.format(path=self.path, qs=qs) + path = reverse(endpoint) + url = f'{path}?{qs}' return self.client.get(url) def process_response(self, response): @@ -287,6 +291,15 @@ def test_hidden_runs_excluded(self): data = response.json() assert data['objects']['results'] == [self.serialize_course_run_search(visible_run)] + def test_non_marketable_runs_excluded(self): + """Search results should not include non-marketable runs.""" + marketable_run = CourseRunFactory(course__partner=self.partner, type__is_marketable=True) + CourseRunFactory(course__partner=self.partner, type__is_marketable=False) + + response = self.get_response() + data = response.json() + self.assertListEqual(data['objects']['results'], [self.serialize_course_run_search(marketable_run)]) + def test_results_filtered_by_default_partner(self): """ Verify the search results only include items related to the default partner if no partner is specified on the request. If a partner is included, the data should be filtered to the requested partner. """ @@ -313,6 +326,40 @@ def test_results_filtered_by_default_partner(self): assert response_data['objects']['results'] == \ [self.serialize_program_search(other_program), self.serialize_course_run_search(other_course_run)] + @ddt.data((True, 9), (False, 9)) + @ddt.unpack + def test_query_count_exclude_expired_course_run(self, exclude_expired, expected_queries): + """ Verify that there is no query explosion when excluding expired course runs. """ + program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active) + course_run = CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Published) + course_run2 = CourseRunFactory(course=course_run.course, status=CourseRunStatus.Published) + course_run3 = CourseRunFactory(course=course_run.course, status=CourseRunStatus.Published) + course_run4 = CourseRunFactory(course=course_run.course, status=CourseRunStatus.Published) + self.reindex_courses(program) + + query = {'partner': self.partner.short_code} + if exclude_expired: + query['exclude_expired_course_run'] = 'True' + + # Filter results by partner + with self.assertNumQueries(expected_queries): + response = self.get_response( + query, + endpoint='api:v1:search-all-list' + ) + assert response.status_code == 200 + response_data = response.json() + expected = [ + self.serialize_course_run_search(run) + for run in (course_run, course_run2, course_run3, course_run4) + ] + [ + self.serialize_program_search(program), + # We need to render the json, and then parse it again, to get all of the formatted + # data the same as the data coming out of search. + json.loads(JSONRenderer().render(self.serialize_course_search(course_run.course)).decode('utf-8')), + ] + self.assertCountEqual(response_data['results'], expected) + def test_empty_query(self): """ Verify, when the query (q) parameter is empty, the endpoint behaves as if the parameter was not provided. """ @@ -335,7 +382,8 @@ def test_results_ordered_by_start_date(self, ordering): upcoming = CourseRunFactory(course__partner=self.partner, start=now + datetime.timedelta(weeks=4)) course_run_keys = [course_run.key for course_run in [archived, current, starting_soon, upcoming]] - response = self.get_response({"ordering": ordering}) + with self.assertNumQueries(6): + response = self.get_response({"ordering": ordering}) assert response.status_code == 200 assert response.data['objects']['count'] == 4 @@ -343,6 +391,22 @@ def test_results_ordered_by_start_date(self, ordering): expected = [self.serialize_course_run_search(course_run) for course_run in course_runs] assert response.data['objects']['results'] == expected + @ddt.data(True, False) + def test_results_ordered_by_aggregation_key(self, ascending): + """ Verify the search results can be ordered by start date """ + run1 = CourseRunFactory(course__partner=self.partner, course__key='edX+DemoX') + run2 = CourseRunFactory(course__partner=self.partner, course__key='fakeX+FakeX') + + with self.assertNumQueries(6): + response = self.get_response({'ordering': 'aggregation_key' if ascending else '-aggregation_key'}) + assert response.status_code == 200 + assert response.data['objects']['count'] == 2 + + run1_data = self.serialize_course_run_search(run1) + run2_data = self.serialize_course_run_search(run2) + expected = [run1_data, run2_data] if ascending else [run2_data, run1_data] + assert response.data['objects']['results'] == expected + def test_results_include_aggregation_key(self): """ Verify the search results only include the aggregation_key for each document. """ course_run = CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Published) @@ -352,6 +416,134 @@ def test_results_include_aggregation_key(self): assert response.status_code == 200 response_data = response.json() + expected = sorted( + [f'courserun:{course_run.course.key}', f'program:{program.uuid}'] + ) + actual = sorted( + [obj.get('aggregation_key') for obj in response_data['objects']['results']] + ) + assert expected == actual + + +class LimitedAggregateSearchViewSetTests( + ElasticsearchTestMixin, mixins.LoginMixin, mixins.SerializationMixin, mixins.APITestCase +): + path = reverse('api:v1:search-limited-facets') + + # pylint: disable=no-member + def serialize_course_run_search(self, run): + return super().serialize_course_run_search(run, serializers.LimitedAggregateSearchSerializer) + + # pylint: disable=no-member + def serialize_program_search(self, program): + return super().serialize_program_search(program, serializers.LimitedAggregateSearchSerializer) + + # pylint: disable=no-member + def serialize_course_search(self, course): + return super().serialize_course_search(course, serializers.LimitedAggregateSearchSerializer) + + def test_results_only_include_published_objects(self): + """ Verify the search results only include items with status set to 'Published'. """ + # These items should NOT be in the results + CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Unpublished) + ProgramFactory(partner=self.partner, status=ProgramStatus.Unpublished) + + course_run = CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Published) + program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active) + + with self.assertNumQueries(5): + response = self.client.get(self.path) + assert response.status_code == 200 + response_data = response.json() + assert response_data['objects']['results'] == \ + [self.serialize_program_search(program), self.serialize_course_run_search(course_run)] + + def test_hidden_runs_excluded(self): + """Search results should not include hidden runs.""" + visible_run = CourseRunFactory(course__partner=self.partner) + hidden_run = CourseRunFactory(course__partner=self.partner, hidden=True) + + assert CourseRun.objects.get(hidden=True) == hidden_run + + with self.assertNumQueries(5): + response = self.client.get(self.path) + data = response.json() + assert data['objects']['results'] == [self.serialize_course_run_search(visible_run)] + + def test_results_include_aggregation_key(self): + """ Verify the search results only include the aggregation_key for each document. """ + course_run = CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Published) + program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active) + + with self.assertNumQueries(5): + response = self.client.get(self.path) + assert response.status_code == 200 + response_data = response.json() + + expected = sorted( + [f'courserun:{course_run.course.key}', f'program:{program.uuid}'] + ) + actual = sorted( + [obj.get('aggregation_key') for obj in response_data['objects']['results']] + ) + assert expected == actual + + +class LimitedAggregateSearchViewSetTests( + ElasticsearchTestMixin, mixins.LoginMixin, mixins.SerializationMixin, mixins.APITestCase +): + path = reverse('api:v1:search-limited-facets') + + # pylint: disable=no-member + def serialize_course_run_search(self, run): + return super().serialize_course_run_search(run, serializers.LimitedAggregateSearchSerializer) + + # pylint: disable=no-member + def serialize_program_search(self, program): + return super().serialize_program_search(program, serializers.LimitedAggregateSearchSerializer) + + # pylint: disable=no-member + def serialize_course_search(self, course): + return super().serialize_course_search(course, serializers.LimitedAggregateSearchSerializer) + + def test_results_only_include_published_objects(self): + """ Verify the search results only include items with status set to 'Published'. """ + # These items should NOT be in the results + CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Unpublished) + ProgramFactory(partner=self.partner, status=ProgramStatus.Unpublished) + + course_run = CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Published) + program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active) + + with self.assertNumQueries(5): + response = self.client.get(self.path) + assert response.status_code == 200 + response_data = response.json() + assert response_data['objects']['results'] == \ + [self.serialize_program_search(program), self.serialize_course_run_search(course_run)] + + def test_hidden_runs_excluded(self): + """Search results should not include hidden runs.""" + visible_run = CourseRunFactory(course__partner=self.partner) + hidden_run = CourseRunFactory(course__partner=self.partner, hidden=True) + + assert CourseRun.objects.get(hidden=True) == hidden_run + + with self.assertNumQueries(5): + response = self.client.get(self.path) + data = response.json() + assert data['objects']['results'] == [self.serialize_course_run_search(visible_run)] + + def test_results_include_aggregation_key(self): + """ Verify the search results only include the aggregation_key for each document. """ + course_run = CourseRunFactory(course__partner=self.partner, status=CourseRunStatus.Published) + program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active) + + with self.assertNumQueries(5): + response = self.client.get(self.path) + assert response.status_code == 200 + response_data = response.json() + expected = sorted( ['courserun:{}'.format(course_run.course.key), 'program:{}'.format(program.uuid)] ) @@ -372,7 +564,8 @@ def test_post(self): CourseFactory(key='course:edX+DemoX', title='ABCs of Ͳҽʂէìղց') data = {'content_type': 'course', 'aggregation_key': ['course:edX+DemoX']} expected = {'previous': None, 'results': [], 'next': None, 'count': 0} - response = self.client.post(self.path, data=data, format='json') + with self.assertNumQueries(3): + response = self.client.post(self.path, data=data, format='json') assert response.json() == expected def test_get(self): @@ -383,11 +576,31 @@ def test_get(self): expected = {'previous': None, 'results': [], 'next': None, 'count': 0} query = {'content_type': 'course', 'aggregation_key': ['course:edX+DemoX']} qs = urllib.parse.urlencode(query) - url = '{path}?{qs}'.format(path=self.path, qs=qs) + url = f'{self.path}?{qs}' response = self.client.get(url) assert response.json() == expected +class BrowsableAPIRendererWithoutFormsTests(TestCase): + def setUp(self): + super().setUp() + self.method_args = ({}, {}, '', {}) + + def test_get_rendered_html_form(self): + """ + Verify that `get_rendered_html_form` returns `None` + """ + browsable_api_renderer = BrowsableAPIRendererWithoutForms() + assert browsable_api_renderer.get_rendered_html_form(*self.method_args) is None + + def test_get_raw_data_form(self): + """ + Verify that `get_raw_data_form` returns `None` + """ + browsable_api_renderer = BrowsableAPIRendererWithoutForms() + assert browsable_api_renderer.get_raw_data_form(*self.method_args) is None + + class TypeaheadSearchViewTests(mixins.TypeaheadSerializationMixin, mixins.LoginMixin, ElasticsearchTestMixin, mixins.SynonymTestMixin, mixins.APITestCase): path = reverse('api:v1:search-typeahead') @@ -397,7 +610,7 @@ def get_response(self, query=None, partner=None): query_dict.update({'partner': partner or self.partner.short_code}) qs = urllib.parse.urlencode(query_dict) - url = '{path}?{qs}'.format(path=self.path, qs=qs) + url = f'{self.path}?{qs}' return self.client.get(url) def process_response(self, response): @@ -421,8 +634,8 @@ def test_typeahead_multiple_results(self): RESULT_COUNT = TypeaheadSearchView.RESULT_COUNT title = "Test" for i in range(RESULT_COUNT + 1): - CourseRunFactory(title="{}{}".format(title, i), course__partner=self.partner) - ProgramFactory(title="{}{}".format(title, i), status=ProgramStatus.Active, partner=self.partner) + CourseRunFactory(title=f"{title}{i}", course__partner=self.partner) + ProgramFactory(title=f"{title}{i}", status=ProgramStatus.Active, partner=self.partner) response = self.get_response({'q': title}) self.assertEqual(response.status_code, 200) response_data = response.json() @@ -436,9 +649,9 @@ def test_typeahead_deduplicate_course_runs(self): course1 = CourseFactory(partner=self.partner) course2 = CourseFactory(partner=self.partner) for i in range(RESULT_COUNT): - CourseRunFactory(title="{}{}{}".format(title, course1.title, i), course=course1) + CourseRunFactory(title=f"{title}{course1.title}{i}", course=course1) for i in range(RESULT_COUNT): - CourseRunFactory(title="{}{}{}".format(title, course2.title, i), course=course2) + CourseRunFactory(title=f"{title}{course2.title}{i}", course=course2) response = self.get_response({'q': title}) assert response.status_code == 200 response_data = response.json() @@ -525,12 +738,22 @@ def test_exception(self): def test_typeahead_authoring_organizations_partial_search(self): """ Test typeahead response with partial organization matching. """ authoring_organizations = OrganizationFactory.create_batch(3) - course_run = CourseRunFactory(authoring_organizations=authoring_organizations, course__partner=self.partner) - program = ProgramFactory(authoring_organizations=authoring_organizations, partner=self.partner) + course_run = CourseRunFactory.create(course__partner=self.partner) + program = ProgramFactory.create(partner=self.partner) + for authoring_organization in authoring_organizations: + course_run.authoring_organizations.add(authoring_organization) + program.authoring_organizations.add(authoring_organization) + course_run.save() + program.save() partial_key = authoring_organizations[0].key[0:5] response = self.get_response({'q': partial_key}) self.assertEqual(response.status_code, 200) + + # This call is flaky in Travis. It is reliable locally, but occasionally in our CI environment, + # this call won't contain the data for course_runs and programs. Instead of relying on the factories + # we now explicitly add the authoring organizations to a course_run and program and call .save() + # in order to update the search indexes. expected = { 'course_runs': [self.serialize_course_run_search(course_run)], 'programs': [self.serialize_program_search(program)] @@ -570,3 +793,192 @@ def test_typeahead_org_course_runs_come_up_first(self): self.serialize_program_search(harvard_program)] } self.assertDictEqual(response.data, expected) + + +class TestPersonFacetSearchViewSet(mixins.SerializationMixin, mixins.LoginMixin, + ElasticsearchTestMixin, mixins.APITestCase): + path = reverse('api:v1:search-people-facets') + + def test_search_single(self): + org = OrganizationFactory() + course = CourseFactory(authoring_organizations=[org]) + person1 = PersonFactory(partner=self.partner) + person2 = PersonFactory(partner=self.partner) + PersonFactory(partner=self.partner) + CourseRunFactory(staff=[person1, person2], course=course) + + facet_name = f'organizations_exact:{org.key}' + self.reindex_people(person1) + self.reindex_people(person2) + + query = {'selected_facets': facet_name} + qs = urllib.parse.urlencode(query) + url = f'{self.path}?{qs}' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_data = response.json() + self.assertEqual(response_data['objects']['count'], 2) + + query = {'selected_facets': facet_name, 'q': person1.uuid} + qs = urllib.parse.urlencode(query) + url = f'{self.path}?{qs}' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_data = response.json() + self.assertEqual(response_data['objects']['count'], 1) + self.assertEqual(response_data['objects']['results'][0]['uuid'], str(person1.uuid)) + self.assertEqual(response_data['objects']['results'][0]['full_name'], person1.full_name) + + +class AutoCompletePersonTests(mixins.APITestCase): + """ + Tests for person autocomplete lookups + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True) + self.client.login(username=self.user.username, password=USER_PASSWORD) + + first_instructor = PersonFactory(given_name="First", family_name="Instructor") + second_instructor = PersonFactory(given_name="Second", family_name="Instructor") + self.instructors = [first_instructor, second_instructor] + + self.organizations = OrganizationFactory.create_batch(3) + self.organization_extensions = [] + + for instructor in self.instructors: + PositionFactory(organization=self.organizations[0], title="professor", person=instructor) + + for organization in self.organizations: + org_ex = publisher_factories.OrganizationExtensionFactory(organization=organization) + self.organization_extensions.append(org_ex) + + disco_course = CourseFactory(authoring_organizations=[self.organizations[0]]) + disco_course2 = CourseFactory(authoring_organizations=[self.organizations[1]]) + CourseRunFactory(course=disco_course, staff=[first_instructor]) + CourseRunFactory(course=disco_course2, staff=[second_instructor]) + + self.user.groups.add(self.organization_extensions[0].group) + + def query(self, q): + query_params = f'?q={q}' + path = reverse('api:v1:person-search-typeahead') + return self.client.get(path + query_params) + + def test_instructor_autocomplete(self): + """ Verify instructor autocomplete returns the data. """ + response = self.query('ins') + self._assert_response(response, 2) + + # update first instructor's name + self.instructors[0].given_name = 'dummy_name' + self.instructors[0].save() + + response = self.query('dummy') + self._assert_response(response, 1) + + def test_instructor_autocomplete_non_staff_user(self): + """ Verify instructor autocomplete works for non-staff users. """ + self._make_user_non_staff() + response = self.query('dummy') + self._assert_response(response, 0) + + def test_instructor_autocomplete_no_query_param(self): + """ Verify instructor autocomplete returns bad response for request with no query. """ + self._make_user_non_staff() + response = self.client.get(reverse('api:v1:person-search-typeahead')) + self._assert_error_response(response, ["The 'q' querystring parameter is required for searching."], 400) + + def test_instructor_autocomplete_spaces(self): + """ Verify instructor autocomplete allows spaces. """ + response = self.query('sec ins') + self._assert_response(response, 1) + + def test_instructor_autocomplete_no_results(self): + """ Verify instructor autocomplete correctly finds no matches if string doesn't match. """ + response = self.query('second nope') + self._assert_response(response, 0) + + def test_instructor_autocomplete_last_name_first_name(self): + """ Verify instructor autocomplete allows last name first. """ + response = self.query('instructor first') + self._assert_response(response, 1) + + def test_instructor_position_in_label(self): + """ Verify that instructor label contains position of instructor if it exists.""" + position_title = 'professor' + + response = self.query('ins') + + self.assertContains(response, position_title) + + def test_instructor_image_in_label(self): + """ Verify that instructor label contains profile image url.""" + response = self.query('ins') + self.assertContains(response, self.instructors[0].get_profile_image_url) + self.assertContains(response, self.instructors[1].get_profile_image_url) + + def _assert_response(self, response, expected_length): + """ Assert autocomplete response. """ + assert response.status_code == 200 + data = json.loads(response.content.decode('utf-8')) + assert len(data) == expected_length + + def _assert_error_response(self, response, expected_response, expected_response_code=200): + """ Assert autocomplete response. """ + assert response.status_code == expected_response_code + data = json.loads(response.content.decode('utf-8')) + assert data == expected_response + + def test_instructor_autocomplete_with_uuid(self): + """ Verify instructor autocomplete returns the data with valid uuid. """ + uuid = self.instructors[0].uuid + response = self.query(uuid) + self._assert_response(response, 1) + + def test_instructor_autocomplete_with_invalid_uuid(self): + """ Verify instructor autocomplete returns empty list without giving error. """ + uuid = 'invalid-uuid' + response = self.query(uuid) + self._assert_response(response, 0) + + def test_instructor_autocomplete_without_staff_user(self): + """ Verify instructor autocomplete returns the data if user is not staff. """ + non_staff_user = UserFactory() + non_staff_user.groups.add(self.organization_extensions[0].group) + self.client.logout() + self.client.login(username=non_staff_user.username, password=USER_PASSWORD) + + response = self.query('ins') + self._assert_response(response, 2) + + def test_instructor_autocomplete_without_login(self): + """ Verify instructor autocomplete returns a forbidden code if user is not logged in. """ + self.client.logout() + person_autocomplete_url = reverse( + 'api:v1:person-search-typeahead' + ) + '?q={q}'.format(q=self.instructors[0].uuid) + + response = self.client.get(person_autocomplete_url) + self._assert_error_response(response, {'detail': 'Authentication credentials were not provided.'}, 401) + + def test_autocomplete_limit_by_org(self): + org = self.organizations[0] + person_autocomplete_url = reverse( + 'api:v1:person-search-typeahead' + ) + '?q=ins' + single_autocomplete_url = person_autocomplete_url + f'&org={org.key}' + response = self.client.get(single_autocomplete_url) + self._assert_response(response, 1) + + org2 = self.organizations[1] + multiple_autocomplete_url = single_autocomplete_url + f'&org={org2.key}' + response = self.client.get(multiple_autocomplete_url) + self._assert_response(response, 2) + + def _make_user_non_staff(self): + self.client.logout() + self.user = UserFactory(is_staff=False) + self.user.save() + self.client.login(username=self.user.username, password=USER_PASSWORD) diff --git a/course_discovery/apps/api/v1/tests/test_views/test_subjects.py b/course_discovery/apps/api/v1/tests/test_views/test_subjects.py index 9799f9097a..4193e5257d 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_subjects.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_subjects.py @@ -9,7 +9,7 @@ class SubjectViewSetTests(SerializationMixin, APITestCase): list_path = reverse('api:v1:subject-list') def setUp(self): - super(SubjectViewSetTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) self.client.login(username=self.user.username, password=USER_PASSWORD) @@ -20,7 +20,7 @@ def test_authentication(self): self.client.logout() response = self.client.get(self.list_path) - assert response.status_code == 403 + assert response.status_code == 401 def test_retrieve(self): """ The request should return details for a single subject. """ diff --git a/course_discovery/apps/api/v1/tests/test_views/test_topics.py b/course_discovery/apps/api/v1/tests/test_views/test_topics.py index d884aff955..5ad9eff644 100644 --- a/course_discovery/apps/api/v1/tests/test_views/test_topics.py +++ b/course_discovery/apps/api/v1/tests/test_views/test_topics.py @@ -10,7 +10,7 @@ class TopicViewSetTests(SerializationMixin, APITestCase): list_path = reverse('api:v1:topic-list') def setUp(self): - super(TopicViewSetTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) self.client.login(username=self.user.username, password=USER_PASSWORD) @@ -21,7 +21,7 @@ def test_authentication(self): self.client.logout() response = self.client.get(self.list_path) - assert response.status_code == 403 + assert response.status_code == 401 def test_list(self): """ Verify the endpoint returns a list of all topic. """ diff --git a/course_discovery/apps/api/v1/tests/test_views/test_user_management.py b/course_discovery/apps/api/v1/tests/test_views/test_user_management.py new file mode 100644 index 0000000000..8fa3b8902a --- /dev/null +++ b/course_discovery/apps/api/v1/tests/test_views/test_user_management.py @@ -0,0 +1,93 @@ +import json +from unittest import mock + +import ddt +from django.urls import reverse + +from course_discovery.apps.api.tests.jwt_utils import generate_jwt_header_for_user +from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase +from course_discovery.apps.core.tests.factories import UserFactory + +JSON_CONTENT_TYPE = 'application/json' + + +@ddt.ddt +@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker') +class UsernameReplacementViewTests(APITestCase): + """ Tests UsernameReplacementView """ + SERVICE_USERNAME = 'test_replace_username_service_worker' + + def setUp(self): + super().setUp() + self.service_user = UserFactory(username=self.SERVICE_USERNAME) + self.url = reverse("api:v1:replace_usernames") + + def build_jwt_headers(self, user): + """ + Helper function for creating headers for the JWT authentication. + """ + token = generate_jwt_header_for_user(user) + headers = {'HTTP_AUTHORIZATION': token} + return headers + + def call_api(self, user, data): + """ Helper function to call API with data """ + data = json.dumps(data) + headers = self.build_jwt_headers(user) + return self.client.post(self.url, data, content_type=JSON_CONTENT_TYPE, **headers) + + def test_auth(self): + """ Verify the endpoint only works with the service worker """ + data = { + "username_mappings": [ + {"test_username_1": "test_new_username_1"}, + {"test_username_2": "test_new_username_2"} + ] + } + + # Test unauthenticated + response = self.client.post(self.url) + self.assertEqual(response.status_code, 401) + + # Test non-service worker + random_user = UserFactory() + response = self.call_api(random_user, data) + self.assertEqual(response.status_code, 403) + + # Test service worker + response = self.call_api(self.service_user, data) + self.assertEqual(response.status_code, 200) + + @ddt.data( + [{}, {}], + {}, + [{"test_key": "test_value", "test_key_2": "test_value_2"}] + ) + def test_bad_schema(self, mapping_data): + """ Verify the endpoint rejects bad data schema """ + data = { + "username_mappings": mapping_data + } + response = self.call_api(self.service_user, data) + self.assertEqual(response.status_code, 400) + + def test_existing_and_non_existing_users(self): + """ + Tests a mix of existing and non existing users. Users that don't exist + in this service are also treated as a success because no work needs to + be done changing their username. + """ + random_users = [UserFactory() for _ in range(5)] + fake_usernames = ["myname_" + str(x) for x in range(5)] + existing_users = [{user.username: user.username + '_new'} for user in random_users] + non_existing_users = [{username: username + '_new'} for username in fake_usernames] + data = { + "username_mappings": existing_users + non_existing_users + } + expected_response = { + 'failed_replacements': [], + 'successful_replacements': existing_users + non_existing_users + } + response = self.call_api(self.service_user, data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, expected_response) diff --git a/course_discovery/apps/api/v1/urls.py b/course_discovery/apps/api/v1/urls.py index 1c1c09a09c..7fabc92423 100644 --- a/course_discovery/apps/api/v1/urls.py +++ b/course_discovery/apps/api/v1/urls.py @@ -3,12 +3,16 @@ from rest_framework import routers from course_discovery.apps.api.v1.views import search as search_views -from course_discovery.apps.api.v1.views.affiliates import AffiliateWindowViewSet +from course_discovery.apps.api.v1.views.affiliates import AffiliateWindowViewSet, ProgramsAffiliateWindowViewSet from course_discovery.apps.api.v1.views.catalog_queries import CatalogQueryContainsViewSet from course_discovery.apps.api.v1.views.catalogs import CatalogViewSet +from course_discovery.apps.api.v1.views.collaborators import CollaboratorViewSet +from course_discovery.apps.api.v1.views.comments import CommentViewSet +from course_discovery.apps.api.v1.views.course_editors import CourseEditorViewSet from course_discovery.apps.api.v1.views.course_runs import CourseRunViewSet from course_discovery.apps.api.v1.views.courses import CourseViewSet from course_discovery.apps.api.v1.views.currency import CurrencyView +from course_discovery.apps.api.v1.views.level_types import LevelTypeViewSet from course_discovery.apps.api.v1.views.organizations import OrganizationViewSet from course_discovery.apps.api.v1.views.pathways import PathwayViewSet from course_discovery.apps.api.v1.views.people import PersonViewSet @@ -16,32 +20,47 @@ from course_discovery.apps.api.v1.views.programs import ProgramViewSet from course_discovery.apps.api.v1.views.subjects import SubjectViewSet from course_discovery.apps.api.v1.views.topics import TopicViewSet +from course_discovery.apps.api.v1.views.user_management import UsernameReplacementView + +app_name = 'v1' partners_router = routers.SimpleRouter() -partners_router.register(r'affiliate_window/catalogs', AffiliateWindowViewSet, base_name='affiliate_window') +partners_router.register(r'affiliate_window/catalogs', AffiliateWindowViewSet, basename='affiliate_window') +partners_router.register( + r'affiliate_window/programs/catalogs', + ProgramsAffiliateWindowViewSet, + basename='programs_affiliate_window' +) urlpatterns = [ - url(r'^partners/', include(partners_router.urls, namespace='partners')), + url(r'^partners/', include((partners_router.urls, 'partners'))), url(r'search/typeahead', search_views.TypeaheadSearchView.as_view(), name='search-typeahead'), + url(r'^search/person_typeahead', search_views.PersonTypeaheadSearchView.as_view(), name='person-search-typeahead'), url(r'currency', CurrencyView.as_view(), name='currency'), - url(r'^catalog/query_contains/?', CatalogQueryContainsViewSet.as_view(), name='catalog-query_contains') + url(r'^catalog/query_contains/?', CatalogQueryContainsViewSet.as_view(), name='catalog-query_contains'), + url(r'^replace_usernames/$', UsernameReplacementView.as_view(), name="replace_usernames"), ] router = routers.SimpleRouter() router.register(r'catalogs', CatalogViewSet) -router.register(r'courses', CourseViewSet, base_name='course') -router.register(r'course_runs', CourseRunViewSet, base_name='course_run') -router.register(r'organizations', OrganizationViewSet, base_name='organization') -router.register(r'people', PersonViewSet, base_name='person') -router.register(r'subjects', SubjectViewSet, base_name='subject') -router.register(r'topics', TopicViewSet, base_name='topic') -router.register(r'pathways', PathwayViewSet, base_name='pathway') -router.register(r'programs', ProgramViewSet, base_name='program') -router.register(r'program_types', ProgramTypeViewSet, base_name='program_type') -router.register(r'search/all', search_views.AggregateSearchViewSet, base_name='search-all') -router.register(r'search/courses', search_views.CourseSearchViewSet, base_name='search-courses') -router.register(r'search/course_runs', search_views.CourseRunSearchViewSet, base_name='search-course_runs') -router.register(r'search/programs', search_views.ProgramSearchViewSet, base_name='search-programs') -router.register(r'search/people', search_views.PersonSearchViewSet, base_name='search-people') +router.register(r'comments', CommentViewSet, basename='comment') +router.register(r'courses', CourseViewSet, basename='course') +router.register(r'course_editors', CourseEditorViewSet, basename='course_editor') +router.register(r'course_runs', CourseRunViewSet, basename='course_run') +router.register(r'collaborators', CollaboratorViewSet, basename='collaborator') +router.register(r'organizations', OrganizationViewSet, basename='organization') +router.register(r'people', PersonViewSet, basename='person') +router.register(r'subjects', SubjectViewSet, basename='subject') +router.register(r'topics', TopicViewSet, basename='topic') +router.register(r'pathways', PathwayViewSet, basename='pathway') +router.register(r'programs', ProgramViewSet, basename='program') +router.register(r'level_types', LevelTypeViewSet, basename='level_type') +router.register(r'program_types', ProgramTypeViewSet, basename='program_type') +router.register(r'search/limited', search_views.LimitedAggregateSearchView, basename='search-limited') +router.register(r'search/all', search_views.AggregateSearchViewSet, basename='search-all') +router.register(r'search/courses', search_views.CourseSearchViewSet, basename='search-courses') +router.register(r'search/course_runs', search_views.CourseRunSearchViewSet, basename='search-course_runs') +router.register(r'search/programs', search_views.ProgramSearchViewSet, basename='search-programs') +router.register(r'search/people', search_views.PersonSearchViewSet, basename='search-people') urlpatterns += router.urls diff --git a/course_discovery/apps/api/v1/views/__init__.py b/course_discovery/apps/api/v1/views/__init__.py index c5678fd20b..e69de29bb2 100644 --- a/course_discovery/apps/api/v1/views/__init__.py +++ b/course_discovery/apps/api/v1/views/__init__.py @@ -1,34 +0,0 @@ -from django.contrib.auth import get_user_model - -from course_discovery.apps.api import serializers - -User = get_user_model() - - -def prefetch_related_objects_for_courses(queryset): - """ - Pre-fetches the related objects that will be serialized with a `Course`. - - Pre-fetching allows us to consolidate our database queries rather than run - thousands of queries as we serialize the data. For details, see the links below: - - - https://docs.djangoproject.com/en/1.10/ref/models/querysets/#select-related - - https://docs.djangoproject.com/en/1.10/ref/models/querysets/#prefetch-related - - Args: - queryset (QuerySet): original query - - Returns: - QuerySet - """ - _prefetch_fields = serializers.PREFETCH_FIELDS - _select_related_fields = serializers.SELECT_RELATED_FIELDS - - # Prefetch the data for the related course runs - course_run_prefetch_fields = _prefetch_fields['course_run'] + _select_related_fields['course_run'] - course_run_prefetch_fields = ['course_runs__' + field for field in course_run_prefetch_fields] - queryset = queryset.prefetch_related(*course_run_prefetch_fields) - - queryset = queryset.select_related(*_select_related_fields['course']) - queryset = queryset.prefetch_related(*_prefetch_fields['course']) - return queryset diff --git a/course_discovery/apps/api/v1/views/affiliates.py b/course_discovery/apps/api/v1/views/affiliates.py index f601650ef9..c904f4ff9f 100644 --- a/course_discovery/apps/api/v1/views/affiliates.py +++ b/course_discovery/apps/api/v1/views/affiliates.py @@ -8,7 +8,7 @@ from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer from course_discovery.apps.catalogs.models import Catalog -from course_discovery.apps.course_metadata.models import CourseRun, Seat +from course_discovery.apps.course_metadata.models import CourseRun, ProgramType, Seat class AffiliateWindowViewSet(viewsets.ViewSet): @@ -21,7 +21,7 @@ class AffiliateWindowViewSet(viewsets.ViewSet): # versions of this API should only support the system default, PageNumberPagination. pagination_class = ProxiedPagination - def retrieve(self, request, pk=None): # pylint: disable=redefined-builtin,unused-argument + def retrieve(self, request, pk=None): """ Return verified and professional seats of courses against provided catalog id. --- @@ -43,6 +43,9 @@ def retrieve(self, request, pk=None): # pylint: disable=redefined-builtin,unuse 'course_run__course', 'course_run__course__level_type', 'course_run__course__partner', + 'course_run__course__type', + 'course_run__type', + 'type', ).prefetch_related( 'course_run__course__authoring_organizations', 'course_run__course__subjects', @@ -50,3 +53,42 @@ def retrieve(self, request, pk=None): # pylint: disable=redefined-builtin,unuse serializer = serializers.AffiliateWindowSerializer(seats, many=True) return Response(serializer.data) + + +class ProgramsAffiliateWindowViewSet(viewsets.ViewSet): + permission_classes = (IsAuthenticated,) + renderer_classes = (AffiliateWindowXMLRenderer,) + serializer_class = serializers.ProgramsAffiliateWindowSerializer + + def retrieve(self, request, pk=None): + catalog = get_object_or_404(Catalog, pk=pk) + + if not catalog.has_object_read_permission(request): + raise PermissionDenied + + try: + exclude_type = ProgramType.objects.get(slug=ProgramType.MASTERS) + except ProgramType.DoesNotExist: + exclude_type = '' + programs = catalog.programs().marketable().exclude(type=exclude_type).select_related( + 'type', + 'partner', + ).prefetch_related( + 'excluded_course_runs', + 'type__applicable_seat_types', + 'type__translations', + 'courses', + 'courses__course_runs', + 'courses__course_runs__language', + 'courses__canonical_course_run', + 'courses__canonical_course_run__seats', + 'courses__canonical_course_run__seats__course_run__course', + 'courses__canonical_course_run__seats__type', + 'courses__canonical_course_run__seats__currency', + 'courses__course_runs__seats', + 'courses__entitlements', + 'courses__entitlements__currency', + 'courses__entitlements__mode', + ) + serializer = serializers.ProgramsAffiliateWindowSerializer(programs, many=True) + return Response(serializer.data) diff --git a/course_discovery/apps/api/v1/views/catalogs.py b/course_discovery/apps/api/v1/views/catalogs.py index 844ed90fc0..34bdd17c83 100644 --- a/course_discovery/apps/api/v1/views/catalogs.py +++ b/course_discovery/apps/api/v1/views/catalogs.py @@ -1,21 +1,23 @@ import datetime +from django.contrib.auth import get_user_model from django.db import transaction from django.http import StreamingHttpResponse from dry_rest_permissions.generics import DRYPermissions from rest_framework import status, viewsets -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.response import Response from course_discovery.apps.api import filters, serializers from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.renderers import CourseRunCSVRenderer -from course_discovery.apps.api.v1.views import User from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.course_metadata.models import CourseRun +User = get_user_model() -# pylint: disable=no-member + +# pylint: disable=useless-super-delegation class CatalogViewSet(viewsets.ModelViewSet): """ Catalog resource. """ @@ -52,7 +54,7 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): """ Destroy a catalog. """ - return super(CatalogViewSet, self).destroy(request, *args, **kwargs) + return super().destroy(request, *args, **kwargs) # pylint: disable=no-member def list(self, request, *args, **kwargs): """ Retrieve a list of all catalogs. @@ -65,22 +67,22 @@ def list(self, request, *args, **kwargs): paramType: query multiple: false """ - return super(CatalogViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) # pylint: disable=no-member def partial_update(self, request, *args, **kwargs): """ Update one, or more, fields for a catalog. """ - return super(CatalogViewSet, self).partial_update(request, *args, **kwargs) + return super().partial_update(request, *args, **kwargs) # pylint: disable=no-member def retrieve(self, request, *args, **kwargs): """ Retrieve details for a catalog. """ - return super(CatalogViewSet, self).retrieve(request, *args, **kwargs) + return super().retrieve(request, *args, **kwargs) # pylint: disable=no-member def update(self, request, *args, **kwargs): """ Update a catalog. """ - return super(CatalogViewSet, self).update(request, *args, **kwargs) + return super().update(request, *args, **kwargs) # pylint: disable=no-member - @detail_route() - def courses(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument + @action(detail=True) + def courses(self, request, id=None): # pylint: disable=redefined-builtin """ Retrieve the list of courses contained within this catalog. @@ -109,8 +111,8 @@ def courses(self, request, id=None): # pylint: disable=redefined-builtin,unused ) return self.get_paginated_response(serializer.data) - @detail_route() - def contains(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument + @action(detail=True) + def contains(self, request, id=None): # pylint: disable=redefined-builtin """ Determine if this catalog contains the provided courses. @@ -148,8 +150,8 @@ def contains(self, request, id=None): # pylint: disable=redefined-builtin,unuse serializer = serializers.ContainedCoursesSerializer(instance) return Response(serializer.data) - @detail_route() - def csv(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument + @action(detail=True) + def csv(self, request, id=None): # pylint: disable=redefined-builtin """ Retrieve a CSV containing the course runs contained within this catalog. diff --git a/course_discovery/apps/api/v1/views/collaborators.py b/course_discovery/apps/api/v1/views/collaborators.py new file mode 100644 index 0000000000..9030e583f8 --- /dev/null +++ b/course_discovery/apps/api/v1/views/collaborators.py @@ -0,0 +1,44 @@ +import logging + +from rest_framework import viewsets +from rest_framework.pagination import CursorPagination +from rest_framework.permissions import IsAuthenticated + +from course_discovery.apps.api import serializers +from course_discovery.apps.api.cache import CompressedCacheResponseMixin +from course_discovery.apps.api.permissions import IsInOrgOrReadOnly + +logger = logging.getLogger(__name__) + + +# pylint: disable=useless-super-delegation +class CollaboratorViewSet(CompressedCacheResponseMixin, viewsets.ModelViewSet): + """ CollaboratorSerializer resource. """ + + lookup_field = 'uuid' + lookup_value_regex = '[0-9a-f-]+' + permission_classes = (IsAuthenticated, IsInOrgOrReadOnly,) + queryset = serializers.CollaboratorSerializer.prefetch_queryset() + serializer_class = serializers.CollaboratorSerializer + pagination_class = CursorPagination + + def create(self, request, *args, **kwargs): + logger.info('The raw collaborator data coming from the publisher POST is {}.'.format(request.data)) + + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + logger.info('The raw collaborator data coming from the publisher PATCH is {}.'.format(request.data)) + + return super().update(request, *args, **kwargs) # pylint: disable=no-member + + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) # pylint: disable=no-member + + def list(self, request, *args, **kwargs): + """ Retrieve a list of all collaborators. """ + return super().list(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + """ Retieve details for a collaborator. """ + return super().retrieve(request, *args, **kwargs) diff --git a/course_discovery/apps/api/v1/views/comments.py b/course_discovery/apps/api/v1/views/comments.py new file mode 100644 index 0000000000..54e2451e23 --- /dev/null +++ b/course_discovery/apps/api/v1/views/comments.py @@ -0,0 +1,94 @@ +import logging + +from django.http.response import Http404 +from django.utils.translation import ugettext as _ +from rest_framework import status, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from course_discovery.apps.api.serializers import CommentSerializer +from course_discovery.apps.core.models import SalesforceConfiguration +from course_discovery.apps.course_metadata.emails import send_email_for_comment +from course_discovery.apps.course_metadata.models import Course, CourseEditor, Organization +from course_discovery.apps.course_metadata.salesforce import SalesforceMissingCaseException, SalesforceUtil +from course_discovery.apps.course_metadata.utils import ensure_draft_world + +log = logging.getLogger(__name__) + + +class CommentViewSet(viewsets.GenericViewSet): + + permission_classes = (IsAuthenticated,) + serializer_class = CommentSerializer + + def get_queryset(self): + """ + Override needed for DRF, but we don't use any queryset for this ViewSet + """ + + def list(self, request): + course_uuid = request.query_params.get('course_uuid') + if not course_uuid: + return Response( + _('You must include a course_uuid in your query parameters.'), + status=status.HTTP_400_BAD_REQUEST + ) + partner = request.site.partner + course = self._get_course_or_404(partner, course_uuid) + + user_orgs = Organization.user_organizations(request.user) + if not set(user_orgs).intersection(course.authoring_organizations.all()) and not request.user.is_staff: + raise PermissionDenied + + util = self._get_salesforce_util_or_404(partner) + comments = util.get_comments_for_course(course) + + return Response(comments) + + def create(self, request): + comment_creation_fields = { + 'course_uuid': request.data.get('course_uuid'), + 'comment': request.data.get('comment'), + } + + missing_values = [k for k, v in comment_creation_fields.items() if v is None] + error_message = '' + if missing_values: + error_message += ''.join([_('Missing value for: [{name}]. ').format(name=name) for name in missing_values]) + if error_message: + return Response((_('Incorrect data sent. ') + error_message).strip(), status=status.HTTP_400_BAD_REQUEST) + + partner = self.request.site.partner + course = self._get_course_or_404(partner, comment_creation_fields.get('course_uuid')) + + if not CourseEditor.is_course_editable(request.user, course): + raise PermissionDenied + + util = self._get_salesforce_util_or_404(partner) + try: + comment = util.create_comment_for_course_case( + course, + request.user, + comment_creation_fields.get('comment'), + course_run_key=request.data.get('course_run_key') + ) + send_email_for_comment(comment, course, request.user) + return Response(comment, status=status.HTTP_201_CREATED) + except SalesforceMissingCaseException as ex: + return Response(ex.message, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @staticmethod + def _get_course_or_404(partner, course_uuid): + try: + course = Course.objects.filter_drafts().get(partner=partner, uuid=course_uuid) + return ensure_draft_world(course) + except Course.DoesNotExist: + raise Http404 + + @staticmethod + def _get_salesforce_util_or_404(partner): + try: + return SalesforceUtil(partner) + except SalesforceConfiguration.DoesNotExist: + raise Http404 diff --git a/course_discovery/apps/api/v1/views/course_editors.py b/course_discovery/apps/api/v1/views/course_editors.py new file mode 100644 index 0000000000..5f18c5b0f1 --- /dev/null +++ b/course_discovery/apps/api/v1/views/course_editors.py @@ -0,0 +1,49 @@ +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import mixins, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.pagination import CursorPagination + +from course_discovery.apps.api.filters import CourseEditorFilter +from course_discovery.apps.api.permissions import CanAppointCourseEditor +from course_discovery.apps.api.serializers import CourseEditorSerializer +from course_discovery.apps.course_metadata.models import Course, CourseEditor + + +class CourseEditorViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet): + """CourseEditor Resource""" + permission_classes = [CanAppointCourseEditor] + serializer_class = CourseEditorSerializer + filter_backends = (DjangoFilterBackend,) + filterset_class = CourseEditorFilter + pagination_class = CursorPagination + + @property + def course(self): + return Course.objects.filter_drafts(uuid=self.request.data['course'], partner=self.request.site.partner).first() + + def get_queryset(self): + return CourseEditor.editors_for_user(self.request.user) + + def create(self, request, *args, **kwargs): + """The User who performs creation must be staff or belonging to the associated organization, the user being + assigned must belong to the associated organization""" + if 'user_id' not in request.data: + request.data['user_id'] = request.user.id + + user_model = get_user_model() + editor = get_object_or_404(user_model, pk=request.data['user_id']) + authoring_orgs = self.course.authoring_organizations.all() + users_in_authoring_orgs = user_model.objects.filter( + groups__organization_extension__organization__in=authoring_orgs + ).distinct() + + if editor not in users_in_authoring_orgs: + raise PermissionDenied('Editor does not belong to an authoring organization of this course.') + + return super().create(request) diff --git a/course_discovery/apps/api/v1/views/course_runs.py b/course_discovery/apps/api/v1/views/course_runs.py index 5c276a6109..d2eae35ea7 100644 --- a/course_discovery/apps/api/v1/views/course_runs.py +++ b/course_discovery/apps/api/v1/views/course_runs.py @@ -1,30 +1,65 @@ +import logging + +from django.db import transaction from django.db.models.functions import Lower +from django.http.response import Http404 +from django.utils.translation import ugettext as _ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status, viewsets -from rest_framework.decorators import list_route +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.filters import OrderingFilter -from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated +from rest_framework.permissions import SAFE_METHODS, IsAuthenticated from rest_framework.response import Response from course_discovery.apps.api import filters, serializers from course_discovery.apps.api.pagination import ProxiedPagination -from course_discovery.apps.api.utils import get_query_param +from course_discovery.apps.api.permissions import IsCourseRunEditorOrDjangoOrReadOnly +from course_discovery.apps.api.serializers import MetadataWithRelatedChoices +from course_discovery.apps.api.utils import StudioAPI, get_query_param, reviewable_data_has_changed +from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported from course_discovery.apps.core.utils import SearchQuerySetWrapper +from course_discovery.apps.course_metadata.choices import CourseRunStatus from course_discovery.apps.course_metadata.constants import COURSE_RUN_ID_REGEX -from course_discovery.apps.course_metadata.models import CourseRun +from course_discovery.apps.course_metadata.models import Course, CourseEditor, CourseRun +from course_discovery.apps.course_metadata.utils import ensure_draft_world +from course_discovery.apps.publisher.utils import is_publisher_user + +log = logging.getLogger(__name__) + +def writable_request_wrapper(method): + def inner(*args, **kwargs): + try: + with transaction.atomic(): + return method(*args, **kwargs) + except (PermissionDenied, ValidationError, Http404): + raise # just pass these along + except Exception as e: # pylint: disable=broad-except + content = str(e) + if hasattr(e, 'content'): + content = e.content.decode('utf8') if isinstance(e.content, bytes) else e.content + msg = _('Failed to set course run data: {}').format(content) + log.exception(msg) + return Response(msg, status=status.HTTP_400_BAD_REQUEST) + return inner -# pylint: disable=no-member + +# pylint: disable=useless-super-delegation class CourseRunViewSet(viewsets.ModelViewSet): """ CourseRun resource. """ filter_backends = (DjangoFilterBackend, OrderingFilter) - filter_class = filters.CourseRunFilter + filterset_class = filters.CourseRunFilter lookup_field = 'key' lookup_value_regex = COURSE_RUN_ID_REGEX - ordering_fields = ('start',) - permission_classes = (IsAuthenticated, DjangoModelPermissions) + ordering_fields = ('start', 'id', 'title_override', 'average_rating', 'total_raters') + permission_classes = (IsAuthenticated, IsCourseRunEditorOrDjangoOrReadOnly) queryset = CourseRun.objects.all().order_by(Lower('key')) serializer_class = serializers.CourseRunWithProgramsSerializer + metadata_class = MetadataWithRelatedChoices + metadata_related_choices_whitelist = ( + 'content_language', 'level_type', 'transcript_languages', 'expected_program_type', 'type' + ) # Explicitly support PageNumberPagination and LimitOffsetPagination. Future # versions of this API should only support the system default, PageNumberPagination. @@ -43,18 +78,32 @@ def get_queryset(self): """ q = self.request.query_params.get('q') partner = self.request.site.partner + edit_mode = get_query_param(self.request, 'editable') or self.request.method not in SAFE_METHODS + + if edit_mode and q: + raise EditableAndQUnsupported() + + if edit_mode and (not self.request.user.is_staff and not is_publisher_user(self.request.user)): + raise PermissionDenied + + if edit_mode: + queryset = CourseRun.objects.filter_drafts() + queryset = CourseEditor.editable_course_runs(self.request.user, queryset) + else: + queryset = self.queryset + queryset = queryset.filter(status=CourseRunStatus.Published) if q: qs = SearchQuerySetWrapper(CourseRun.search(q).filter(partner=partner.short_code)) # This is necessary to avoid issues with the filter backend. qs.model = self.queryset.model return qs - else: - queryset = super(CourseRunViewSet, self).get_queryset().filter(course__partner=partner) - return self.get_serializer_class().prefetch_queryset(queryset=queryset) - def get_serializer_context(self, *args, **kwargs): - context = super().get_serializer_context(*args, **kwargs) + queryset = queryset.filter(course__partner=partner) + return self.get_serializer_class().prefetch_queryset(queryset=queryset) + + def get_serializer_context(self): + context = super().get_serializer_context() context.update({ 'exclude_utm': get_query_param(self.request, 'exclude_utm'), 'include_deleted_programs': get_query_param(self.request, 'include_deleted_programs'), @@ -125,17 +174,197 @@ def list(self, request, *args, **kwargs): paramType: query multiple: false """ - return super(CourseRunViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) # pylint: disable=no-member + + @classmethod + def push_to_studio(cls, request, course_run, create=False, old_course_run_key=None): + if course_run.course.partner.studio_url: + api = StudioAPI(course_run.course.partner.studio_api_client) + api.push_to_studio(course_run, create, old_course_run_key, user=request.user) + else: + log.info('Not pushing course run info for %s to Studio as partner %s has no studio_url set.', + course_run.key, course_run.course.partner.short_code) + + @classmethod + def update_course_run_image_in_studio(cls, course_run): + if course_run.course.partner.studio_url: + api = StudioAPI(course_run.course.partner.studio_api_client) + api.update_course_run_image_in_studio(course_run) + else: + log.info('Not updating course run image for %s to Studio as partner %s has no studio_url set.', + course_run.key, course_run.course.partner.short_code) + + @writable_request_wrapper + def create_run_helper(self, run_data, request=None): + # These are both required to be part of self because when we call self.get_serializer, it tries + # to set these two variables as part of the serializer context. When the endpoint is hit directly, + # self.request should exist, but when this function is called from the Course POST endpoint in courses.py + # we have to manually set these values. + if not hasattr(self, 'request'): + self.request = request # pylint: disable=attribute-defined-outside-init + if not hasattr(self, 'format_kwarg'): + self.format_kwarg = None # pylint: disable=attribute-defined-outside-init + + # Set a pacing default when creating (studio requires this to be set, even though discovery does not) + run_data.setdefault('pacing_type', 'instructor_paced') + + # Guard against externally setting the draft state + run_data.pop('draft', None) + + prices = run_data.pop('prices', {}) + + # Grab any existing course run for this course (we'll use it when talking to studio to form basis of rerun) + course_key = run_data.get('course', None) # required field + if not course_key: + raise ValidationError({'course': ['This field is required.']}) + + # Before creating the serializer we need to ensure the course has draft rows as expected + # The serializer will attempt to retrieve the draft version of the Course + course = Course.objects.filter_drafts().get(key=course_key) + course = ensure_draft_world(course) + old_course_run_key = run_data.pop('rerun', None) + + serializer = self.get_serializer(data=run_data) + serializer.is_valid(raise_exception=True) + + # Save run to database + course_run = serializer.save(draft=True) + + course_run.update_or_create_seats(course_run.type, prices) - def partial_update(self, request, *args, **kwargs): + # Set canonical course run if needed (done this way to match historical behavior - but shouldn't this be + # updated *each* time we make a new run?) + if not course.canonical_course_run: + course.canonical_course_run = course_run + course.save() + elif not old_course_run_key: + # On a rerun, only set the old course run key to the canonical key if a rerun hasn't been provided + # This will prevent a breaking change if users of this endpoint don't choose to provide a key on rerun + old_course_run_key = course.canonical_course_run.key + + if old_course_run_key: + old_course_run = CourseRun.objects.filter_drafts().get(key=old_course_run_key) + course_run.language = old_course_run.language + course_run.min_effort = old_course_run.min_effort + course_run.max_effort = old_course_run.max_effort + course_run.weeks_to_complete = old_course_run.weeks_to_complete + course_run.save() + course_run.staff.set(old_course_run.staff.all()) + course_run.transcript_languages.set(old_course_run.transcript_languages.all()) + + # And finally, push run to studio + self.push_to_studio(self.request, course_run, create=True, old_course_run_key=old_course_run_key) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def create(self, request, *args, **kwargs): + """ Create a course run object. """ + response = self.create_run_helper(request.data) + if response.status_code == 201: + run_key = response.data.get('key') + course_run = CourseRun.everything.get(key=run_key, draft=True) + self.update_course_run_image_in_studio(course_run) + + return response + + @writable_request_wrapper + def _update_course_run(self, course_run, draft, changed, serializer, request, prices): + save_kwargs = {} + # If changes are made after review and before publish, revert status to unpublished. + # Unless we're just switching the status + non_exempt_update = changed and course_run.status == CourseRunStatus.Reviewed + if non_exempt_update: + save_kwargs['status'] = CourseRunStatus.Unpublished + official_run = course_run.official_version + official_run.status = CourseRunStatus.Unpublished + official_run.save() + # When the course run is being updated and is coming from the Unpublished state, we always want to set + # it's status to in legal review. If it is coming from the Reviewed state, we only want to put it + # back into legal review if a non exempt field was changed (expected_program_name and expected_program_type) + if not draft and (course_run.status == CourseRunStatus.Unpublished or non_exempt_update): + save_kwargs['status'] = CourseRunStatus.LegalReview + + course_run = serializer.save(**save_kwargs) + + if course_run in course_run.course.active_course_runs: + course_run.update_or_create_seats(course_run.type, prices) + + self.push_to_studio(request, course_run, create=False) + + # Published course runs can be re-published directly or course runs that remain in the Reviewed + # state can update their official version. We want to do this even in the Reviewed case for + # when an exempt field is changed and we still want to update the official even though we don't + # want to completely unpublish it. + if ((not draft and course_run.status == CourseRunStatus.Published) or + course_run.status == CourseRunStatus.Reviewed): + course_run.update_or_create_official_version() + + return Response(serializer.data) + + def handle_internal_review(self, request, serializer): + # Disallow updates on non internal review fields while course is in review + for key in request.data.keys(): + if key not in CourseRun.INTERNAL_REVIEW_FIELDS: + return Response( + _('Can only update status, ofac restrictions, and ofac comment'), + status=status.HTTP_400_BAD_REQUEST + ) + + serializer.save() + return Response(serializer.data) + + def update(self, request, **kwargs): """ Update one, or more, fields for a course run. """ - return super(CourseRunViewSet, self).partial_update(request, *args, **kwargs) + # logging to help debug error around course url slugs incrementing + log.info('The raw course run data coming from publisher is {}.'.format(request.data)) + + # Update one, or more, fields for a course run. + course_run = self.get_object() + course_run = ensure_draft_world(course_run) # always work on drafts + partial = kwargs.pop('partial', False) + # Sending draft=False triggers the review process for unpublished courses + draft = request.data.pop('draft', True) # Don't let draft parameter trickle down + prices = request.data.pop('prices', {}) + + serializer = self.get_serializer(course_run, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + # Handle staff update on course run in review with valid status transition + if (request.user.is_staff and course_run.in_review and 'status' in request.data and + request.data['status'] in CourseRunStatus.INTERNAL_STATUS_TRANSITIONS): + return self.handle_internal_review(request, serializer) + + # Handle regular non-internal update + request.data.pop('status', None) # Status management is handled in the model + serializer.validated_data.pop('status', None) # Status management is handled in the model + # Disallow patch or put if the course run is in review. + if course_run.in_review: + return Response( + _('Course run is in review. Editing disabled.'), + status=status.HTTP_403_FORBIDDEN + ) + # Disallow internal review fields when course run is not in review + for key in request.data.keys(): + if key in CourseRun.INTERNAL_REVIEW_FIELDS: + return Response( + _('Invalid parameter'), + status=status.HTTP_400_BAD_REQUEST + ) + + changed = reviewable_data_has_changed( + course_run, serializer.validated_data.items(), CourseRun.STATUS_CHANGE_EXEMPT_FIELDS) + response = self._update_course_run(course_run, draft, changed, serializer, request, prices) + + self.update_course_run_image_in_studio(course_run) + + return response def retrieve(self, request, *args, **kwargs): """ Retrieve details for a course run. """ - return super(CourseRunViewSet, self).retrieve(request, *args, **kwargs) + return super().retrieve(request, *args, **kwargs) # pylint: disable=no-member - @list_route() + @action(detail=False) def contains(self, request): """ Determine if course runs are found in the query results. @@ -178,3 +407,8 @@ def contains(self, request): serializer = serializers.ContainedCourseRunsSerializer(instance) return Response(serializer.data) return Response(status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, _request, *_args, **_kwargs): + """ Delete a course run. """ + # Not supported + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/course_discovery/apps/api/v1/views/courses.py b/course_discovery/apps/api/v1/views/courses.py index f3d7af6393..a210d23d10 100644 --- a/course_discovery/apps/api/v1/views/courses.py +++ b/course_discovery/apps/api/v1/views/courses.py @@ -1,28 +1,77 @@ +import base64 +import logging import re +from django.core import validators +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.db import transaction +from django.db.models import Q from django.db.models.functions import Lower +from django.http.response import Http404 from django.shortcuts import get_object_or_404 +from django.utils.translation import ugettext as _ from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated +from rest_framework import filters as rest_framework_filters +from rest_framework import status, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import SAFE_METHODS, IsAuthenticated +from rest_framework.response import Response from course_discovery.apps.api import filters, serializers +from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.pagination import ProxiedPagination -from course_discovery.apps.api.utils import get_query_param -from course_discovery.apps.course_metadata.choices import CourseRunStatus +from course_discovery.apps.api.permissions import IsCourseEditorOrReadOnly +from course_discovery.apps.api.serializers import CourseEntitlementSerializer, MetadataWithType +from course_discovery.apps.api.utils import get_query_param, reviewable_data_has_changed +from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported +from course_discovery.apps.api.v1.views.course_runs import CourseRunViewSet +from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.constants import COURSE_ID_REGEX, COURSE_UUID_REGEX -from course_discovery.apps.course_metadata.models import Course, CourseRun +from course_discovery.apps.course_metadata.models import ( + Collaborator, Course, CourseEditor, CourseEntitlement, CourseRun, CourseType, CourseUrlSlug, Organization, Program, + Seat, Video +) +from course_discovery.apps.course_metadata.utils import ( + create_missing_entitlement, ensure_draft_world, validate_course_number +) +from course_discovery.apps.publisher.utils import is_publisher_user +logger = logging.getLogger(__name__) -# pylint: disable=no-member -class CourseViewSet(viewsets.ReadOnlyModelViewSet): + +def writable_request_wrapper(method): + def inner(*args, **kwargs): + try: + with transaction.atomic(): + return method(*args, **kwargs) + except ValidationError as exc: + return Response(exc.message if hasattr(exc, 'message') else str(exc), + status=status.HTTP_400_BAD_REQUEST) + except (PermissionDenied, Http404): + raise # just pass these along + except Exception as e: # pylint: disable=broad-except + content = str(e) + if hasattr(e, 'content'): + content = e.content.decode('utf8') if isinstance(e.content, bytes) else e.content + msg = _('Failed to set data: {}').format(content) + logger.exception(msg) + return Response(msg, status=status.HTTP_400_BAD_REQUEST) + return inner + + +# pylint: disable=useless-super-delegation +class CourseViewSet(CompressedCacheResponseMixin, viewsets.ModelViewSet): """ Course resource. """ - filter_backends = (DjangoFilterBackend,) - filter_class = filters.CourseFilter + + filter_backends = (DjangoFilterBackend, rest_framework_filters.OrderingFilter) + filterset_class = filters.CourseFilter lookup_field = 'key' lookup_value_regex = COURSE_ID_REGEX + '|' + COURSE_UUID_REGEX - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, IsCourseEditorOrReadOnly,) serializer_class = serializers.CourseWithProgramsSerializer + metadata_class = MetadataWithType + metadata_related_choices_whitelist = ('mode', 'level_type', 'subjects',) course_key_regex = re.compile(COURSE_ID_REGEX) course_uuid_regex = re.compile(COURSE_UUID_REGEX) @@ -52,15 +101,36 @@ def get_object(self): def get_queryset(self): partner = self.request.site.partner q = self.request.query_params.get('q') + # We don't want to create an additional elasticsearch index right now for draft courses, so we + # try to implement a basic search behavior with this pubq parameter here against key and name. + pub_q = self.request.query_params.get('pubq') + edit_method = self.request.method not in SAFE_METHODS + edit_mode = get_query_param(self.request, 'editable') or edit_method + + if edit_mode and q: + raise EditableAndQUnsupported() + + if edit_mode and (not self.request.user.is_staff and not is_publisher_user(self.request.user)): + raise PermissionDenied + + if edit_mode: + # Start with either draft versions or real versions of the courses + queryset = Course.objects.filter_drafts() + queryset = CourseEditor.editable_courses(self.request.user, queryset, check_editors=edit_method) + else: + queryset = self.queryset if q: - queryset = Course.search(q) + queryset = Course.search(q, queryset=queryset) queryset = self.get_serializer_class().prefetch_queryset(queryset=queryset, partner=partner) else: - if get_query_param(self.request, 'include_hidden_course_runs'): - course_runs = CourseRun.objects.filter(course__partner=partner) + if edit_mode: + course_runs = CourseRun.objects.filter_drafts(course__partner=partner) else: - course_runs = CourseRun.objects.filter(course__partner=partner).exclude(hidden=True) + course_runs = CourseRun.objects.filter(course__partner=partner) + + if not get_query_param(self.request, 'include_hidden_course_runs'): + course_runs = course_runs.exclude(hidden=True) if get_query_param(self.request, 'marketable_course_runs_only'): course_runs = course_runs.marketable().active() @@ -71,16 +141,24 @@ def get_queryset(self): if get_query_param(self.request, 'published_course_runs_only'): course_runs = course_runs.filter(status=CourseRunStatus.Published) + if get_query_param(self.request, 'include_deleted_programs'): + programs = Program.objects.all() + else: + programs = Program.objects.exclude(status=ProgramStatus.Deleted) + queryset = self.get_serializer_class().prefetch_queryset( - queryset=self.queryset, + queryset=queryset, course_runs=course_runs, - partner=partner + partner=partner, + programs=programs, ) + if pub_q and edit_mode: + return queryset.filter(Q(key__icontains=pub_q) | Q(title__icontains=pub_q)).order_by(Lower('key')) return queryset.order_by(Lower('key')) - def get_serializer_context(self, *args, **kwargs): - context = super().get_serializer_context(*args, **kwargs) + def get_serializer_context(self): + context = super().get_serializer_context() query_params = ['exclude_utm', 'include_deleted_programs'] for query_param in query_params: @@ -88,6 +166,248 @@ def get_serializer_context(self, *args, **kwargs): return context + def get_course_key(self, data): + return '{org}+{number}'.format(org=data['org'], number=data['number']) + + @writable_request_wrapper + def create(self, request, *args, **kwargs): + """ + Create a Course, Course Entitlement, and Entitlement. + """ + course_run_creation_fields = request.data.pop('course_run', None) + course_creation_fields = { + 'title': request.data.get('title'), + 'number': request.data.get('number'), + 'org': request.data.get('org'), + 'type': request.data.get('type'), + } + url_slug = request.data.get('url_slug', '') + + missing_values = [k for k, v in course_creation_fields.items() if v is None] + error_message = '' + if missing_values: + error_message += ''.join([_('Missing value for: [{name}]. ').format(name=name) for name in missing_values]) + if not Organization.objects.filter(key=course_creation_fields['org']).exists(): + error_message += _('Organization [{org}] does not exist. ').format(org=course_creation_fields['org']) + if not CourseType.objects.filter(uuid=course_creation_fields['type']).exists(): + error_message += _('Course Type [{course_type}] does not exist. ').format( + course_type=course_creation_fields['type']) + if error_message: + return Response((_('Incorrect data sent. ') + error_message).strip(), status=status.HTTP_400_BAD_REQUEST) + + partner = request.site.partner + course_creation_fields['partner'] = partner.id + course_creation_fields['key'] = self.get_course_key(course_creation_fields) + + validate_course_number(course_creation_fields['number']) + + serializer = self.get_serializer(data=course_creation_fields) + serializer.is_valid(raise_exception=True) + + # Confirm that this course doesn't already exist in an official non-draft form + if Course.objects.filter(partner=partner, key=course_creation_fields['key']).exists(): + raise Exception(_('A course with key [{key}] already exists.').format(key=course_creation_fields['key'])) + + # if a manually entered url_slug, ensure it's not already taken (auto-generated are guaranteed uniqueness) + if url_slug: + validators.validate_slug(url_slug) + if CourseUrlSlug.objects.filter(url_slug=url_slug, partner=partner).exists(): + raise Exception(_('Course creation was unsuccessful. The course URL slug ‘[{url_slug}]’ is already in ' + 'use. Please update this field and try again.').format(url_slug=url_slug)) + + course = serializer.save(draft=True) + course.set_active_url_slug(url_slug) + + organization = Organization.objects.get(key=course_creation_fields['org']) + course.authoring_organizations.add(organization) + + collaborators_uuid = request.data.get('collaborators') + if collaborators_uuid: + collaborators = Collaborator.objects.filter(uuid__in=collaborators_uuid) + course.collaborators.add(*collaborators) + + entitlement_types = course.type.entitlement_types.all() + prices = request.data.get('prices', {}) + for entitlement_type in entitlement_types: + CourseEntitlement.objects.create( + course=course, + mode=entitlement_type, + partner=partner, + price=prices.get(entitlement_type.slug, 0), + draft=True, + ) + + CourseEditor.objects.create( + user=request.user, + course=course, + ) + + # We want to create the course run here so it is captured as part of the atomic transaction. + # Note: We have to send the request object as well because it is used for its metadata + # (like request.user and is set as part of the serializer context) + if course_run_creation_fields: + course_run_creation_fields.update({'course': course.key, 'prices': prices}) + run_response = CourseRunViewSet().create_run_helper(course_run_creation_fields, request) + if run_response.status_code != 201: + raise Exception(str(run_response.data)) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def update_entitlement(self, course, entitlement_type, price, partial=False): + """ + Finds and updates an existing entitlement from the incoming data, with verification. + + Will create an entitlement if we're switching from Audit. + Returns a tuple of (CourseEntitlement, bool) where the second value is whether the entitlement changed. + """ + entitlement = CourseEntitlement.everything.filter(course=course, draft=True).first() + existing_slug = entitlement.mode.slug if entitlement else Seat.AUDIT + + # We want to allow upgrading an entitlement from Audit -> Verified, but allow no other + # entitlement type changes. We use the official version existing as an indicator for + # ecom products having already been created. + entitlement_type_switch_whitelist = {Seat.AUDIT: Seat.VERIFIED} + if (course.official_version and existing_slug != entitlement_type.slug and + entitlement_type_switch_whitelist.get(existing_slug) != entitlement_type.slug): + raise ValidationError(_('Switching entitlement types after being reviewed is not supported. Please reach ' + 'out to your project coordinator for additional help if necessary.')) + + if entitlement: + data = {'mode': entitlement_type.slug, 'price': price} + serializer = CourseEntitlementSerializer(entitlement, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + return serializer.save(), entitlement.price != float(price) + else: + return (CourseEntitlement.objects.create( + course=course, + mode=entitlement_type, + partner=course.partner, + price=price, + draft=True, + ), True) + + def log_request_subjects_and_prices(self, data, course): # pragma: no cover + req_subjects = ', '.join(data.get('subjects', [])) + current_subjects = ', '.join(list(map(lambda s: s.slug, course.subjects.all()))) + prices = data.get('prices', {}) + logger.info( + 'UPDATE to course uuid - {uuid}, req subjects - [{req_subjects}], request prices - {prices}, ' + 'current subjects - [{current_subjects}]'.format(uuid=data.get('uuid'), req_subjects=req_subjects, + prices=prices, current_subjects=current_subjects) + ) + + @writable_request_wrapper + def update_course(self, data, partial=False): # pylint: disable=too-many-statements + """ Updates an existing course from incoming data. """ + + # logging to help debug error around course url slugs incrementing + logger.info('The raw course data coming from publisher is {}.'.format(data)) + + changed = False + # Sending draft=False means the course data is live and updates should be pushed out immediately + draft = data.pop('draft', True) + image_data = data.pop('image', None) + video_data = data.pop('video', None) + url_slug = data.pop('url_slug', '') + + # Get and validate object serializer + course = self.get_object() + course = ensure_draft_world(course) # always work on drafts + serializer = self.get_serializer(course, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + + # TEMPORARY - log incoming request (subject and prices) for all course updates, see Jira DISCO-1593 + self.log_request_subjects_and_prices(data, course) + + # First, update course entitlements + if data.get('type') or data.get('prices'): + entitlements = [] + prices = data.get('prices', {}) + course_type = CourseType.objects.get(uuid=data.get('type')) if data.get('type') else course.type + entitlement_types = course_type.entitlement_types.all() + for entitlement_type in entitlement_types: + price = prices.get(entitlement_type.slug) + if price is None: + continue + entitlement, did_change = self.update_entitlement(course, entitlement_type, price, partial=partial) + entitlements.append(entitlement) + changed = changed or did_change + # Deleting entitlements here since they would be orphaned otherwise. + # One example of how this situation can happen is if a course team is switching between + # "Verified and Audit" and "Audit Only" before actually publishing their course run. + course.entitlements.exclude(mode__in=entitlement_types).delete() + course.entitlements.set(entitlements) + + # Save video if a new video source is provided + if (video_data and video_data.get('src') and + (not course.video or video_data.get('src') != course.video.src)): + video, __ = Video.objects.get_or_create(src=video_data['src']) + course.video = video + + # Save image and convert to the correct format + if image_data and isinstance(image_data, str) and image_data.startswith('data:image'): + # base64 encoded image - decode + file_format, imgstr = image_data.split(';base64,') # format ~= data:image/X;base64,/xxxyyyzzz/ + ext = file_format.split('/')[-1] # guess file extension + image_data = ContentFile(base64.b64decode(imgstr), name=f'tmp.{ext}') + course.image.save(image_data.name, image_data) + + if data.get('collaborators'): + collaborators_uuids = data.get('collaborators') + collaborators = Collaborator.objects.filter(uuid__in=collaborators_uuids) + course.collaborators.add(*collaborators) + + # If price didnt change, check the other fields on the course + # (besides image and video, they are popped off above) + changed = changed or reviewable_data_has_changed(course, serializer.validated_data.items()) + + if url_slug: + validators.validate_slug(url_slug) + all_course_historical_slugs_excluding_present = CourseUrlSlug.objects.filter( + url_slug=url_slug, partner=course.partner).exclude(course__uuid=course.uuid) + if all_course_historical_slugs_excluding_present.exists(): + raise Exception( + _('Course edit was unsuccessful. The course URL slug ‘[{url_slug}]’ is already in use. ' + 'Please update this field and try again.').format(url_slug=url_slug)) + + # Then the course itself + course = serializer.save() + if url_slug: + course.set_active_url_slug(url_slug) + + if not draft: + for course_run in course.active_course_runs: + if course_run.status == CourseRunStatus.Published: + # This will also update the course + course_run.update_or_create_official_version() + + # Revert any Reviewed course runs back to Unpublished + if changed: + for course_run in course.course_runs.filter(status=CourseRunStatus.Reviewed): + course_run.status = CourseRunStatus.Unpublished + course_run.save() + course_run.official_version.status = CourseRunStatus.Unpublished + course_run.official_version.save() + + # hack to get the correctly-updated url slug into the response + return_dict = {'url_slug': course.active_url_slug} + return_dict.update(serializer.data) + return Response(return_dict) + + def update(self, request, *_args, **_kwargs): + """ Update details for a course. """ + return self.update_course(request.data, partial=False) + + def partial_update(self, request, *_args, **_kwargs): + """ Partially update details for a course. """ + return self.update_course(request.data, partial=True) + + def destroy(self, _request, *_args, **_kwargs): + """ Delete a course. """ + # Not supported + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + def list(self, request, *args, **kwargs): """ List all courses. --- @@ -143,8 +463,18 @@ def list(self, request, *args, **kwargs): paramType: query multiple: false """ - return super(CourseViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) def retrieve(self, request, *args, **kwargs): """ Retrieve details for a course. """ - return super(CourseViewSet, self).retrieve(request, *args, **kwargs) + # Check if we can convert from run-level seat pricing to course-level entitlements. + # + # Yes, creating an object is kind of an odd thing to do on a GET endpoint - but it's a one time migration + # to entitlements and subsequent calls will not make further objects. + # This was deemed simpler than faking that an entitlement exists in the response and making the object when + # a client calls PATCH. + course = self.get_object() + if get_query_param(request, 'editable') and not course.entitlements.exists(): + create_missing_entitlement(course) + + return super().retrieve(request, *args, **kwargs) diff --git a/course_discovery/apps/api/v1/views/level_types.py b/course_discovery/apps/api/v1/views/level_types.py new file mode 100644 index 0000000000..e66d63bf2b --- /dev/null +++ b/course_discovery/apps/api/v1/views/level_types.py @@ -0,0 +1,19 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import viewsets +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated + +from course_discovery.apps.api import filters, serializers +from course_discovery.apps.course_metadata.models import LevelType + + +class LevelTypeViewSet(viewsets.ReadOnlyModelViewSet): + """ LevelType resource. """ + lookup_field = 'translations__name_t' + lookup_url_kwarg = 'name' + pagination_class = PageNumberPagination + permission_classes = (IsAuthenticated,) + serializer_class = serializers.LevelTypeSerializer + queryset = serializers.LevelTypeSerializer.prefetch_queryset(LevelType.objects.all()) + filter_backends = (DjangoFilterBackend,) + filterset_class = filters.LevelTypeFilter diff --git a/course_discovery/apps/api/v1/views/organizations.py b/course_discovery/apps/api/v1/views/organizations.py index fb5018d251..5e82dbd1bb 100644 --- a/course_discovery/apps/api/v1/views/organizations.py +++ b/course_discovery/apps/api/v1/views/organizations.py @@ -1,17 +1,20 @@ from django_filters.rest_framework import DjangoFilterBackend +from guardian.shortcuts import get_objects_for_user from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from course_discovery.apps.api import filters, serializers +from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.pagination import ProxiedPagination +from course_discovery.apps.publisher.models import OrganizationExtension -# pylint: disable=no-member -class OrganizationViewSet(viewsets.ReadOnlyModelViewSet): +# pylint: disable=useless-super-delegation +class OrganizationViewSet(CompressedCacheResponseMixin, viewsets.ReadOnlyModelViewSet): """ Organization resource. """ filter_backends = (DjangoFilterBackend,) - filter_class = filters.OrganizationFilter + filterset_class = filters.OrganizationFilter lookup_field = 'uuid' lookup_value_regex = '[0-9a-f-]+' permission_classes = (IsAuthenticated,) @@ -22,13 +25,28 @@ class OrganizationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = ProxiedPagination def get_queryset(self): + user = self.request.user partner = self.request.site.partner - return serializers.OrganizationSerializer.prefetch_queryset(partner=partner) + + if user.is_staff: + return serializers.OrganizationSerializer.prefetch_queryset(partner=partner) + else: + organizations = get_objects_for_user( + user, + OrganizationExtension.VIEW_COURSE, + OrganizationExtension, + use_groups=True, + with_superuser=False + ).values_list('organization') + orgs_queryset = serializers.OrganizationSerializer.prefetch_queryset(partner=partner).filter( + pk__in=organizations + ) + return orgs_queryset def list(self, request, *args, **kwargs): """ Retrieve a list of all organizations. """ - return super(OrganizationViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) def retrieve(self, request, *args, **kwargs): """ Retrieve details for an organization. """ - return super(OrganizationViewSet, self).retrieve(request, *args, **kwargs) + return super().retrieve(request, *args, **kwargs) diff --git a/course_discovery/apps/api/v1/views/pathways.py b/course_discovery/apps/api/v1/views/pathways.py index df064687a4..c428a0074c 100644 --- a/course_discovery/apps/api/v1/views/pathways.py +++ b/course_discovery/apps/api/v1/views/pathways.py @@ -1,16 +1,15 @@ """ Views for accessing Pathway data """ from rest_framework import viewsets -from rest_framework_extensions.cache.mixins import CacheResponseMixin from course_discovery.apps.api import serializers +from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.permissions import ReadOnlyByPublisherUser -from course_discovery.apps.course_metadata.models import Pathway - -class PathwayViewSet(CacheResponseMixin, viewsets.ReadOnlyModelViewSet): +class PathwayViewSet(CompressedCacheResponseMixin, viewsets.ReadOnlyModelViewSet): permission_classes = (ReadOnlyByPublisherUser,) serializer_class = serializers.PathwaySerializer def get_queryset(self): - return Pathway.objects.filter(partner=self.request.site.partner).order_by('created') + queryset = self.get_serializer_class().prefetch_queryset(partner=self.request.site.partner) + return queryset.order_by('created') diff --git a/course_discovery/apps/api/v1/views/people.py b/course_discovery/apps/api/v1/views/people.py index f2463b5039..d0aba82cec 100644 --- a/course_discovery/apps/api/v1/views/people.py +++ b/course_discovery/apps/api/v1/views/people.py @@ -2,29 +2,32 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status, viewsets -from rest_framework.permissions import DjangoModelPermissions +from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly from rest_framework.response import Response from course_discovery.apps.api import filters, serializers +from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.pagination import PageNumberPagination -from course_discovery.apps.api.utils import get_query_param +from course_discovery.apps.api.serializers import MetadataWithRelatedChoices from course_discovery.apps.course_metadata.exceptions import MarketingSiteAPIClientException, PersonToMarketingException logger = logging.getLogger(__name__) -# pylint: disable=no-member -class PersonViewSet(viewsets.ModelViewSet): +# pylint: disable=useless-super-delegation +class PersonViewSet(CompressedCacheResponseMixin, viewsets.ModelViewSet): """ PersonSerializer resource. """ filter_backends = (DjangoFilterBackend,) - filter_class = filters.PersonFilter + filterset_class = filters.PersonFilter lookup_field = 'uuid' lookup_value_regex = '[0-9a-f-]+' - permission_classes = (DjangoModelPermissions,) + permission_classes = (DjangoModelPermissionsOrAnonReadOnly,) queryset = serializers.PersonSerializer.prefetch_queryset() serializer_class = serializers.PersonSerializer pagination_class = PageNumberPagination + metadata_class = MetadataWithRelatedChoices + metadata_related_choices_whitelist = ('organization',) def create(self, request, *args, **kwargs): """ @@ -56,7 +59,7 @@ def create(self, request, *args, **kwargs): headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - def update(self, request, *args, **kwargs): # pylint: disable=unused-argument + def update(self, request, *args, **kwargs): """ Updates a person in discovery and the corresponding person node in drupal """ @@ -92,19 +95,21 @@ def update(self, request, *args, **kwargs): # pylint: disable=unused-argument def list(self, request, *args, **kwargs): """ Retrieve a list of all people. """ - return super(PersonViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) def retrieve(self, request, *args, **kwargs): """ Retrieve details for a person. """ - return super(PersonViewSet, self).retrieve(request, *args, **kwargs) + return super().retrieve(request, *args, **kwargs) def get_queryset(self): # Only include people from the current request's site - return self.queryset.filter(partner=self.request.site.partner) - - def get_serializer_context(self, *args, **kwargs): - context = super().get_serializer_context(*args, **kwargs) - query_params = ['include_course_runs_staffed', 'include_publisher_course_runs_staffed'] - for query_param in query_params: - context[query_param] = get_query_param(self.request, query_param) - return context + queryset = self.queryset.filter(partner=self.request.site.partner) + org_keys = self.request.query_params.getlist('org', None) + + if org_keys: + # We are pulling the people who are part of course runs belonging to the given organizations. + # This blank order_by is there to offset the default ordering on people since + # we don't care about the order in which they are returned. + queryset = queryset.filter(courses_staffed__course__authoring_organizations__key__in=org_keys).order_by() + + return queryset diff --git a/course_discovery/apps/api/v1/views/program_types.py b/course_discovery/apps/api/v1/views/program_types.py index 534e079012..449b904c8d 100644 --- a/course_discovery/apps/api/v1/views/program_types.py +++ b/course_discovery/apps/api/v1/views/program_types.py @@ -1,8 +1,9 @@ +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated -from course_discovery.apps.api import serializers +from course_discovery.apps.api import filters, serializers from course_discovery.apps.course_metadata.models import ProgramType @@ -13,3 +14,5 @@ class ProgramTypeViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = (IsAuthenticated,) queryset = serializers.ProgramTypeSerializer.prefetch_queryset(ProgramType.objects.all()) serializer_class = serializers.ProgramTypeSerializer + filter_backends = (DjangoFilterBackend,) + filterset_class = filters.ProgramTypeFilter diff --git a/course_discovery/apps/api/v1/views/programs.py b/course_discovery/apps/api/v1/views/programs.py index 7c215ee226..66b787c0a8 100644 --- a/course_discovery/apps/api/v1/views/programs.py +++ b/course_discovery/apps/api/v1/views/programs.py @@ -1,23 +1,28 @@ +import base64 + +from django.core.files.base import ContentFile from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets +from rest_framework import filters as rest_framework_filters +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework_extensions.cache.mixins import CacheResponseMixin from course_discovery.apps.api import filters, serializers +from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.utils import get_query_param -from course_discovery.apps.course_metadata.models import Program +from course_discovery.apps.course_metadata.models import Course, CourseRun, Organization, Program -# pylint: disable=no-member -class ProgramViewSet(CacheResponseMixin, viewsets.ReadOnlyModelViewSet): +class ProgramViewSet(CompressedCacheResponseMixin, viewsets.ModelViewSet): """ Program resource. """ lookup_field = 'uuid' lookup_value_regex = '[0-9a-f-]+' permission_classes = (IsAuthenticated,) - filter_backends = (DjangoFilterBackend,) - filter_class = filters.ProgramFilter + filter_backends = (DjangoFilterBackend, rest_framework_filters.OrderingFilter) + filterset_class = filters.ProgramFilter # Explicitly support PageNumberPagination and LimitOffsetPagination. Future # versions of this API should only support the system default, PageNumberPagination. @@ -40,8 +45,8 @@ def get_queryset(self): return self.get_serializer_class().prefetch_queryset(queryset=queryset, partner=partner) - def get_serializer_context(self, *args, **kwargs): - context = super().get_serializer_context(*args, **kwargs) + def get_serializer_context(self): + context = super().get_serializer_context() query_params = ['exclude_utm', 'use_full_course_serializer', 'published_course_runs_only', 'marketable_enrollable_course_runs_with_archived'] for query_param in query_params: @@ -49,6 +54,64 @@ def get_serializer_context(self, *args, **kwargs): return context + def prepare_and_set_read_only_data(self, data, context): + """ + Extracts read only data from data dictionary and sets it in context. + """ + data = dict(data) # Convert Querydict to dict + authoring_organizations = data.get('authoring_organizations') + if authoring_organizations: + context['authoring_organizations'] = Organization.objects.filter(key__in=authoring_organizations).distinct() + + credit_backing_organizations = data.get('credit_backing_organizations') + if credit_backing_organizations: + context['credit_backing_organizations'] = Organization.objects.filter(key__in=credit_backing_organizations).distinct() + + course_run_keys = data.get('course_runs') + if course_run_keys: + course_runs = CourseRun.objects.filter(key__in=course_run_keys).distinct() + courses = Course.objects.filter(key__in=course_runs.values('course__key').distinct()) + excluded_course_runs = CourseRun.objects.filter(course__in=courses).exclude(key__in=course_run_keys).distinct() + + context['courses'] = courses + context['excluded_course_runs'] = excluded_course_runs + + def create(self, request, *args, **kwargs): + """ + Create a new program. + """ + data = request.data.copy() + context = {} + + self.prepare_and_set_read_only_data(data, context) + + context['partner'] = request.site.partner + context['request'] = request + + serializer = serializers.ProgramSerializer(data=data, context=context) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def update(self, request, *args, **kwargs): + """ + Update exsisting program. + """ + data = request.data.copy() + context = {} + instance = Program.objects.get(uuid=kwargs.get('uuid')) + + self.prepare_and_set_read_only_data(data, context) + + context['request'] = request + + serializer = serializers.ProgramSerializer(instance, data=data, context=context) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_200_OK, headers=headers) + def list(self, request, *args, **kwargs): """ List all programs. --- @@ -106,4 +169,22 @@ def list(self, request, *args, **kwargs): return Response(uuids) - return super(ProgramViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) + + @action(detail=True, methods=['post']) + def update_card_image(self, request, *_args, **_kwargs): + if not self.request.user.is_staff: + raise PermissionDenied + program = self.get_object() + image_data = request.data.get('image', None) + if image_data and isinstance(image_data, str) and image_data.startswith('data:image'): + # base64 encoded image - decode + file_format, imgstr = image_data.split(';base64,') # format ~= data:image/X;base64,/xxxyyyzzz/ + ext = file_format.split('/')[-1] # guess file extension + image_data = ContentFile(base64.b64decode(imgstr), name=f'tmp.{ext}') + program.card_image.save(image_data.name, image_data) + msg = 'Successfully updated program card image for program {uuid}: {title}'.format(uuid=program.uuid, + title=program.title) + return Response(msg) + else: + return Response('Bad image data in request', status=status.HTTP_400_BAD_REQUEST) diff --git a/course_discovery/apps/api/v1/views/search.py b/course_discovery/apps/api/v1/views/search.py index a7950713b6..b111ffa06e 100644 --- a/course_discovery/apps/api/v1/views/search.py +++ b/course_discovery/apps/api/v1/views/search.py @@ -1,15 +1,19 @@ +import uuid + +from django.db.models import Q from django.http import QueryDict -from drf_haystack.filters import HaystackFilter +from drf_haystack.filters import HaystackFilter, HaystackOrderingFilter from drf_haystack.mixins import FacetMixin from drf_haystack.viewsets import HaystackViewSet from haystack.backends import SQ from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet -from rest_framework import renderers, status, viewsets -from rest_framework.decorators import list_route +from rest_framework import status, viewsets +from rest_framework.decorators import action from rest_framework.exceptions import ParseError, ValidationError from rest_framework.filters import OrderingFilter from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer from rest_framework.response import Response from rest_framework.views import APIView @@ -18,14 +22,16 @@ from course_discovery.apps.course_metadata.models import Course, CourseRun, Person, Program +# pylint: disable=useless-super-delegation class BaseHaystackViewSet(mixins.DetailMixin, FacetMixin, HaystackViewSet): document_uid_field = 'key' - facet_filter_backends = [filters.HaystackFacetFilterWithQueries, filters.HaystackFilter, OrderingFilter] - ordering_fields = ('start',) + facet_filter_backends = [filters.HaystackFacetFilterWithQueries, filters.HaystackFilter, HaystackOrderingFilter] + ordering_fields = ('aggregation_key', 'start') load_all = True lookup_field = 'key' permission_classes = (IsAuthenticated,) + ensure_published = True def list(self, request, *args, **kwargs): """ @@ -38,9 +44,9 @@ def list(self, request, *args, **kwargs): type: string required: false """ - return super(BaseHaystackViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) - @list_route(methods=['get'], url_path='facets') + @action(detail=False, methods=['get'], url_path='facets') def facets(self, request): """ Returns faceted search results @@ -68,7 +74,7 @@ def facets(self, request): pytype: str required: false """ - return super(BaseHaystackViewSet, self).facets(request) + return super().facets(request) def filter_facet_queryset(self, queryset): queryset = super().filter_facet_queryset(queryset) @@ -80,14 +86,15 @@ def filter_facet_queryset(self, queryset): facet_serializer_cls = self.get_facet_serializer_class() field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {}) - # Ensure we only return published, non-hidden items - queryset = queryset.filter(published=True).exclude(hidden=True) + if self.ensure_published: + # Ensure we only return published, non-hidden items + queryset = queryset.filter(published=True).exclude(hidden=True) for facet in self.request.query_params.getlist('selected_query_facets'): query = field_queries.get(facet) if not query: - raise ParseError('The selected query facet [{facet}] is not valid.'.format(facet=facet)) + raise ParseError(f'The selected query facet [{facet}] is not valid.') queryset = queryset.raw_search(query['query']) @@ -111,13 +118,23 @@ def get_request_filters(request): return request_filters +class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer): + """Renders the browsable api without the forms.""" + + def get_rendered_html_form(self, data, view, method, request): + return None + + def get_raw_data_form(self, data, view, method, request): + return None + + class CatalogDataViewSet(viewsets.GenericViewSet): - renderer_classes = [renderers.JSONRenderer] + renderer_classes = [JSONRenderer, BrowsableAPIRendererWithoutForms] permission_classes = (IsAuthenticated,) - filter_backends = (CatalogDataFilterBackend,) + filter_backends = (CatalogDataFilterBackend, HaystackOrderingFilter) def create(self, request): - return self.list(request) + return self.list(request) # pylint: disable=no-member class CourseSearchViewSet(BaseHaystackViewSet): @@ -128,6 +145,8 @@ class CourseSearchViewSet(BaseHaystackViewSet): class CourseRunSearchViewSet(BaseHaystackViewSet): + ordering_fields = ('start', 'id', 'title_override', 'average_rating', 'total_raters') + filter_backends = [filters.HaystackFilter, OrderingFilter] index_models = (CourseRun,) detail_serializer_class = serializers.CourseRunSearchModelSerializer facet_serializer_class = serializers.CourseRunFacetSerializer @@ -150,14 +169,103 @@ class AggregateSearchViewSet(BaseHaystackViewSet, CatalogDataViewSet): serializer_class = serializers.AggregateSearchSerializer +class LimitedAggregateSearchView(FacetMixin, HaystackViewSet): + """ + The purpose of this endpoint is to provide search data in the correct order to + consume the ordering for another service. We will be providing a limited + set of data based on what exists in the search indexes. Other types of + ordering are not supported. + """ + document_uid_field = 'key' + facet_filter_backends = [filters.HaystackFilter] + + lookup_field = 'key' + permission_classes = (IsAuthenticated,) + facet_serializer_class = serializers.AggregateFacetSearchSerializer + serializer_class = serializers.LimitedAggregateSearchSerializer + + def filter_facet_queryset(self, queryset): + queryset = super().filter_facet_queryset(queryset) + + # Ensure we only return published, non-hidden items + queryset = queryset.filter(published=True).exclude(hidden=True) + + return queryset + + class PersonSearchViewSet(BaseHaystackViewSet): """ Generic person search """ + ordering_fields = ('created', 'full_name',) + permission_classes = (IsAuthenticated,) index_models = (Person,) + filter_backends = (CatalogDataFilterBackend, HaystackOrderingFilter) detail_serializer_class = serializers.PersonSearchModelSerializer facet_serializer_class = serializers.PersonFacetSerializer serializer_class = serializers.PersonSearchSerializer + ensure_published = False + document_uid = 'uuid' + lookup_field = 'uuid' + + +class PersonTypeaheadSearchView(APIView): + """ Typeahead for people. """ + permission_classes = (IsAuthenticated,) + + def get(self, request, *args, **kwargs): + """ + Typeahead uses the ngram_analyzer as the index_analyzer to generate ngrams of the title during indexing. + i.e. Data Science -> da, dat, at, ata, data, etc... + Typeahead uses the lowercase analyzer as the search_analyzer. + The ngram_analyzer uses the lowercase filter as well, which makes typeahead case insensitive. + Available analyzers are defined in index _settings and field level analyzers are defined in the index _mapping. + NGrams are used rather than EdgeNgrams because NGrams allow partial searches across white space: + i.e. data sci - > data science, but not data analysis or scientific method + --- + parameters: + - name: q + description: "Search text" + paramType: query + required: true + type: string + - name: orgs + description: "Organization short codes" + paramType: query + required: false + type: List of string + """ + query = request.query_params.get('q') + if not query: + raise ValidationError("The 'q' querystring parameter is required for searching.") + words = query.split() + org_keys = self.request.GET.getlist('org', None) + + queryset = Person.objects.all() + + if org_keys: + # We are pulling the people who are part of course runs belonging to the given organizations. + # This blank order_by is there to offset the default ordering on people since + # we don't care about the order in which they are returned. + queryset = queryset.filter( + courses_staffed__course__authoring_organizations__key__in=org_keys + ).distinct().order_by() + + for word in words: + # Progressively filter the same queryset - every word must match something + queryset = queryset.filter(Q(given_name__icontains=word) | Q(family_name__icontains=word)) + + # No match? Maybe they gave us a UUID... + if not queryset: + try: + q_uuid = uuid.UUID(query).hex + queryset = Person.objects.filter(uuid=q_uuid) + except ValueError: + pass + + context = {'request': self.request} + serialized_people = [serializers.PersonSerializer(p, context=context).data for p in queryset] + return Response(serialized_people, status=status.HTTP_200_OK) class TypeaheadSearchView(APIView): @@ -183,9 +291,9 @@ def get_results(self, query, partner): if course_key in seen_course_keys: continue - else: - seen_course_keys.add(course_key) - course_run_list.append(course_run) + + seen_course_keys.add(course_key) + course_run_list.append(course_run) if len(course_run_list) == self.RESULT_COUNT: break diff --git a/course_discovery/apps/api/v1/views/subjects.py b/course_discovery/apps/api/v1/views/subjects.py index f1cb8cb052..ea67bce1ee 100644 --- a/course_discovery/apps/api/v1/views/subjects.py +++ b/course_discovery/apps/api/v1/views/subjects.py @@ -6,12 +6,12 @@ from course_discovery.apps.api.pagination import ProxiedPagination -# pylint: disable=no-member +# pylint: disable=useless-super-delegation class SubjectViewSet(viewsets.ReadOnlyModelViewSet): """ Subject resource. """ filter_backends = (DjangoFilterBackend,) - filter_class = filters.SubjectFilter + filterset_class = filters.SubjectFilter lookup_field = 'uuid' lookup_value_regex = '[0-9a-f-]+' permission_classes = (IsAuthenticated,) @@ -22,12 +22,13 @@ class SubjectViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = ProxiedPagination def get_queryset(self): - return serializers.SubjectSerializer.prefetch_queryset() + partner = self.request.site.partner + return serializers.SubjectSerializer.prefetch_queryset(partner=partner) def list(self, request, *args, **kwargs): """ Retrieve a list of all subjects. """ - return super(SubjectViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) # pylint: disable=no-member def retrieve(self, request, *args, **kwargs): """ Retrieve details for an subject. """ - return super(SubjectViewSet, self).retrieve(request, *args, **kwargs) + return super().retrieve(request, *args, **kwargs) diff --git a/course_discovery/apps/api/v1/views/topics.py b/course_discovery/apps/api/v1/views/topics.py index c5223b2520..426091af4b 100644 --- a/course_discovery/apps/api/v1/views/topics.py +++ b/course_discovery/apps/api/v1/views/topics.py @@ -6,12 +6,12 @@ from course_discovery.apps.api.pagination import ProxiedPagination -# pylint: disable=no-member +# pylint: disable=useless-super-delegation class TopicViewSet(viewsets.ReadOnlyModelViewSet): """ Topic resource. """ filter_backends = (DjangoFilterBackend,) - filter_class = filters.TopicFilter + filterset_class = filters.TopicFilter lookup_field = 'uuid' lookup_value_regex = '[0-9a-f-]+' permission_classes = (IsAuthenticated,) @@ -26,8 +26,8 @@ def get_queryset(self): def list(self, request, *args, **kwargs): """ Retrieve a list of all topics. """ - return super(TopicViewSet, self).list(request, *args, **kwargs) + return super().list(request, *args, **kwargs) # pylint: disable=no-member def retrieve(self, request, *args, **kwargs): """ Retrieve details for an topic. """ - return super(TopicViewSet, self).retrieve(request, *args, **kwargs) + return super().retrieve(request, *args, **kwargs) diff --git a/course_discovery/apps/api/v1/views/user_management.py b/course_discovery/apps/api/v1/views/user_management.py new file mode 100644 index 0000000000..e2860f3871 --- /dev/null +++ b/course_discovery/apps/api/v1/views/user_management.py @@ -0,0 +1,147 @@ +import logging + +from django.apps import apps +from django.db import transaction +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.serializers import ValidationError +from rest_framework.views import APIView + +from course_discovery.apps.api.permissions import CanReplaceUsername + +log = logging.getLogger(__name__) + + +class UsernameReplacementView(APIView): + """ + WARNING: This API is only meant to be used as part of a larger job that + updates usernames across all services. DO NOT run this alone or users will + not match across the system and things will be broken. This API should be + called from the LMS endpoint which verifies uniqueness of the username + first. + + API will recieve a list of current usernames and their new username. + """ + + authentication_classes = (JwtAuthentication, ) + permission_classes = (permissions.IsAuthenticated, CanReplaceUsername) + + def post(self, request): + """ + **POST Parameters** + + A POST request must include the following parameter. + + * username_mappings: Required. A list of objects that map the current username (key) + to the new username (value) + { + "username_mappings": [ + {"current_username_1": "new_username_1"}, + {"current_username_2": "new_username_2"} + ] + } + + **POST Response Values** + + As long as data validation passes, the request will return a 200 with a new mapping + of old usernames (key) to new username (value) + + { + "successful_replacements": [ + {"old_username_1": "new_username_1"} + ], + "failed_replacements": [ + {"old_username_2": "new_username_2"} + ] + } + """ + + # (model_name, column_name) + MODELS_WITH_USERNAME = ( + ('core.user', 'username'), + ) + + username_mappings = request.data.get("username_mappings") + replacement_locations = self._load_models(MODELS_WITH_USERNAME) + + if not self._has_valid_schema(username_mappings): + raise ValidationError("Request data does not match schema") + + successful_replacements, failed_replacements = [], [] + + for username_pair in username_mappings: + current_username = list(username_pair.keys())[0] + new_username = list(username_pair.values())[0] + successfully_replaced = self._replace_username_for_all_models( + current_username, + new_username, + replacement_locations + ) + if successfully_replaced: + successful_replacements.append({current_username: new_username}) + else: + failed_replacements.append({current_username: new_username}) + return Response( + status=status.HTTP_200_OK, + data={ + "successful_replacements": successful_replacements, + "failed_replacements": failed_replacements + } + ) + + def _load_models(self, models_with_fields): + """ Takes tuples that contain a model path and returns the list with a loaded version of the model """ + try: + replacement_locations = [(apps.get_model(model), column) for (model, column) in models_with_fields] + except LookupError: # pragma: no cover + log.exception("Unable to load models for username replacement") + raise + return replacement_locations + + def _has_valid_schema(self, post_data): + """ Verifies the data is a list of objects with a single key:value pair """ + if not isinstance(post_data, list): + return False + for obj in post_data: + if not (isinstance(obj, dict) and len(obj) == 1): + return False + return True + + def _replace_username_for_all_models(self, current_username, new_username, replacement_locations): + """ + Replaces current_username with new_username for all (model, column) pairs in replacement locations. + Returns if it was successful or not. Will return successful even if no matching + """ + try: + with transaction.atomic(): + num_rows_changed = 0 + for (model, column) in replacement_locations: + num_rows_changed += model.objects.filter( + **{column: current_username} + ).update( + **{column: new_username} + ) + except Exception as exc: # pragma: no cover pylint: disable=broad-except + log.exception( + "Unable to change username from %s to %s. Failed on table %s because %s", + current_username, + new_username, + model.__class__.__name__, + exc + ) + return False + if num_rows_changed == 0: + log.info( + "Unable to change username from %s to %s because %s doesn't exist.", + current_username, + new_username, + current_username + ) + else: + log.info( + "Successfully changed username from %s to %s.", + current_username, + new_username + ) + return True diff --git a/course_discovery/apps/api/views.py b/course_discovery/apps/api/views.py index df3ea5b2a9..02d6a00356 100644 --- a/course_discovery/apps/api/views.py +++ b/course_discovery/apps/api/views.py @@ -23,17 +23,23 @@ class SwaggerSchemaView(APIView): def get(self, request): generator = SchemaGenerator(title='Discovery API') schema = generator.get_schema(request=request) - if not schema: # get_schema() uses the same permissions check as the API endpoints. # If we don't get a schema document back, it means the user is not # authenticated or doesn't have permission to access the API. # api_docs_permission_denied_handler() handles both of these cases. return api_docs_permission_denied_handler(request) + elif schema and request.user and request.user.is_anonymous: + return _redirect_to_login(request) return Response(schema) +def _redirect_to_login(request): + login_url = '{path}?next={next}'.format(path=reverse('login'), next=request.path) + return redirect(login_url, permanent=False) + + def api_docs_permission_denied_handler(request): """ Permission denied handler for calls to the API documentation. @@ -48,8 +54,6 @@ def api_docs_permission_denied_handler(request): HttpResponseRedirect: Redirect to the login page if the user is not logged in. After a successful login, the user will be redirected back to the original path. """ - if request.user and request.user.is_authenticated(): + if request.user and request.user.is_authenticated: raise PermissionDenied(_('You are not permitted to access the API documentation.')) - - login_url = '{path}?next={next}'.format(path=reverse('login'), next=request.path) - return redirect(login_url, permanent=False) + return _redirect_to_login(request) diff --git a/course_discovery/apps/catalogs/admin.py b/course_discovery/apps/catalogs/admin.py index f06d5dab28..dbef935b9f 100644 --- a/course_discovery/apps/catalogs/admin.py +++ b/course_discovery/apps/catalogs/admin.py @@ -6,8 +6,9 @@ @admin.register(Catalog) class CatalogAdmin(GuardedModelAdmin): - list_display = ('name',) + list_display = ('id', 'name',) readonly_fields = ('created', 'modified',) + search_fields = ('id', 'name') - class Media(object): + class Media: js = ('js/catalogs-change-form.js',) diff --git a/course_discovery/apps/catalogs/migrations/0001_initial.py b/course_discovery/apps/catalogs/migrations/0001_initial.py index f7488491ce..6345081244 100644 --- a/course_discovery/apps/catalogs/migrations/0001_initial.py +++ b/course_discovery/apps/catalogs/migrations/0001_initial.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import django_extensions.db.fields from django.db import migrations, models diff --git a/course_discovery/apps/catalogs/migrations/0001_squashed_0002_auto_20160327_2101.py b/course_discovery/apps/catalogs/migrations/0001_squashed_0002_auto_20160327_2101.py index 9c93c7804f..ea2ff4cf04 100644 --- a/course_discovery/apps/catalogs/migrations/0001_squashed_0002_auto_20160327_2101.py +++ b/course_discovery/apps/catalogs/migrations/0001_squashed_0002_auto_20160327_2101.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.10 on 2016-11-03 22:01 -from __future__ import unicode_literals + import django_extensions.db.fields from django.db import migrations, models diff --git a/course_discovery/apps/catalogs/migrations/0002_auto_20160327_2101.py b/course_discovery/apps/catalogs/migrations/0002_auto_20160327_2101.py index 25383f305c..ff1a07d023 100644 --- a/course_discovery/apps/catalogs/migrations/0002_auto_20160327_2101.py +++ b/course_discovery/apps/catalogs/migrations/0002_auto_20160327_2101.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/catalogs/migrations/0002_catalog_include_archived.py b/course_discovery/apps/catalogs/migrations/0002_catalog_include_archived.py index 9ea0b51069..f8a1e05413 100644 --- a/course_discovery/apps/catalogs/migrations/0002_catalog_include_archived.py +++ b/course_discovery/apps/catalogs/migrations/0002_catalog_include_archived.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-08 10:09 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/catalogs/migrations/0003_auto_20200331_0725.py b/course_discovery/apps/catalogs/migrations/0003_auto_20200331_0725.py new file mode 100644 index 0000000000..c258461dab --- /dev/null +++ b/course_discovery/apps/catalogs/migrations/0003_auto_20200331_0725.py @@ -0,0 +1,18 @@ +# Generated by Django 1.11.29 on 2020-03-31 07:25 + + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogs', '0002_catalog_include_archived'), + ] + + operations = [ + migrations.AlterModelOptions( + name='catalog', + options={'default_permissions': ('add', 'change', 'delete'), 'get_latest_by': 'modified', 'ordering': ('-modified', '-created'), 'permissions': (('view_catalog', 'Can view catalog'),)}, + ), + ] diff --git a/course_discovery/apps/catalogs/migrations/0004_add_programs_query.py b/course_discovery/apps/catalogs/migrations/0004_add_programs_query.py new file mode 100644 index 0000000000..0ca3c53c42 --- /dev/null +++ b/course_discovery/apps/catalogs/migrations/0004_add_programs_query.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-04-22 19:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogs', '0003_auto_20200331_0725'), + ] + + operations = [ + migrations.AddField( + model_name='catalog', + name='program_query', + field=models.TextField(blank=True, default='', help_text='Query to retrieve Program catalog contents'), + ), + migrations.AlterField( + model_name='catalog', + name='query', + field=models.TextField(help_text='Query to retrieve Course Run catalog contents'), + ), + ] diff --git a/course_discovery/apps/catalogs/migrations/0005_auto_20200804_1401.py b/course_discovery/apps/catalogs/migrations/0005_auto_20200804_1401.py new file mode 100644 index 0000000000..2ba1fd8a26 --- /dev/null +++ b/course_discovery/apps/catalogs/migrations/0005_auto_20200804_1401.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.14 on 2020-08-04 14:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogs', '0004_add_programs_query'), + ] + + operations = [ + migrations.AlterModelOptions( + name='catalog', + options={'default_permissions': ('add', 'change', 'delete'), 'get_latest_by': 'modified', 'permissions': (('view_catalog', 'Can view catalog'),)}, + ), + ] diff --git a/course_discovery/apps/catalogs/models.py b/course_discovery/apps/catalogs/models.py index 1b6fc415b4..7e838595ee 100644 --- a/course_discovery/apps/catalogs/models.py +++ b/course_discovery/apps/catalogs/models.py @@ -1,4 +1,4 @@ -from collections import Iterable +from collections.abc import Iterable from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -7,17 +7,23 @@ from haystack.query import SearchQuerySet from course_discovery.apps.core.mixins import ModelPermissionsMixin -from course_discovery.apps.course_metadata.models import Course, CourseRun +from course_discovery.apps.course_metadata.models import Course, CourseRun, Program class Catalog(ModelPermissionsMixin, TimeStampedModel): VIEW_PERMISSION = 'view_catalog' name = models.CharField(max_length=255, null=False, blank=False, help_text=_('Catalog name')) - query = models.TextField(null=False, blank=False, help_text=_('Query to retrieve catalog contents')) + query = models.TextField(null=False, blank=False, help_text=_('Query to retrieve Course Run catalog contents')) + program_query = models.TextField( + null=False, + blank=True, + help_text=_('Query to retrieve Program catalog contents'), + default='' + ) include_archived = models.BooleanField(default=False, help_text=_('Include archived courses')) def __str__(self): - return 'Catalog #{id}: {name}'.format(id=self.id, name=self.name) # pylint: disable=no-member + return f'Catalog #{self.id}: {self.name}' def _get_query_results(self): """ @@ -36,11 +42,19 @@ def courses(self): """ return Course.search(self.query) + def programs(self): + """ Returns the list of Programs contained within this catalog. + + Returns: + QuerySet + """ + return Program.search(self.program_query) + @property def courses_count(self): return self._get_query_results().count() - def contains(self, course_ids): # pylint: disable=unused-argument + def contains(self, course_ids): """ Determines if the given courses are contained in this catalog. Arguments: @@ -57,7 +71,7 @@ def contains(self, course_ids): # pylint: disable=unused-argument return contains - def contains_course_runs(self, course_run_ids): # pylint: disable=unused-argument + def contains_course_runs(self, course_run_ids): """ Determines if the given course runs are contained in this catalog. @@ -125,3 +139,6 @@ class Meta(TimeStampedModel.Meta): permissions = ( ('view_catalog', 'Can view catalog'), ) + # The view permission was added in 2.1, as result two view permissions tries to insert into db and triggers + # integrity error. Customize the default permission list and removed view from there + default_permissions = ('add', 'change', 'delete',) diff --git a/course_discovery/apps/catalogs/tests/factories.py b/course_discovery/apps/catalogs/tests/factories.py index d5651ba296..4be2daf6c3 100644 --- a/course_discovery/apps/catalogs/tests/factories.py +++ b/course_discovery/apps/catalogs/tests/factories.py @@ -4,8 +4,8 @@ from course_discovery.apps.catalogs.models import Catalog -class CatalogFactory(factory.DjangoModelFactory): - class Meta(object): +class CatalogFactory(factory.django.DjangoModelFactory): + class Meta: model = Catalog name = FuzzyText(prefix='catalog-name-') diff --git a/course_discovery/apps/catalogs/tests/test_models.py b/course_discovery/apps/catalogs/tests/test_models.py index 05df537966..0fba8d356d 100644 --- a/course_discovery/apps/catalogs/tests/test_models.py +++ b/course_discovery/apps/catalogs/tests/test_models.py @@ -1,4 +1,5 @@ import ddt +from django.contrib.auth.models import ContentType, Permission from django.test import TestCase from course_discovery.apps.catalogs.models import Catalog @@ -13,7 +14,7 @@ class CatalogTests(ElasticsearchTestMixin, TestCase): """ Catalog model tests. """ def setUp(self): - super(CatalogTests, self).setUp() + super().setUp() self.catalog = factories.CatalogFactory(query='title:abc*') self.course = CourseFactory(key='a/b/c', title='ABCs of Ͳҽʂէìղց') self.refresh_index() @@ -24,7 +25,7 @@ def test_unicode(self): self.catalog.name = name self.catalog.save() - expected = 'Catalog #{id}: {name}'.format(id=self.catalog.id, name=name) + expected = f'Catalog #{self.catalog.id}: {name}' self.assertEqual(str(self.catalog), expected) def test_courses(self): @@ -97,3 +98,9 @@ def test_set_viewers_with_invalid_argument(self, viewers): with self.assertRaises(TypeError) as context: self.catalog.viewers = viewers self.assertEqual(context.exception.args[0], 'Viewers must be a non-string iterable containing User objects.') + + @ddt.data('add_catalog', 'change_catalog', 'view_catalog', 'delete_catalog') + def test_catalogs_permissions(self, perm): + """ Validate that model has the all four permissions. """ + cont_type = ContentType.objects.get(app_label='catalogs', model='catalog') + self.assertTrue(Permission.objects.get(content_type=cont_type, codename=perm)) diff --git a/course_discovery/apps/core/admin.py b/course_discovery/apps/core/admin.py index faa34e4988..9a61866bc2 100644 --- a/course_discovery/apps/core/admin.py +++ b/course_discovery/apps/core/admin.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from course_discovery.apps.core.forms import UserThrottleRateForm -from course_discovery.apps.core.models import Currency, Partner, User, UserThrottleRate +from course_discovery.apps.core.models import Currency, Partner, SalesforceConfiguration, User, UserThrottleRate @admin.register(User) @@ -40,16 +40,15 @@ class CurrencyAdmin(admin.ModelAdmin): class PartnerAdmin(admin.ModelAdmin): fieldsets = ( (None, { - 'fields': ('name', 'short_code', 'lms_url', 'studio_url', 'site') - }), - (_('OpenID Connect'), { - 'description': _( - 'OpenID Connect is used for front-end authentication as well as getting access to the APIs.'), - 'fields': ('oidc_url_root', 'oidc_key', 'oidc_secret',) + 'fields': ('name', 'short_code', 'lms_url', 'lms_admin_url', 'studio_url', 'publisher_url', 'site', 'is_disabled',) }), (_('API Configuration'), { 'description': _('Configure the APIs that will be used to retrieve data.'), - 'fields': ('courses_api_url', 'ecommerce_api_url', 'organizations_api_url', 'programs_api_url',) + 'fields': ('courses_api_url', + 'lms_coursemode_api_url', + 'ecommerce_api_url', + 'organizations_api_url', + 'programs_api_url',) }), (_('Marketing Site Configuration'), { 'description': _('Configure the marketing site URLs that will be used to retrieve data and create URLs.'), @@ -64,3 +63,15 @@ class PartnerAdmin(admin.ModelAdmin): list_display = ('name', 'short_code', 'site') ordering = ('name', 'short_code', 'site') search_fields = ('name', 'short_code') + + +@admin.register(SalesforceConfiguration) +class SalesforceConfigurationAdmin(admin.ModelAdmin): + list_display = ( + 'username', + 'password', + 'organization_id', + 'security_token', + 'is_sandbox' + ) + search_fields = ('organization_id', 'partner') diff --git a/course_discovery/apps/core/api_client/lms.py b/course_discovery/apps/core/api_client/lms.py index f6134060fb..723118458b 100644 --- a/course_discovery/apps/core/api_client/lms.py +++ b/course_discovery/apps/core/api_client/lms.py @@ -4,12 +4,11 @@ import logging from django.core.cache import cache +from edx_django_utils.cache import get_cache_key from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.exceptions import SlumberBaseException from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin -from course_discovery.apps.api.utils import get_cache_key - logger = logging.getLogger(__name__) SENTINEL_NO_RESULT = () @@ -17,7 +16,7 @@ ONE_MINUTE = 60 -class LMSAPIClient(object): +class LMSAPIClient: """ API Client for communication between discovery and LMS. """ diff --git a/course_discovery/apps/core/constants.py b/course_discovery/apps/core/constants.py index 3154a44017..8a969c7179 100644 --- a/course_discovery/apps/core/constants.py +++ b/course_discovery/apps/core/constants.py @@ -1,7 +1,7 @@ """ Constants for the core app. """ -class Status(object): +class Status: """Health statuses.""" - OK = u"OK" - UNAVAILABLE = u"UNAVAILABLE" + OK = "OK" + UNAVAILABLE = "UNAVAILABLE" diff --git a/course_discovery/apps/core/forms.py b/course_discovery/apps/core/forms.py index a75f054e3e..a24338fc60 100644 --- a/course_discovery/apps/core/forms.py +++ b/course_discovery/apps/core/forms.py @@ -19,7 +19,6 @@ def clean_rate(self): int(num) # Only evaluated for the (possible) side effect of a ValueError period_choices = ('second', 'minute', 'hour', 'day') if period not in period_choices: - # pylint: disable=no-member # Translators: 'period_choices' is a list of possible values, like ('second', 'minute', 'hour') error_msg = _("period must be one of {period_choices}.").format(period_choices=period_choices) raise forms.ValidationError(error_msg) diff --git a/course_discovery/apps/core/lookups.py b/course_discovery/apps/core/lookups.py deleted file mode 100644 index 97aa499a2a..0000000000 --- a/course_discovery/apps/core/lookups.py +++ /dev/null @@ -1,15 +0,0 @@ -from dal import autocomplete - -from course_discovery.apps.core.models import User - - -class UserAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if self.request.user.is_authenticated() and self.request.user.is_staff: - qs = User.objects.all() - if self.q: - qs = qs.filter(username__icontains=self.q) - - return qs - - return [] diff --git a/course_discovery/apps/core/management/commands/create_or_update_partner.py b/course_discovery/apps/core/management/commands/create_or_update_partner.py index a57f30d477..6821d8df0a 100644 --- a/course_discovery/apps/core/management/commands/create_or_update_partner.py +++ b/course_discovery/apps/core/management/commands/create_or_update_partner.py @@ -1,4 +1,4 @@ -""" Creates or updates a Partner, including API and OIDC information """ +""" Creates or updates a Partner, including API information """ import logging @@ -61,6 +61,12 @@ def add_arguments(self, parser): type=str, default='', help='API endpoint for accessing Partner program data.') + parser.add_argument('--lms-url', + action='store', + dest='lms_url', + type=str, + default='', + help='API endpoint for accessing lms.') parser.add_argument('--marketing-site-api-url', action='store', dest='marketing_site_api_url', @@ -85,24 +91,6 @@ def add_arguments(self, parser): type=str, default='', help='Password used for accessing Partner marketing site data.') - parser.add_argument('--oidc-url-root', - action='store', - dest='oidc_url_root', - type=str, - default='', - help='URL root used for Partner OIDC workflows.') - parser.add_argument('--oidc-key', - action='store', - dest='oidc_key', - type=str, - default='', - help='Key used for Partner OIDC workflows.') - parser.add_argument('--oidc-secret', - action='store', - dest='oidc_secret', - type=str, - default='', - help='Key used for Partner OIDC workflows.') def handle(self, *args, **options): """ Creates or updates Site and Partner records. """ @@ -129,13 +117,11 @@ def handle(self, *args, **options): 'ecommerce_api_url': options.get('ecommerce_api_url'), 'organizations_api_url': options.get('organizations_api_url'), 'programs_api_url': options.get('programs_api_url'), + 'lms_url': options.get('lms_url'), 'marketing_site_api_url': options.get('marketing_site_api_url'), 'marketing_site_url_root': options.get('marketing_site_url_root'), 'marketing_site_api_username': options.get('marketing_site_api_username'), 'marketing_site_api_password': options.get('marketing_site_api_password'), - 'oidc_url_root': options.get('oidc_url_root'), - 'oidc_key': options.get('oidc_key'), - 'oidc_secret': options.get('oidc_secret'), } ) logger.info('Partner %s with code %s', 'created' if created else 'updated', partner_code) diff --git a/course_discovery/apps/core/management/commands/create_sites_and_partners.py b/course_discovery/apps/core/management/commands/create_sites_and_partners.py index 6e7b312abe..3cbba9c7bf 100644 --- a/course_discovery/apps/core/management/commands/create_sites_and_partners.py +++ b/course_discovery/apps/core/management/commands/create_sites_and_partners.py @@ -59,7 +59,7 @@ def _get_site_partner_data(self): """ site_data = {} for config_file in self.find(self.configuration_filename, self.theme_path): - logger.info("Reading file from {file_name}".format(file_name=config_file)) + logger.info(f"Reading file from {config_file}") configuration_data = json.loads( json.dumps( json.load( @@ -83,29 +83,29 @@ def handle(self, *args, **options): else: configuration_prefix = 'sandbox' - self.configuration_filename = '{}_configuration.json'.format(configuration_prefix) + self.configuration_filename = f'{configuration_prefix}_configuration.json' self.dns_name = options['dns_name'] self.theme_path = options['theme_path'] logger.info("Using %s configuration...", configuration_prefix) - logger.info("DNS name: '{dns_name}'".format(dns_name=self.dns_name)) - logger.info("Theme path: '{theme_path}'".format(theme_path=self.theme_path)) + logger.info(f"DNS name: '{self.dns_name}'") + logger.info(f"Theme path: '{self.theme_path}'") all_sites = self._get_site_partner_data() for site_partner, site_partner_data in all_sites.items(): partner_data = site_partner_data['partner_data'] - logger.info("Creating '{site}' Site".format(site=site_partner)) + logger.info(f"Creating '{site_partner}' Site") site, _ = Site.objects.get_or_create( domain=site_partner_data['site_domain'], defaults={"name": site_partner} ) - logger.info("Successfully created {site} site".format(site=site_partner)) + logger.info(f"Successfully created {site_partner} site") partner_data['site'] = site - logger.info("Creating or Updating '{partner}' Partner".format(partner=site_partner)) + logger.info(f"Creating or Updating '{site_partner}' Partner") Partner.objects.update_or_create( short_code=site_partner, defaults=partner_data ) - logger.info("Successfully created {partner} Partner".format(partner=site_partner)) + logger.info(f"Successfully created {site_partner} Partner") diff --git a/course_discovery/apps/core/management/commands/setup_course_discovery_service.py b/course_discovery/apps/core/management/commands/setup_course_discovery_service.py new file mode 100644 index 0000000000..ed05cf28ee --- /dev/null +++ b/course_discovery/apps/core/management/commands/setup_course_discovery_service.py @@ -0,0 +1,26 @@ +""" Management command to set up course discovery service locally""" + +from course_discovery.apps.core.models import Partner +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Set up course discovery service locally' + domain_name = 'edx.devstack.lms:18381' + + def setup_course_discovery_service(self): + site, _ = Site.objects.get_or_create(name=self.domain_name, domain=self.domain_name) + Partner.objects.get_or_create( + name='edx.devstack.lms:18381', short_code='edly', + lms_url='http://edx.devstack.lms:18000', site=site, + courses_api_url='http://edx.devstack.lms:18000/api/courses/v1/', + ecommerce_api_url='http://edx.devstack.lms:18130/api/v2/', + marketing_site_url_root='http://wordpress.edx.devstack.lms/', + marketing_site_api_url='http://wordpress.edx.devstack.lms/wp-json/edly/v1/course_runs', + studio_url='http://edx.devstack.lms:18010' + ) + + def handle(self, *args, **options): + """Set up course discovery service locally.""" + self.setup_course_discovery_service() diff --git a/course_discovery/apps/core/management/commands/tests/devstack_configuration.json b/course_discovery/apps/core/management/commands/tests/devstack_configuration.json index a8c5cfca38..5d7d68f303 100644 --- a/course_discovery/apps/core/management/commands/tests/devstack_configuration.json +++ b/course_discovery/apps/core/management/commands/tests/devstack_configuration.json @@ -4,10 +4,7 @@ "site_partner": "dummy", "partner_data": { "name": "dummy", - "oidc_key": "key-dummy", "studio_url": "https://studio-dummy-{dns_name}.example.com", - "oidc_secret": "secret-{dns_name}", - "oidc_url_root": "https://dummy-{dns_name}.example.com/oauth2", "courses_api_url": "https://dummy-{dns_name}.example.com/api/courses/v1/", "ecommerce_api_url": "https://ecommerce-dummy-{dns_name}.example.com/", "organizations_api_url": "https://dummy-{dns_name}.example.com/api/organizations/v0/" diff --git a/course_discovery/apps/core/management/commands/tests/sandbox_configuration.json b/course_discovery/apps/core/management/commands/tests/sandbox_configuration.json index a8c5cfca38..5d7d68f303 100644 --- a/course_discovery/apps/core/management/commands/tests/sandbox_configuration.json +++ b/course_discovery/apps/core/management/commands/tests/sandbox_configuration.json @@ -4,10 +4,7 @@ "site_partner": "dummy", "partner_data": { "name": "dummy", - "oidc_key": "key-dummy", "studio_url": "https://studio-dummy-{dns_name}.example.com", - "oidc_secret": "secret-{dns_name}", - "oidc_url_root": "https://dummy-{dns_name}.example.com/oauth2", "courses_api_url": "https://dummy-{dns_name}.example.com/api/courses/v1/", "ecommerce_api_url": "https://ecommerce-dummy-{dns_name}.example.com/", "organizations_api_url": "https://dummy-{dns_name}.example.com/api/organizations/v0/" diff --git a/course_discovery/apps/core/management/commands/tests/test_create_or_update_partner.py b/course_discovery/apps/core/management/commands/tests/test_create_or_update_partner.py index df057649b6..6a7a46ab08 100644 --- a/course_discovery/apps/core/management/commands/tests/test_create_or_update_partner.py +++ b/course_discovery/apps/core/management/commands/tests/test_create_or_update_partner.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from ddt import data, ddt from django.core.management import CommandError, call_command from django.test import TestCase @@ -23,9 +21,6 @@ class CreateOrUpdatePartnerCommandTests(TestCase): marketing_site_url_root = 'https://www.fake.org/' marketing_site_api_username = 'marketing-username' marketing_site_api_password = 'marketing-password' - oidc_url_root = 'https://oidc.fake.org/' - oidc_key = 'oidc-key' - oidc_secret = 'oidc-secret' def _check_partner(self, partner): self.assertEqual(partner.site.domain, self.site_domain) @@ -39,9 +34,6 @@ def _check_partner(self, partner): self.assertEqual(partner.marketing_site_url_root, self.marketing_site_url_root) self.assertEqual(partner.marketing_site_api_username, self.marketing_site_api_username) self.assertEqual(partner.marketing_site_api_password, self.marketing_site_api_password) - self.assertEqual(partner.oidc_url_root, self.oidc_url_root) - self.assertEqual(partner.oidc_key, self.oidc_key) - self.assertEqual(partner.oidc_secret, self.oidc_secret) def _call_command(self, **kwargs): """ @@ -66,9 +58,6 @@ def _call_command(self, **kwargs): 'marketing_site_url_root': 'marketing-site-url-root', 'marketing_site_api_username': 'marketing-site-api-username', 'marketing_site_api_password': 'marketing-site-api-password', - 'oidc_url_root': 'oidc-url-root', - 'oidc_key': 'oidc-key', - 'oidc_secret': 'oidc-secret', } for kwarg, value in kwargs.items(): @@ -91,9 +80,6 @@ def _create_partner(self): marketing_site_url_root=self.marketing_site_url_root, marketing_site_api_username=self.marketing_site_api_username, marketing_site_api_password=self.marketing_site_api_password, - oidc_url_root=self.oidc_url_root, - oidc_key=self.oidc_key, - oidc_secret=self.oidc_secret, ) def test_create_partner(self): @@ -123,9 +109,6 @@ def test_update_partner(self): self.marketing_site_url_root = 'https://www.updated.org/' self.marketing_site_api_username = 'updated-username' self.marketing_site_api_password = 'updated-password' - self.oidc_url_root = 'https://oidc.updated.org/' - self.oidc_key = 'updated-key' - self.oidc_secret = 'updated-secret' self._call_command( site_id=site.id, @@ -140,9 +123,6 @@ def test_update_partner(self): marketing_site_url_root=self.marketing_site_url_root, marketing_site_api_username=self.marketing_site_api_username, marketing_site_api_password=self.marketing_site_api_password, - oidc_url_root=self.oidc_url_root, - oidc_key=self.oidc_key, - oidc_secret=self.oidc_secret, ) partner = Partner.objects.get(short_code=self.partner_code) diff --git a/course_discovery/apps/core/management/commands/tests/test_create_sites_and_partners.py b/course_discovery/apps/core/management/commands/tests/test_create_sites_and_partners.py index b2f503bc16..8f97bf40a3 100644 --- a/course_discovery/apps/core/management/commands/tests/test_create_sites_and_partners.py +++ b/course_discovery/apps/core/management/commands/tests/test_create_sites_and_partners.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import os from django.contrib.sites.models import Site @@ -15,7 +13,7 @@ class CreateSitesAndPartnersTests(TestCase): """ Test the create_sites_and_partners command """ def setUp(self): - super(CreateSitesAndPartnersTests, self).setUp() + super().setUp() self.dns_name = "dummy-dns" self.theme_path = os.path.dirname(__file__) @@ -35,25 +33,19 @@ def _assert_site_and_partner_are_valid(self): site_name = site.name self.assertEqual( site.domain, - "discovery-{site}-{dns_name}.example.com".format(site=site_name, dns_name=self.dns_name) + f"discovery-{site_name}-{self.dns_name}.example.com" ) partner = Partner.objects.get(site=site) self.assertEqual(partner.short_code, site_name) self.assertEqual(partner.name, "dummy") - self.assertEqual(partner.oidc_key, "key-dummy") - self.assertEqual(partner.oidc_secret, "secret-{dns_name}".format(dns_name=self.dns_name)) - self.assertEqual( - partner.oidc_url_root, - "https://dummy-{dns_name}.example.com/oauth2".format(dns_name=self.dns_name) - ) self.assertEqual( partner.courses_api_url, - "https://dummy-{dns_name}.example.com/api/courses/v1/".format(dns_name=self.dns_name) + f"https://dummy-{self.dns_name}.example.com/api/courses/v1/" ) self.assertEqual( partner.ecommerce_api_url, - "https://ecommerce-dummy-{dns_name}.example.com/".format(dns_name=self.dns_name) + f"https://ecommerce-dummy-{self.dns_name}.example.com/" ) self.assertEqual( partner.organizations_api_url, diff --git a/course_discovery/apps/core/migrations/0001_initial.py b/course_discovery/apps/core/migrations/0001_initial.py deleted file mode 100644 index 9ce94e8b86..0000000000 --- a/course_discovery/apps/core/migrations/0001_initial.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import django.contrib.auth.models -import django.core.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth', '0006_require_contenttypes_0002'), - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), - ('password', models.CharField(verbose_name='password', max_length=128)), - ('last_login', models.DateTimeField(blank=True, verbose_name='last login', null=True)), - ('is_superuser', models.BooleanField(help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status', default=False)), - ('username', models.CharField(validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, max_length=30, verbose_name='username', error_messages={'unique': 'A user with that username already exists.'})), - ('first_name', models.CharField(blank=True, verbose_name='first name', max_length=30)), - ('last_name', models.CharField(blank=True, verbose_name='last name', max_length=30)), - ('email', models.EmailField(blank=True, verbose_name='email address', max_length=254)), - ('is_staff', models.BooleanField(help_text='Designates whether the user can log into this admin site.', verbose_name='staff status', default=False)), - ('is_active', models.BooleanField(help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active', default=True)), - ('date_joined', models.DateTimeField(verbose_name='date joined', default=django.utils.timezone.now)), - ('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Name', null=True)), - ('groups', models.ManyToManyField(to='auth.Group', related_query_name='user', help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', blank=True, verbose_name='groups', related_name='user_set')), - ('user_permissions', models.ManyToManyField(to='auth.Permission', related_query_name='user', help_text='Specific permissions for this user.', blank=True, verbose_name='user permissions', related_name='user_set')), - ], - options={ - 'get_latest_by': 'date_joined', - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/course_discovery/apps/core/migrations/0001_squashed_0011_auto_20161101_2207.py b/course_discovery/apps/core/migrations/0001_squashed_0011_auto_20161101_2207.py index 4fe2eccb06..56951c362c 100644 --- a/course_discovery/apps/core/migrations/0001_squashed_0011_auto_20161101_2207.py +++ b/course_discovery/apps/core/migrations/0001_squashed_0011_auto_20161101_2207.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.10 on 2016-11-03 22:04 -from __future__ import unicode_literals + import django.contrib.auth.models import django.core.validators @@ -20,8 +19,8 @@ def add_currencies(apps, schema_editor): """ Currency = apps.get_model('core', 'Currency') Currency.objects.bulk_create( - [Currency(code=currency.letter, name=currency.name) for currency in pycountry.currencies if - not currency.letter.startswith('X')] + [Currency(code=currency.alpha_3, name=currency.name) for currency in pycountry.currencies if + not currency.alpha_3.startswith('X')] ) diff --git a/course_discovery/apps/core/migrations/0002_partner_studio_url.py b/course_discovery/apps/core/migrations/0002_partner_studio_url.py index 5b681e4d27..fb69029841 100644 --- a/course_discovery/apps/core/migrations/0002_partner_studio_url.py +++ b/course_discovery/apps/core/migrations/0002_partner_studio_url.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-26 13:31 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/core/migrations/0002_userthrottlerate.py b/course_discovery/apps/core/migrations/0002_userthrottlerate.py deleted file mode 100644 index a8bb0d5cf3..0000000000 --- a/course_discovery/apps/core/migrations/0002_userthrottlerate.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='UserThrottleRate', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('rate', models.CharField(max_length=50)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/course_discovery/apps/core/migrations/0003_auto_20160315_1910.py b/course_discovery/apps/core/migrations/0003_auto_20160315_1910.py deleted file mode 100644 index b8aa61a228..0000000000 --- a/course_discovery/apps/core/migrations/0003_auto_20160315_1910.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0002_userthrottlerate'), - ] - - operations = [ - migrations.AlterField( - model_name='userthrottlerate', - name='rate', - field=models.CharField(help_text='The rate of requests to limit this user to. The format is specified by Django Rest Framework (see http://www.django-rest-framework.org/api-guide/throttling/).', max_length=50), - ), - ] diff --git a/course_discovery/apps/core/migrations/0003_auto_20170522_0856.py b/course_discovery/apps/core/migrations/0003_auto_20170522_0856.py index bac556c295..e5ab0cf2e7 100644 --- a/course_discovery/apps/core/migrations/0003_auto_20170522_0856.py +++ b/course_discovery/apps/core/migrations/0003_auto_20170522_0856.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.7 on 2017-05-22 08:56 -from __future__ import unicode_literals + import django.contrib.auth.validators from django.db import migrations, models diff --git a/course_discovery/apps/core/migrations/0004_currency.py b/course_discovery/apps/core/migrations/0004_currency.py deleted file mode 100644 index 2beeaa5251..0000000000 --- a/course_discovery/apps/core/migrations/0004_currency.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0003_auto_20160315_1910'), - ] - - operations = [ - migrations.CreateModel( - name='Currency', - fields=[ - ('code', models.CharField(unique=True, primary_key=True, serialize=False, max_length=6)), - ('name', models.CharField(max_length=255)), - ], - options={ - 'verbose_name_plural': 'Currencies', - }, - ), - ] diff --git a/course_discovery/apps/core/migrations/0004_partner_site.py b/course_discovery/apps/core/migrations/0004_partner_site.py index 3e07d5e2c9..89415f3689 100644 --- a/course_discovery/apps/core/migrations/0004_partner_site.py +++ b/course_discovery/apps/core/migrations/0004_partner_site.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.7 on 2017-06-05 09:08 -from __future__ import unicode_literals + import django.db.models.deletion from django.db import migrations, models diff --git a/course_discovery/apps/core/migrations/0005_auto_20170830_1246.py b/course_discovery/apps/core/migrations/0005_auto_20170830_1246.py index 8e271ca63c..2a008d2ccb 100644 --- a/course_discovery/apps/core/migrations/0005_auto_20170830_1246.py +++ b/course_discovery/apps/core/migrations/0005_auto_20170830_1246.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-08-30 12:46 -from __future__ import unicode_literals + import django.db.models.deletion from django.db import migrations, models diff --git a/course_discovery/apps/core/migrations/0005_populate_currencies.py b/course_discovery/apps/core/migrations/0005_populate_currencies.py deleted file mode 100644 index 846e0f2561..0000000000 --- a/course_discovery/apps/core/migrations/0005_populate_currencies.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import pycountry -from django.db import migrations - - -def add_currencies(apps, schema_editor): - """ Populates the currency table. - - Data is pulled from pycountry. X currencies are not included given their limited use, and a desire - to limit the size of the options displayed in Django admin. - """ - Currency = apps.get_model('core', 'Currency') - Currency.objects.bulk_create( - [Currency(code=currency.letter, name=currency.name) for currency in pycountry.currencies if - not currency.letter.startswith('X')] - ) - - -def remove_currencies(apps, schema_editor): - """ Deletes all rows in the currency table. """ - Currency = apps.get_model('core', 'Currency') - Currency.objects.all().delete() - - -class Migration(migrations.Migration): - dependencies = [ - ('core', '0004_currency'), - ] - - operations = [ - migrations.RunPython(add_currencies, remove_currencies), - ] diff --git a/course_discovery/apps/core/migrations/0006_partner_lms_url.py b/course_discovery/apps/core/migrations/0006_partner_lms_url.py index 8c8861f21b..89d74a532b 100644 --- a/course_discovery/apps/core/migrations/0006_partner_lms_url.py +++ b/course_discovery/apps/core/migrations/0006_partner_lms_url.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-09-28 10:50 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/core/migrations/0007_auto_20171004_1133.py b/course_discovery/apps/core/migrations/0007_auto_20171004_1133.py index aecfeae5a2..3dc1c0a072 100644 --- a/course_discovery/apps/core/migrations/0007_auto_20171004_1133.py +++ b/course_discovery/apps/core/migrations/0007_auto_20171004_1133.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-10-04 11:33 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/core/migrations/0008_auto_20181217_1957.py b/course_discovery/apps/core/migrations/0008_auto_20181217_1957.py index 10ec4de959..4ac525826a 100644 --- a/course_discovery/apps/core/migrations/0008_auto_20181217_1957.py +++ b/course_discovery/apps/core/migrations/0008_auto_20181217_1957.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-17 19:57 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/core/migrations/0008_partner.py b/course_discovery/apps/core/migrations/0008_partner.py deleted file mode 100644 index 2b8419fe12..0000000000 --- a/course_discovery/apps/core/migrations/0008_partner.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import django_extensions.db.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0007_auto_20160510_2017'), - ] - - operations = [ - migrations.CreateModel( - name='Partner', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)), - ('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)), - ('name', models.CharField(max_length=128, unique=True)), - ('short_code', models.CharField(max_length=8, unique=True)), - ('courses_api_url', models.URLField(max_length=255, null=True)), - ('ecommerce_api_url', models.URLField(max_length=255, null=True)), - ('organizations_api_url', models.URLField(max_length=255, null=True)), - ('programs_api_url', models.URLField(max_length=255, null=True)), - ('marketing_api_url', models.URLField(max_length=255, null=True)), - ('marketing_url_root', models.URLField(max_length=255, null=True)), - ('social_auth_edx_oidc_url_root', models.CharField(max_length=255, null=True)), - ('social_auth_edx_oidc_key', models.CharField(max_length=255, null=True)), - ('social_auth_edx_oidc_secret', models.CharField(max_length=255, null=True)), - ], - options={ - 'verbose_name': 'Partner', - 'verbose_name_plural': 'Partners', - }, - ), - ] diff --git a/course_discovery/apps/core/migrations/0009_auto_20160730_2131.py b/course_discovery/apps/core/migrations/0009_auto_20160730_2131.py deleted file mode 100644 index f101b96c4a..0000000000 --- a/course_discovery/apps/core/migrations/0009_auto_20160730_2131.py +++ /dev/null @@ -1,89 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0008_partner'), - ] - - operations = [ - migrations.AlterField( - model_name='partner', - name='courses_api_url', - field=models.URLField(null=True, verbose_name='Courses API URL', max_length=255, blank=True), - ), - migrations.AlterField( - model_name='partner', - name='ecommerce_api_url', - field=models.URLField(null=True, verbose_name='E-Commerce API URL', max_length=255, blank=True), - ), - migrations.AlterField( - model_name='partner', - name='marketing_api_url', - field=models.URLField(blank=True, null=True, max_length=255, verbose_name='Marketing Site API URL'), - ), - migrations.AlterField( - model_name='partner', - name='marketing_url_root', - field=models.URLField(blank=True, null=True, max_length=255, verbose_name='Marketing Site URL'), - ), - migrations.AlterField( - model_name='partner', - name='organizations_api_url', - field=models.URLField(null=True, verbose_name='Organizations API URL', max_length=255, blank=True), - ), - migrations.AlterField( - model_name='partner', - name='programs_api_url', - field=models.URLField(blank=True, null=True, max_length=255, verbose_name='Programs API URL'), - ), - migrations.AlterField( - model_name='partner', - name='short_code', - field=models.CharField(unique=True, help_text='Convenient code/slug used to identify this Partner (e.g. for management commands.)', max_length=8, verbose_name='Short Code'), - ), - migrations.AlterField( - model_name='partner', - name='social_auth_edx_oidc_key', - field=models.CharField(null=True, max_length=255, verbose_name='OpenID Connect Key'), - ), - migrations.AlterField( - model_name='partner', - name='social_auth_edx_oidc_secret', - field=models.CharField(null=True, max_length=255, verbose_name='OpenID Connect Secret'), - ), - migrations.AlterField( - model_name='partner', - name='social_auth_edx_oidc_url_root', - field=models.CharField(null=True, max_length=255, verbose_name='OpenID Connect URL'), - ), - migrations.RenameField( - model_name='partner', - old_name='social_auth_edx_oidc_key', - new_name='oidc_key', - ), - migrations.RenameField( - model_name='partner', - old_name='social_auth_edx_oidc_secret', - new_name='oidc_secret', - ), - migrations.RenameField( - model_name='partner', - old_name='social_auth_edx_oidc_url_root', - new_name='oidc_url_root', - ), - migrations.RenameField( - model_name='partner', - old_name='marketing_api_url', - new_name='marketing_site_api_url', - ), - migrations.RenameField( - model_name='partner', - old_name='marketing_url_root', - new_name='marketing_site_url_root', - ), - ] diff --git a/course_discovery/apps/core/migrations/0009_partner_lms_commerce_api_url.py b/course_discovery/apps/core/migrations/0009_partner_lms_commerce_api_url.py new file mode 100644 index 0000000000..df20a794a8 --- /dev/null +++ b/course_discovery/apps/core/migrations/0009_partner_lms_commerce_api_url.py @@ -0,0 +1,19 @@ +# Generated by Django 1.11.15 on 2019-03-14 15:14 + + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_auto_20181217_1957'), + ] + + operations = [ + migrations.AddField( + model_name='partner', + name='lms_commerce_api_url', + field=models.URLField(blank=True, max_length=255, null=True, verbose_name='Commerce API URL'), + ), + ] diff --git a/course_discovery/apps/core/migrations/0010_auto_20160731_0023.py b/course_discovery/apps/core/migrations/0010_auto_20160731_0023.py deleted file mode 100644 index 2ca450a1b9..0000000000 --- a/course_discovery/apps/core/migrations/0010_auto_20160731_0023.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0009_auto_20160730_2131'), - ] - - operations = [ - migrations.AddField( - model_name='partner', - name='marketing_site_api_password', - field=models.CharField(verbose_name='Marketing Site API Password', blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='partner', - name='marketing_site_api_username', - field=models.CharField(verbose_name='Marketing Site API Username', blank=True, max_length=255, null=True), - ), - ] diff --git a/course_discovery/apps/core/migrations/0010_partner_lms_coursemode_api_url.py b/course_discovery/apps/core/migrations/0010_partner_lms_coursemode_api_url.py new file mode 100644 index 0000000000..298b0b4be0 --- /dev/null +++ b/course_discovery/apps/core/migrations/0010_partner_lms_coursemode_api_url.py @@ -0,0 +1,19 @@ +# Generated by Django 1.11.15 on 2019-04-04 17:26 + + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_partner_lms_commerce_api_url'), + ] + + operations = [ + migrations.AddField( + model_name='partner', + name='lms_coursemode_api_url', + field=models.URLField(blank=True, max_length=255, null=True, verbose_name='Course Mode API URL'), + ), + ] diff --git a/course_discovery/apps/core/migrations/0011_auto_20161101_2207.py b/course_discovery/apps/core/migrations/0011_auto_20161101_2207.py deleted file mode 100644 index 68dab2010d..0000000000 --- a/course_discovery/apps/core/migrations/0011_auto_20161101_2207.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.10 on 2016-11-01 22:07 -from __future__ import unicode_literals - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0010_auto_20160731_0023'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='username', - field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=30, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.')], verbose_name='username'), - ), - ] diff --git a/course_discovery/apps/core/migrations/0011_remove_partner_lms_commerce_api_url.py b/course_discovery/apps/core/migrations/0011_remove_partner_lms_commerce_api_url.py new file mode 100644 index 0000000000..6b60836c59 --- /dev/null +++ b/course_discovery/apps/core/migrations/0011_remove_partner_lms_commerce_api_url.py @@ -0,0 +1,18 @@ +# Generated by Django 1.11.15 on 2019-04-12 17:31 + + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_partner_lms_coursemode_api_url'), + ] + + operations = [ + migrations.RemoveField( + model_name='partner', + name='lms_commerce_api_url', + ), + ] diff --git a/course_discovery/apps/core/migrations/0012_partner_publisher_url.py b/course_discovery/apps/core/migrations/0012_partner_publisher_url.py new file mode 100644 index 0000000000..928280a63d --- /dev/null +++ b/course_discovery/apps/core/migrations/0012_partner_publisher_url.py @@ -0,0 +1,19 @@ +# Generated by Django 1.11.15 on 2019-05-31 13:50 + + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_remove_partner_lms_commerce_api_url'), + ] + + operations = [ + migrations.AddField( + model_name='partner', + name='publisher_url', + field=models.URLField(blank=True, help_text='The base URL of your publisher service, if used. Example: https://publisher.example.com/', max_length=255, null=True, verbose_name='Publisher URL'), + ), + ] diff --git a/course_discovery/apps/core/migrations/0013_historicalpartner.py b/course_discovery/apps/core/migrations/0013_historicalpartner.py new file mode 100644 index 0000000000..30f75be2b7 --- /dev/null +++ b/course_discovery/apps/core/migrations/0013_historicalpartner.py @@ -0,0 +1,58 @@ +# Generated by Django 1.11.15 on 2019-06-05 17:58 + + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('core', '0012_partner_publisher_url'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalPartner', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('name', models.CharField(db_index=True, max_length=128)), + ('short_code', models.CharField(db_index=True, help_text='Convenient code/slug used to identify this Partner (e.g. for management commands.)', max_length=8, verbose_name='Short Code')), + ('courses_api_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='Courses API URL')), + ('lms_coursemode_api_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='Course Mode API URL')), + ('ecommerce_api_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='E-Commerce API URL')), + ('organizations_api_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='Organizations API URL')), + ('programs_api_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='Programs API URL')), + ('marketing_site_api_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='Marketing Site API URL')), + ('marketing_site_url_root', models.URLField(blank=True, max_length=255, null=True, verbose_name='Marketing Site URL')), + ('marketing_site_api_username', models.CharField(blank=True, max_length=255, null=True, verbose_name='Marketing Site API Username')), + ('marketing_site_api_password', models.CharField(blank=True, max_length=255, null=True, verbose_name='Marketing Site API Password')), + ('oidc_url_root', models.CharField(max_length=255, null=True, verbose_name='OpenID Connect URL')), + ('oidc_key', models.CharField(max_length=255, null=True, verbose_name='OpenID Connect Key')), + ('oidc_secret', models.CharField(max_length=255, null=True, verbose_name='OpenID Connect Secret')), + ('studio_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='Studio URL')), + ('publisher_url', models.URLField(blank=True, help_text='The base URL of your publisher service, if used. Example: https://publisher.example.com/', max_length=255, null=True, verbose_name='Publisher URL')), + ('lms_url', models.URLField(blank=True, max_length=255, null=True, verbose_name='LMS URL')), + ('analytics_url', models.URLField(blank=True, default='', max_length=255, verbose_name='Analytics API URL')), + ('analytics_token', models.CharField(blank=True, default='', max_length=255, verbose_name='Analytics Access Token')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('site', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='sites.Site')), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical Partner', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/core/migrations/0014_historicalsalesforceconfiguration_salesforceconfiguration.py b/course_discovery/apps/core/migrations/0014_historicalsalesforceconfiguration_salesforceconfiguration.py new file mode 100644 index 0000000000..4f74ff10fc --- /dev/null +++ b/course_discovery/apps/core/migrations/0014_historicalsalesforceconfiguration_salesforceconfiguration.py @@ -0,0 +1,52 @@ +# Generated by Django 1.11.21 on 2019-06-14 13:46 + + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_historicalpartner'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalSalesforceConfiguration', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('username', models.CharField(max_length=255, verbose_name='Salesforce Username')), + ('password', models.CharField(max_length=255, verbose_name='Salesforce Password')), + ('organization_id', models.CharField(blank=True, default='', max_length=255, verbose_name='Salesforce Organization Id')), + ('security_token', models.CharField(blank=True, default='', max_length=255, verbose_name='Salesforce Security Token')), + ('is_sandbox', models.BooleanField(default=True, verbose_name='Is a Salesforce Sandbox?')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('partner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Partner')), + ], + options={ + 'verbose_name': 'historical salesforce configuration', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='SalesforceConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=255, verbose_name='Salesforce Username')), + ('password', models.CharField(max_length=255, verbose_name='Salesforce Password')), + ('organization_id', models.CharField(blank=True, default='', max_length=255, verbose_name='Salesforce Organization Id')), + ('security_token', models.CharField(blank=True, default='', max_length=255, verbose_name='Salesforce Security Token')), + ('is_sandbox', models.BooleanField(default=True, verbose_name='Is a Salesforce Sandbox?')), + ('partner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='salesforce', to='core.Partner')), + ], + ), + ] diff --git a/course_discovery/apps/core/migrations/0015_add_lms_admin_url.py b/course_discovery/apps/core/migrations/0015_add_lms_admin_url.py new file mode 100644 index 0000000000..6d71e221ba --- /dev/null +++ b/course_discovery/apps/core/migrations/0015_add_lms_admin_url.py @@ -0,0 +1,24 @@ +# Generated by Django 1.11.21 on 2019-06-24 15:01 + + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_historicalsalesforceconfiguration_salesforceconfiguration'), + ] + + operations = [ + migrations.AddField( + model_name='historicalpartner', + name='lms_admin_url', + field=models.URLField(blank=True, help_text='The public URL of your LMS Django admin. Example: https://lms-internal.example.com/admin', max_length=255, null=True, verbose_name='LMS Admin URL'), + ), + migrations.AddField( + model_name='partner', + name='lms_admin_url', + field=models.URLField(blank=True, help_text='The public URL of your LMS Django admin. Example: https://lms-internal.example.com/admin', max_length=255, null=True, verbose_name='LMS Admin URL'), + ), + ] diff --git a/course_discovery/apps/core/migrations/0016_add_case_record_type_id.py b/course_discovery/apps/core/migrations/0016_add_case_record_type_id.py new file mode 100644 index 0000000000..b9b6ff87a1 --- /dev/null +++ b/course_discovery/apps/core/migrations/0016_add_case_record_type_id.py @@ -0,0 +1,24 @@ +# Generated by Django 1.11.22 on 2019-08-13 14:17 + + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_add_lms_admin_url'), + ] + + operations = [ + migrations.AddField( + model_name='historicalsalesforceconfiguration', + name='case_record_type_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Case Record Type Id'), + ), + migrations.AddField( + model_name='salesforceconfiguration', + name='case_record_type_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Case Record Type Id'), + ), + ] diff --git a/course_discovery/apps/core/migrations/0017_drop_oidc_fields.py b/course_discovery/apps/core/migrations/0017_drop_oidc_fields.py new file mode 100644 index 0000000000..68c657fada --- /dev/null +++ b/course_discovery/apps/core/migrations/0017_drop_oidc_fields.py @@ -0,0 +1,38 @@ +# Generated by Django 1.11.26 on 2019-11-26 16:48 + + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalpartner', + name='oidc_key', + ), + migrations.RemoveField( + model_name='historicalpartner', + name='oidc_secret', + ), + migrations.RemoveField( + model_name='historicalpartner', + name='oidc_url_root', + ), + migrations.RemoveField( + model_name='partner', + name='oidc_key', + ), + migrations.RemoveField( + model_name='partner', + name='oidc_secret', + ), + migrations.RemoveField( + model_name='partner', + name='oidc_url_root', + ), + ] diff --git a/course_discovery/apps/core/migrations/0018_auto_20200414_0739.py b/course_discovery/apps/core/migrations/0018_auto_20200414_0739.py new file mode 100644 index 0000000000..1c63a44fbb --- /dev/null +++ b/course_discovery/apps/core/migrations/0018_auto_20200414_0739.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-04-14 07:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_drop_oidc_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='last_name', + field=models.CharField(blank=True, max_length=150, verbose_name='last name'), + ), + ] diff --git a/course_discovery/apps/core/migrations/0019_auto_20240402_1929.py b/course_discovery/apps/core/migrations/0019_auto_20240402_1929.py new file mode 100644 index 0000000000..ddccf2d83f --- /dev/null +++ b/course_discovery/apps/core/migrations/0019_auto_20240402_1929.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2024-04-02 19:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_auto_20200414_0739'), + ] + + operations = [ + migrations.AddField( + model_name='historicalpartner', + name='is_disabled', + field=models.BooleanField(default=False, verbose_name='Disable Partner'), + ), + migrations.AddField( + model_name='partner', + name='is_disabled', + field=models.BooleanField(default=False, verbose_name='Disable Partner'), + ), + ] diff --git a/course_discovery/apps/core/models.py b/course_discovery/apps/core/models.py index 2262839d26..54f41f0f2c 100644 --- a/course_discovery/apps/core/models.py +++ b/course_discovery/apps/core/models.py @@ -1,6 +1,7 @@ """ Core models. """ import datetime +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.contrib.sites.models import Site from django.core.cache import cache @@ -8,8 +9,9 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django_extensions.db.models import TimeStampedModel -from edx_rest_api_client.client import EdxRestApiClient +from edx_rest_api_client.client import EdxRestApiClient, OAuthAPIClient from guardian.mixins import GuardianUserMixin +from simple_history.models import HistoricalRecords class User(GuardianUserMixin, AbstractUser): @@ -30,17 +32,17 @@ def access_token(self): return None - class Meta(object): # pylint:disable=missing-docstring + class Meta: get_latest_by = 'date_joined' def get_full_name(self): - return self.full_name or super(User, self).get_full_name() + return self.full_name or super().get_full_name() class UserThrottleRate(models.Model): """Model for configuring a rate limit per-user.""" - user = models.ForeignKey(User) + user = models.ForeignKey(User, models.CASCADE) rate = models.CharField( max_length=50, help_text=_( @@ -55,9 +57,9 @@ class Currency(models.Model): name = models.CharField(max_length=255) def __str__(self): - return '{code} - {name}'.format(code=self.code, name=self.name) + return f'{self.code} - {self.name}' - class Meta(object): + class Meta: verbose_name_plural = 'Currencies' @@ -67,6 +69,9 @@ class Partner(TimeStampedModel): max_length=8, unique=True, null=False, blank=False, verbose_name=_('Short Code'), help_text=_('Convenient code/slug used to identify this Partner (e.g. for management commands.)')) courses_api_url = models.URLField(max_length=255, null=True, blank=True, verbose_name=_('Courses API URL')) + lms_coursemode_api_url = models.URLField( + max_length=255, null=True, blank=True, + verbose_name=_('Course Mode API URL')) ecommerce_api_url = models.URLField(max_length=255, null=True, blank=True, verbose_name=_('E-Commerce API URL')) organizations_api_url = models.URLField(max_length=255, null=True, blank=True, verbose_name=_('Organizations API URL')) @@ -79,14 +84,23 @@ class Partner(TimeStampedModel): verbose_name=_('Marketing Site API Username')) marketing_site_api_password = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Marketing Site API Password')) - oidc_url_root = models.CharField(max_length=255, null=True, verbose_name=_('OpenID Connect URL')) - oidc_key = models.CharField(max_length=255, null=True, verbose_name=_('OpenID Connect Key')) - oidc_secret = models.CharField(max_length=255, null=True, verbose_name=_('OpenID Connect Secret')) + studio_url = models.URLField(max_length=255, null=True, blank=True, verbose_name=_('Studio URL')) - site = models.OneToOneField(Site, on_delete=models.PROTECT) + publisher_url = models.URLField( + max_length=255, null=True, blank=True, verbose_name=_('Publisher URL'), + help_text=_('The base URL of your publisher service, if used. Example: https://publisher.example.com/') + ) + site = models.OneToOneField(Site, models.PROTECT) lms_url = models.URLField(max_length=255, null=True, blank=True, verbose_name=_('LMS URL')) + lms_admin_url = models.URLField( + max_length=255, null=True, blank=True, verbose_name=_('LMS Admin URL'), + help_text=_('The public URL of your LMS Django admin. Example: https://lms-internal.example.com/admin'), + ) analytics_url = models.URLField(max_length=255, blank=True, verbose_name=_('Analytics API URL'), default='') analytics_token = models.CharField(max_length=255, blank=True, verbose_name=_('Analytics Access Token'), default='') + is_disabled = models.BooleanField(verbose_name=_('Disable Partner'), default=False) + + history = HistoricalRecords() def __str__(self): return self.name @@ -95,26 +109,46 @@ class Meta: verbose_name = _('Partner') verbose_name_plural = _('Partners') + @property + def oauth2_provider_url(self): + return settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL + + @property + def oauth2_client_id(self): + return settings.BACKEND_SERVICE_EDX_OAUTH2_KEY + + @property + def oauth2_client_secret(self): + return settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET + @property def has_marketing_site(self): return bool(self.marketing_site_url_root) + @property + def uses_publisher(self): + return settings.ENABLE_PUBLISHER and self.publisher_url + @property def access_token(self): - """ Returns an access token for this site's service user. + """ + Returns the access token for this service. + + We don't use a cached_property decorator because we aren't sure how to + set custom expiration dates for those. Returns: str: JWT access token """ - key = 'partner_access_token_{}'.format(self.id) + key = 'oauth2_access_token' access_token = cache.get(key) if not access_token: - url = '{root}/access_token'.format(root=self.oidc_url_root) + url = f'{self.oauth2_provider_url}/access_token' access_token, expiration_datetime = EdxRestApiClient.get_oauth_access_token( url, - self.oidc_key, - self.oidc_secret, + self.oauth2_client_id, + self.oauth2_client_secret, token_type='jwt' ) @@ -127,3 +161,51 @@ def access_token(self): def studio_api_client(self): studio_api_url = '{root}/api/v1/'.format(root=self.studio_url.strip('/')) return EdxRestApiClient(studio_api_url, jwt=self.access_token) + + @cached_property + def lms_api_client(self): + if not self.lms_url: + return None + + return OAuthAPIClient( + self.lms_url.strip('/'), + self.oauth2_client_id, + self.oauth2_client_secret, + timeout=settings.OAUTH_API_TIMEOUT + ) + + +class SalesforceConfiguration(models.Model): + partner = models.OneToOneField(Partner, models.CASCADE, related_name='salesforce') + username = models.CharField( + max_length=255, + verbose_name=_('Salesforce Username'), + ) + password = models.CharField( + max_length=255, + verbose_name=_('Salesforce Password'), + ) + organization_id = models.CharField( + max_length=255, + blank=True, + verbose_name=_('Salesforce Organization Id'), + default='' + ) + security_token = models.CharField( + max_length=255, + blank=True, + verbose_name=_('Salesforce Security Token'), + default='' + ) + is_sandbox = models.BooleanField( + verbose_name=_('Is a Salesforce Sandbox?'), + default=True + ) + case_record_type_id = models.CharField( + max_length=255, + blank=True, + verbose_name=_('Case Record Type Id'), + null=True, + ) + + history = HistoricalRecords() diff --git a/course_discovery/apps/core/tests/factories.py b/course_discovery/apps/core/tests/factories.py index 5c597ea6eb..43496312cb 100644 --- a/course_discovery/apps/core/tests/factories.py +++ b/course_discovery/apps/core/tests/factories.py @@ -1,8 +1,12 @@ +from uuid import uuid4 + import factory from django.contrib.sites.models import Site -from course_discovery.apps.core.models import Currency, Partner, User +from course_discovery.apps.api.fields import StdImageSerializerField +from course_discovery.apps.core.models import Currency, Partner, SalesforceConfiguration, User from course_discovery.apps.core.tests.utils import FuzzyUrlRoot +from course_discovery.apps.course_metadata.models import Collaborator USER_PASSWORD = 'password' @@ -13,15 +17,24 @@ def add_m2m_data(m2m_relation, data): m2m_relation.add(*data) -class SiteFactory(factory.DjangoModelFactory): +class CollaboratorFactory(factory.django.DjangoModelFactory): + name = factory.Faker('name') + image = StdImageSerializerField() + uuid = factory.LazyFunction(uuid4) + + class Meta: + model = Collaborator + + +class SiteFactory(factory.django.DjangoModelFactory): class Meta: model = Site - domain = factory.Sequence(lambda n: 'test-domain-{number}.fake'.format(number=n)) + domain = factory.Sequence(lambda n: f'test-domain-{n}.fake') name = factory.Faker('name') -class UserFactory(factory.DjangoModelFactory): +class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: 'user_%d' % n) password = factory.PostGenerationMethodCall('set_password', USER_PASSWORD) is_active = True @@ -40,33 +53,46 @@ class StaffUserFactory(UserFactory): is_staff = True -class PartnerFactory(factory.DjangoModelFactory): - name = factory.Sequence(lambda n: 'test-partner-{}'.format(n)) # pylint: disable=unnecessary-lambda - short_code = factory.Sequence(lambda n: 'test{}'.format(n)) # pylint: disable=unnecessary-lambda - courses_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz()) - ecommerce_api_url = '{root}/api/v2/'.format(root=FuzzyUrlRoot().fuzz()) - organizations_api_url = '{root}/api/organizations/v1/'.format(root=FuzzyUrlRoot().fuzz()) - programs_api_url = '{root}/api/programs/v1/'.format(root=FuzzyUrlRoot().fuzz()) - marketing_site_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz()) +class PartnerFactory(factory.django.DjangoModelFactory): + name = factory.Sequence(lambda n: f'test-partner-{n}') # pylint: disable=unnecessary-lambda + short_code = factory.Sequence(lambda n: f'test{n}') # pylint: disable=unnecessary-lambda + courses_api_url = f'{FuzzyUrlRoot().fuzz()}/api/courses/v1/' + lms_coursemode_api_url = f'{FuzzyUrlRoot().fuzz()}/api/course_mode/v1/' + ecommerce_api_url = f'{FuzzyUrlRoot().fuzz()}/api/v2/' + organizations_api_url = f'{FuzzyUrlRoot().fuzz()}/api/organizations/v1/' + programs_api_url = f'{FuzzyUrlRoot().fuzz()}/api/programs/v1/' + marketing_site_api_url = f'{FuzzyUrlRoot().fuzz()}/api/courses/v1/' marketing_site_url_root = factory.Faker('url') marketing_site_api_username = factory.Faker('user_name') marketing_site_api_password = factory.Faker('password') analytics_url = factory.Faker('url') analytics_token = factory.Faker('sha256') - oidc_url_root = factory.Faker('url') - oidc_key = factory.Faker('sha256') - oidc_secret = factory.Faker('sha256') + lms_url = '' + lms_admin_url = f'{FuzzyUrlRoot().fuzz()}/admin' site = factory.SubFactory(SiteFactory) studio_url = factory.Faker('url') - lms_url = factory.Faker('url') + publisher_url = factory.Faker('url') - class Meta(object): + class Meta: model = Partner -class CurrencyFactory(factory.DjangoModelFactory): +class CurrencyFactory(factory.django.DjangoModelFactory): code = factory.fuzzy.FuzzyText(length=6) name = factory.fuzzy.FuzzyText() - class Meta(object): + class Meta: model = Currency + + +class SalesforceConfigurationFactory(factory.django.DjangoModelFactory): + username = factory.fuzzy.FuzzyText() + password = factory.fuzzy.FuzzyText() + organization_id = factory.fuzzy.FuzzyText() + security_token = factory.fuzzy.FuzzyText() + is_sandbox = True + partner = factory.SubFactory(PartnerFactory) + case_record_type_id = factory.fuzzy.FuzzyText() + + class Meta: + model = SalesforceConfiguration diff --git a/course_discovery/apps/core/tests/mixins.py b/course_discovery/apps/core/tests/mixins.py index ecc3ade410..c70baff8c0 100644 --- a/course_discovery/apps/core/tests/mixins.py +++ b/course_discovery/apps/core/tests/mixins.py @@ -13,9 +13,9 @@ @pytest.mark.usefixtures('haystack_default_connection') -class ElasticsearchTestMixin(object): +class ElasticsearchTestMixin: def setUp(self): - super(ElasticsearchTestMixin, self).setUp() + super().setUp() self.index = settings.HAYSTACK_CONNECTIONS['default']['INDEX_NAME'] connection = haystack_connections['default'] self.es = connection.get_backend().conn @@ -44,7 +44,7 @@ def reindex_people(self, person): index.update_object(person) -class LMSAPIClientMixin(object): +class LMSAPIClientMixin: def mock_api_access_request(self, lms_url, user, status=200, api_access_request_overrides=None): """ Mock the api access requests endpoint response of the LMS. @@ -79,7 +79,7 @@ def mock_api_access_request(self, lms_url, user, status=200, api_access_request_ responses.add( responses.GET, - lms_url.rstrip('/') + '/api-admin/api/v1/api_access_request/?user__username={}'.format(user.username), + lms_url.rstrip('/') + f'/api-admin/api/v1/api_access_request/?user__username={user.username}', body=json.dumps(data), content_type='application/json', status=status @@ -101,7 +101,7 @@ def mock_api_access_request_with_configurable_results(self, lms_url, user, statu responses.add( responses.GET, - lms_url.rstrip('/') + '/api-admin/api/v1/api_access_request/?user__username={}'.format(user.username), + lms_url.rstrip('/') + f'/api-admin/api/v1/api_access_request/?user__username={user.username}', body=json.dumps(data), content_type='application/json', status=status @@ -115,7 +115,7 @@ def mock_api_access_request_with_invalid_data(self, lms_url, user, status=200, r responses.add( responses.GET, - lms_url.rstrip('/') + '/api-admin/api/v1/api_access_request/?user__username={}'.format(user.username), + lms_url.rstrip('/') + f'/api-admin/api/v1/api_access_request/?user__username={user.username}', body=json.dumps(data), content_type='application/json', status=status diff --git a/course_discovery/apps/core/tests/test_api_clients.py b/course_discovery/apps/core/tests/test_api_clients.py index f0aaa666b4..7a19145f4d 100644 --- a/course_discovery/apps/core/tests/test_api_clients.py +++ b/course_discovery/apps/core/tests/test_api_clients.py @@ -1,8 +1,7 @@ import logging +from unittest import mock -import mock import responses -from django.core.cache import cache from django.test import TestCase from course_discovery.apps.core.api_client import lms @@ -15,20 +14,20 @@ class TestLMSAPIClient(LMSAPIClientMixin, TestCase): @classmethod def setUpClass(cls): - super(TestLMSAPIClient, cls).setUpClass() + super().setUpClass() logger = logging.getLogger(lms.__name__) cls.log_handler = MockLoggingHandler(level='DEBUG') logger.addHandler(cls.log_handler) cls.log_messages = cls.log_handler.messages @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - def setUp(self, mock_access_token): # pylint: disable=unused-argument - super(TestLMSAPIClient, self).setUp() + def setUp(self, _mock_access_token): # pylint: disable=arguments-differ + super().setUp() # Reset mock logger for each test. self.log_handler.reset() self.user = UserFactory.create() - self.partner = PartnerFactory.create() + self.partner = PartnerFactory.create(lms_url='http://127.0.0.1:8000') self.lms = lms.LMSAPIClient(self.partner.site) self.response = { 'id': 1, @@ -43,7 +42,6 @@ def setUp(self, mock_access_token): # pylint: disable=unused-argument 'site': 1, 'contacted': True } - cache.clear() @responses.activate @mock.patch.object(Partner, 'access_token', return_value='JWT fake') diff --git a/course_discovery/apps/core/tests/test_forms.py b/course_discovery/apps/core/tests/test_forms.py index 9be5ec277f..9c41912ac7 100644 --- a/course_discovery/apps/core/tests/test_forms.py +++ b/course_discovery/apps/core/tests/test_forms.py @@ -11,7 +11,7 @@ class UserThrottleRateFormTest(TestCase): """Tests for the UserThrottleRate admin form.""" def setUp(self): - super(UserThrottleRateFormTest, self).setUp() + super().setUp() self.user = UserFactory() def test_form_valid(self): diff --git a/course_discovery/apps/core/tests/test_install_es_indexes.py b/course_discovery/apps/core/tests/test_install_es_indexes.py index 21dccd56b9..fbf5735fdd 100644 --- a/course_discovery/apps/core/tests/test_install_es_indexes.py +++ b/course_discovery/apps/core/tests/test_install_es_indexes.py @@ -13,7 +13,7 @@ def test_create_index(self): index = settings.HAYSTACK_CONNECTIONS['default']['INDEX_NAME'] # Delete the index - self.es.indices.delete(index=index, ignore=404) # pylint: disable=unexpected-keyword-arg + self.es.indices.delete(index=index, ignore=404) self.assertFalse(self.es.indices.exists(index=index)) call_command('install_es_indexes') diff --git a/course_discovery/apps/core/tests/test_lookups.py b/course_discovery/apps/core/tests/test_lookups.py deleted file mode 100644 index fcf7a70910..0000000000 --- a/course_discovery/apps/core/tests/test_lookups.py +++ /dev/null @@ -1,47 +0,0 @@ -import json - -from django.test import TestCase -from django.urls import reverse - -from course_discovery.apps.api.tests.mixins import SiteMixin -from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory - - -class UserAutocompleteTests(SiteMixin, TestCase): - """ Tests for user autocomplete lookups.""" - - def setUp(self): - super(UserAutocompleteTests, self).setUp() - self.user = UserFactory(username='test_name', is_staff=True) - self.client.login(username=self.user.username, password=USER_PASSWORD) - self.users_list = UserFactory.create_batch(5) - - def test_user_autocomplete(self): - """ Verify user autocomplete returns the data. """ - response = self.client.get( - reverse('admin_core:user-autocomplete') + '?q={user}'.format(user='user') - ) - self._assert_response(response, 5) - - # update first user's username - self.users_list[0].username = 'dummy_name' - self.users_list[0].save() - response = self.client.get( - reverse('admin_core:user-autocomplete') + '?q={user}'.format(user='dummy') - ) - self._assert_response(response, 1) - - def test_course_autocomplete_un_authorize_user(self): - """ Verify user autocomplete returns empty list for un-authorized users. """ - self.client.logout() - self.user.is_staff = False - self.user.save() - self.client.login(username=self.user.username, password=USER_PASSWORD) - response = self.client.get(reverse('admin_core:user-autocomplete')) - self._assert_response(response, 0) - - def _assert_response(self, response, expected_length): - """ Assert autocomplete response. """ - self.assertEqual(response.status_code, 200) - data = json.loads(response.content.decode('utf-8')) - self.assertEqual(len(data['results']), expected_length) diff --git a/course_discovery/apps/core/tests/test_models.py b/course_discovery/apps/core/tests/test_models.py index a73e1212ba..26cd59ef01 100644 --- a/course_discovery/apps/core/tests/test_models.py +++ b/course_discovery/apps/core/tests/test_models.py @@ -12,7 +12,7 @@ class UserTests(TestCase): """ User model tests. """ def setUp(self): - super(UserTests, self).setUp() + super().setUp() self.user = UserFactory() def test_access_token_without_social_auth(self): @@ -38,7 +38,7 @@ def test_get_full_name(self): first_name = "Jerry" last_name = "Seinfeld" user = UserFactory(full_name=None, first_name=first_name, last_name=last_name) - expected = "{first_name} {last_name}".format(first_name=first_name, last_name=last_name) + expected = f"{first_name} {last_name}" self.assertEqual(user.get_full_name(), expected) user = UserFactory(full_name=full_name, first_name=first_name, last_name=last_name) @@ -54,7 +54,7 @@ def test_str(self): code = 'USD' name = 'U.S. Dollar' instance = Currency(code=code, name=name) - self.assertEqual(str(instance), '{code} - {name}'.format(code=code, name=name)) + self.assertEqual(str(instance), f'{code} - {name}') @ddt.ddt @@ -75,14 +75,14 @@ def test_str(self): ) def test_has_marketing_site(self, marketing_site_url_root, expected): partner = PartnerFactory(marketing_site_url_root=marketing_site_url_root) - self.assertEqual(partner.has_marketing_site, expected) # pylint: disable=no-member + self.assertEqual(partner.has_marketing_site, expected) @responses.activate def test_access_token(self): """ Verify the property retrieves, and caches, an access token from the OAuth 2.0 provider. """ token = 'abc123' partner = PartnerFactory() - url = '{root}/access_token'.format(root=partner.oidc_url_root) + url = f'{partner.oauth2_provider_url}/access_token' body = { 'access_token': token, 'expires_in': 3600, diff --git a/course_discovery/apps/core/tests/test_throttles.py b/course_discovery/apps/core/tests/test_throttles.py index 6fb68ac55f..4f25c752b6 100644 --- a/course_discovery/apps/core/tests/test_throttles.py +++ b/course_discovery/apps/core/tests/test_throttles.py @@ -1,13 +1,12 @@ -import mock - -from django.conf import settings from django.urls import reverse +from mock import patch from rest_framework.test import APITestCase from course_discovery.apps.api.tests.mixins import SiteMixin from course_discovery.apps.core.models import UserThrottleRate from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory from course_discovery.apps.core.throttles import OverridableUserRateThrottle, throttling_cache +from course_discovery.apps.publisher.tests.factories import GroupFactory class RateLimitingExceededTest(SiteMixin, APITestCase): @@ -16,52 +15,50 @@ class RateLimitingExceededTest(SiteMixin, APITestCase): """ def setUp(self): - super(RateLimitingExceededTest, self).setUp() + super().setUp() self.url = reverse('api_docs') self.user = UserFactory() self.client.login(username=self.user.username, password=USER_PASSWORD) - self.default_rate_patcher = mock.patch.dict( - settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'], - {'user': '10/hour'} - ) - self.default_rate_patcher.start() def tearDown(self): - super(RateLimitingExceededTest, self).tearDown() + super().tearDown() throttling_cache().clear() - self.user.is_superuser = False - self.user.is_staff = False - self.user.save() - self.default_rate_patcher.stop() - def _make_requests(self): + def _make_requests(self, count=None): """ Make multiple requests until the throttle's limit is exceeded. Returns Response: Response of the last request. """ - default_num_requests = OverridableUserRateThrottle().num_requests - for __ in range(default_num_requests + 1): - response = self.client.get(self.url) + count = count or 6 + user_rates = {'user': '5/hour'} + with patch('rest_framework.views.APIView.throttle_classes', (OverridableUserRateThrottle,)): + with patch.object(OverridableUserRateThrottle, 'THROTTLE_RATES', user_rates): + for __ in range(count - 1): + response = self.client.get(self.url) + assert response.status_code == 200 + response = self.client.get(self.url) return response - def test_rate_limiting(self): - """ Verify the API responds with HTTP 429 if a normal user exceeds the rate limit. """ + def assert_rate_limit_successfully_exceeded(self): + """ Asserts that the throttle's rate limit can be exceeded without encountering an error. """ response = self._make_requests() + assert response.status_code == 200 + def assert_rate_limited(self, count=None): + """ Asserts that the throttle's rate limit was exceeded and we were denied. """ + response = self._make_requests(count) assert response.status_code == 429 + def test_rate_limiting(self): + """ Verify the API responds with HTTP 429 if a normal user exceeds the rate limit. """ + self.assert_rate_limited() + def test_user_throttle_rate(self): """ Verify the UserThrottleRate can be used to override the default rate limit. """ - UserThrottleRate.objects.create(user=self.user, rate='20/hour') - self.assert_rate_limit_successfully_exceeded() - - def assert_rate_limit_successfully_exceeded(self): - """ Asserts that the throttle's rate limit can be exceeded without encountering an error. """ - response = self._make_requests() - - assert response.status_code == 200 + UserThrottleRate.objects.create(user=self.user, rate='10/hour') + self.assert_rate_limited(11) def test_superuser_throttling(self): """ Verify superusers are not throttled. """ @@ -74,3 +71,15 @@ def test_staff_throttling(self): self.user.is_staff = True self.user.save() self.assert_rate_limit_successfully_exceeded() + + def test_publisher_user_throttling(self): + """ Verify publisher users are not throttled. """ + self.user.groups.add(GroupFactory()) + self.assert_rate_limit_successfully_exceeded() + + def test_staff_with_user_throttle_rate(self): + """ Verify the UserThrottleRate kicks in even for staff. """ + self.user.is_staff = True + self.user.save() + UserThrottleRate.objects.create(user=self.user, rate='10/hour') + self.assert_rate_limited(11) diff --git a/course_discovery/apps/core/tests/test_utils.py b/course_discovery/apps/core/tests/test_utils.py index bcf6b8499a..83196e802e 100644 --- a/course_discovery/apps/core/tests/test_utils.py +++ b/course_discovery/apps/core/tests/test_utils.py @@ -2,9 +2,9 @@ from django.test import TestCase from haystack.query import SearchQuerySet -from course_discovery.apps.core.utils import SearchQuerySetWrapper, get_all_related_field_names -from course_discovery.apps.course_metadata.models import CourseRun -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory +from course_discovery.apps.core.utils import SearchQuerySetWrapper, delete_orphans, get_all_related_field_names +from course_discovery.apps.course_metadata.models import CourseRun, Video +from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, VideoFactory class UnrelatedModel(models.Model): @@ -20,7 +20,7 @@ class Meta: class ForeignRelatedModel(models.Model): - fk = models.ForeignKey(RelatedModel) + fk = models.ForeignKey(RelatedModel, models.CASCADE) class Meta: app_label = 'core' @@ -41,10 +41,28 @@ def test_get_all_related_field_names(self): self.assertEqual(get_all_related_field_names(UnrelatedModel), []) self.assertEqual(set(get_all_related_field_names(RelatedModel)), {'foreignrelatedmodel', 'm2mrelatedmodel'}) + def test_delete_orphans(self): + """ Verify the delete_orphans method deletes orphaned instances. """ + orphan = VideoFactory() + used = CourseRunFactory().video + + delete_orphans(Video) + + self.assertTrue(used.__class__.objects.filter(pk=used.pk).exists()) + self.assertFalse(orphan.__class__.objects.filter(pk=orphan.pk).exists()) + + def test_delete_orphans_with_exclusions(self): + """Verify an orphan is not deleted if it is passed in as excluded""" + orphan = VideoFactory() + + delete_orphans(Video, {orphan.pk}) + + self.assertTrue(orphan.__class__.objects.filter(pk=orphan.pk).exists()) + class SearchQuerySetWrapperTests(TestCase): def setUp(self): - super(SearchQuerySetWrapperTests, self).setUp() + super().setUp() title = 'Some random course' query = 'title:' + title @@ -57,7 +75,7 @@ def test_count(self): self.assertEqual(self.search_queryset.count(), self.wrapper.count()) def test_iter(self): - self.assertEqual([e for e in self.course_runs], [e for e in self.wrapper]) + self.assertEqual(self.course_runs, list(self.wrapper)) def test_getitem(self): self.assertEqual(self.course_runs[0], self.wrapper[0]) diff --git a/course_discovery/apps/core/tests/test_views.py b/course_discovery/apps/core/tests/test_views.py index 974cba8929..28753f8934 100644 --- a/course_discovery/apps/core/tests/test_views.py +++ b/course_discovery/apps/core/tests/test_views.py @@ -1,6 +1,7 @@ """Test core.views.""" -import mock +from unittest import mock + from django.conf import settings from django.contrib.auth import get_user_model from django.db import DatabaseError diff --git a/course_discovery/apps/core/tests/utils.py b/course_discovery/apps/core/tests/utils.py index 38bb23fb8e..f88be25f57 100644 --- a/course_discovery/apps/core/tests/utils.py +++ b/course_discovery/apps/core/tests/utils.py @@ -15,8 +15,8 @@ def fuzz(self): tld = FuzzyChoice(('com', 'net', 'org', 'biz', 'pizza', 'coffee', 'diamonds', 'fail', 'win', 'wtf',)) return "{subdomain}.{domain}.{tld}".format( - subdomain=subdomain.fuzz(), - domain=domain.fuzz(), + subdomain=subdomain.fuzz().lower(), + domain=domain.fuzz().lower(), tld=tld.fuzz() ) @@ -45,8 +45,6 @@ def fuzz(self): def mock_api_callback(url, data, results_key=True, pagination=False): def request_callback(request): - # pylint: disable=redefined-builtin - count = len(data) next_url = None previous_url = None @@ -59,11 +57,11 @@ def request_callback(request): if (page * page_size) < count: next_page = page + 1 - next_url = '{}?page={}'.format(url, next_page) + next_url = f'{url}?page={next_page}' if page > 1: previous_page = page - 1 - previous_url = '{}?page={}'.format(url, previous_page) + previous_url = f'{url}?page={previous_page}' body = { 'count': count, @@ -87,7 +85,7 @@ def request_callback(request): def mock_jpeg_callback(): - def request_callback(request): # pylint: disable=unused-argument + def request_callback(request): image_stream = make_image_stream(2120, 1192) return 200, {}, image_stream.getvalue() @@ -111,7 +109,7 @@ def __init__(self, *args, **kwargs): 'error': [], 'critical': [], } - super(MockLoggingHandler, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def emit(self, record): """ diff --git a/course_discovery/apps/core/throttles.py b/course_discovery/apps/core/throttles.py index b43fd5c042..c638bb3739 100644 --- a/course_discovery/apps/core/throttles.py +++ b/course_discovery/apps/core/throttles.py @@ -3,6 +3,7 @@ from rest_framework.throttling import UserRateThrottle from course_discovery.apps.core.models import UserThrottleRate +from course_discovery.apps.publisher.utils import is_publisher_user def throttling_cache(): @@ -22,15 +23,15 @@ class OverridableUserRateThrottle(UserRateThrottle): def allow_request(self, request, view): user = request.user - if user and user.is_authenticated(): - if user.is_superuser or user.is_staff: - return True + if user and user.is_authenticated: try: # Override this throttle's rate if applicable user_throttle = UserThrottleRate.objects.get(user=user) self.rate = user_throttle.rate self.num_requests, self.duration = self.parse_rate(self.rate) except UserThrottleRate.DoesNotExist: - pass + # If we don't have a custom user override, skip throttling if they are a privileged user + if user.is_superuser or user.is_staff or is_publisher_user(user): + return True - return super(OverridableUserRateThrottle, self).allow_request(request, view) + return super().allow_request(request, view) diff --git a/course_discovery/apps/core/urls.py b/course_discovery/apps/core/urls.py deleted file mode 100644 index 28eff71f07..0000000000 --- a/course_discovery/apps/core/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -URLs for the admin autocomplete lookups. -""" -from django.conf.urls import url - -from course_discovery.apps.core.lookups import UserAutocomplete - -urlpatterns = [ - url(r'^user-autocomplete/$', UserAutocomplete.as_view(), name='user-autocomplete',), -] diff --git a/course_discovery/apps/core/utils.py b/course_discovery/apps/core/utils.py index 12d8d5e67e..fb2b899e85 100644 --- a/course_discovery/apps/core/utils.py +++ b/course_discovery/apps/core/utils.py @@ -12,7 +12,7 @@ def serialize_datetime(d): return d.strftime('%Y-%m-%dT%H:%M:%SZ') if d else None -class ElasticsearchUtils(object): +class ElasticsearchUtils: @classmethod def create_alias_and_index(cls, es_connection, alias): logger.info('Making sure alias [%s] exists...', alias) @@ -46,7 +46,7 @@ def create_index(cls, es_connection, prefix): index_name (str): Name of the new index. """ timestamp = datetime.datetime.utcnow().strftime('%Y%m%d_%H%M%S') - index_name = '{alias}_{timestamp}'.format(alias=prefix, timestamp=timestamp) + index_name = f'{prefix}_{timestamp}' index_settings = settings.ELASTICSEARCH_INDEX_SETTINGS index_settings['settings']['analysis']['filter']['synonym']['synonyms'] = get_synonyms(es_connection) es_connection.indices.create(index=index_name, body=index_settings) @@ -56,7 +56,7 @@ def create_index(cls, es_connection, prefix): @classmethod def delete_index(cls, es_connection, index): logger.info('Deleting index [%s]...', index) - es_connection.indices.delete(index=index, ignore=404) # pylint: disable=unexpected-keyword-arg + es_connection.indices.delete(index=index, ignore=404) logger.info('...index deleted.') @classmethod @@ -83,26 +83,31 @@ def get_all_related_field_names(model): list[str] """ fields = model._meta._get_fields(forward=False) # pylint: disable=protected-access - names = set([field.name for field in fields]) + names = {field.name for field in fields} return list(names) -def delete_orphans(model): +def delete_orphans(model, exclude=None): """ Deletes all instances of the given model with no relationships to other models. Args: model (Model): Model whose instances should be deleted + exclude: ID's of records to exclude from deletion Returns: None """ field_names = get_all_related_field_names(model) - kwargs = {'{0}__isnull'.format(field_name): True for field_name in field_names} - model.objects.filter(**kwargs).delete() + kwargs = {f'{field_name}__isnull': True for field_name in field_names} + query = model.objects.filter(**kwargs) + if exclude: + query = query.exclude(pk__in=exclude) + query.delete() -class SearchQuerySetWrapper(object): + +class SearchQuerySetWrapper: """ Decorates a SearchQuerySet object using a generator for efficient iteration """ @@ -112,7 +117,7 @@ def __init__(self, qs): def __getattr__(self, item): try: - super().__getattr__(item) + return super().__getattr__(item) except AttributeError: # If the attribute is not found on this class, # proxy the request to the SearchQuerySet. @@ -128,3 +133,10 @@ def __getitem__(self, key): return self.qs[key].object # Pass the slice/range on to the delegate return SearchQuerySetWrapper(self.qs[key]) + + +def use_read_replica_if_available(queryset): + """ + If there is a database called 'read_replica', use that database for the queryset. + """ + return queryset.using("read_replica") if "read_replica" in settings.DATABASES else queryset diff --git a/course_discovery/apps/course_metadata/admin.py b/course_discovery/apps/course_metadata/admin.py index d2c2bcf060..ba0c04f22b 100644 --- a/course_discovery/apps/course_metadata/admin.py +++ b/course_discovery/apps/course_metadata/admin.py @@ -1,10 +1,17 @@ +from adminsortable2.admin import SortableAdminMixin +from dal import autocomplete from django.contrib import admin, messages +from django.db.utils import IntegrityError +from django.forms import CheckboxSelectMultiple, ModelForm from django.http import HttpResponseRedirect from django.urls import reverse +from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from parler.admin import TranslatableAdmin +from course_discovery.apps.course_metadata.algolia_forms import SearchDefaultResultsConfigurationForm +from course_discovery.apps.course_metadata.algolia_models import SearchDefaultResultsConfiguration from course_discovery.apps.course_metadata.exceptions import ( MarketingSiteAPIClientException, MarketingSitePublisherException ) @@ -17,6 +24,20 @@ ) +class CurriculumCourseMembershipForm(ModelForm): + """ + A custom CurriculumCourseMembershipForm to override the widget used by the course ModelChoiceField. + This allows us to leverage the view at the URL admin_metadata:course-autocomplete, which filters out draft + courses. + """ + class Meta: + model = CurriculumCourseMembership + fields = ['curriculum', 'course', 'course_run_exclusions', 'is_active'] + widgets = { + 'course': autocomplete.ModelSelect2(url='admin_metadata:course-autocomplete') + } + + class ProgramEligibilityFilter(admin.SimpleListFilter): title = _('eligible for one-click purchase') parameter_name = 'eligible_for_one_click_purchase' @@ -47,6 +68,7 @@ def queryset(self, request, queryset): class SeatInline(admin.TabularInline): model = Seat extra = 1 + readonly_fields = ('_upgrade_deadline',) class PositionInline(admin.TabularInline): @@ -67,30 +89,71 @@ class PersonAreaOfExpertiseInline(admin.TabularInline): @admin.register(Course) class CourseAdmin(admin.ModelAdmin): form = CourseAdminForm - list_display = ('uuid', 'key', 'title',) + list_display = ('uuid', 'key', 'key_for_reruns', 'title', 'draft',) list_filter = ('partner',) ordering = ('key', 'title',) - readonly_fields = ('uuid', 'enrollment_count', 'recent_enrollment_count',) - search_fields = ('uuid', 'key', 'title',) + readonly_fields = ('uuid', 'enrollment_count', 'recent_enrollment_count', 'active_url_slug', 'key', 'number') + search_fields = ('uuid', 'key', 'key_for_reruns', 'title',) + raw_id_fields = ('canonical_course_run', 'draft_version',) + autocomplete_fields = ['canonical_course_run'] + + +@admin.register(CourseEditor) +class CourseEditorAdmin(admin.ModelAdmin): + list_display = ('user', 'course',) + search_fields = ('user__username', 'course__title', 'course__key',) + raw_id_fields = ('user', 'course',) @admin.register(CourseEntitlement) class CourseEntitlementAdmin(admin.ModelAdmin): - list_display = ['course', 'get_course_number', 'mode'] + list_display = ['course', 'get_course_key', 'mode', 'draft'] + + def get_course_key(self, obj): + return obj.course.key + + get_course_key.short_description = 'Course key' + + raw_id_fields = ('course', 'draft_version',) + search_fields = ['course__title', 'course__key'] + + +@admin.register(Mode) +class ModeAdmin(admin.ModelAdmin): + list_display = ['slug', 'name'] + + +@admin.register(Track) +class TrackAdmin(admin.ModelAdmin): + list_display = ['mode', 'seat_type'] - def get_course_number(self, obj): - return obj.course.number - get_course_number.short_description = 'Course number' +@admin.register(CourseRunType) +class CourseRunTypeAdmin(admin.ModelAdmin): + list_display = ['uuid', 'name'] + search_fields = ['uuid', 'name'] - raw_id_fields = ['course'] - search_fields = ['course__title', 'course__number'] + +class CourseTypeAdminForm(ModelForm): + class Meta: + model = CourseType + fields = '__all__' + widgets = { + 'white_listed_orgs': CheckboxSelectMultiple + } + + +@admin.register(CourseType) +class CourseTypeAdmin(admin.ModelAdmin): + list_display = ['uuid', 'name'] + search_fields = ['uuid', 'name'] + form = CourseTypeAdminForm @admin.register(CourseRun) class CourseRunAdmin(admin.ModelAdmin): inlines = (SeatInline,) - list_display = ('uuid', 'key', 'title', 'status',) + list_display = ('uuid', 'key', 'external_key', 'title', 'status', 'draft',) list_filter = ( 'course__partner', 'hidden', @@ -99,9 +162,9 @@ class CourseRunAdmin(admin.ModelAdmin): 'license', ) ordering = ('key',) - raw_id_fields = ('course',) - readonly_fields = ('uuid', 'enrollment_count', 'recent_enrollment_count',) - search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug',) + raw_id_fields = ('course', 'draft_version',) + readonly_fields = ('uuid', 'enrollment_count', 'recent_enrollment_count', 'hidden', 'key') + search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug', 'external_key') save_error = False def response_change(self, request, obj): @@ -118,7 +181,7 @@ def save_model(self, request, obj, form, change): logger.exception('An error occurred while publishing course run [%s] to the marketing site.', obj.key) - msg = PUBLICATION_FAILURE_MSG_TPL.format(model='course run') # pylint: disable=no-member + msg = PUBLICATION_FAILURE_MSG_TPL.format(model='course run') messages.add_message(request, messages.ERROR, msg) @@ -132,16 +195,17 @@ class ProgramAdmin(admin.ModelAdmin): 'recent_enrollment_count',) raw_id_fields = ('video',) search_fields = ('uuid', 'title', 'marketing_slug') + exclude = ('card_image_url',) # ordering the field display on admin page. fields = ( - 'uuid', 'title', 'subtitle', 'status', 'type', 'partner', 'banner_image', 'banner_image_url', 'card_image_url', - 'marketing_slug', 'overview', 'credit_redemption_overview', 'video', 'total_hours_of_effort', + 'uuid', 'title', 'subtitle', 'marketing_hook', 'status', 'type', 'partner', 'banner_image', 'banner_image_url', + 'card_image', 'marketing_slug', 'overview', 'credit_redemption_overview', 'video', 'total_hours_of_effort', 'weeks_to_complete', 'min_hours_effort_per_week', 'max_hours_effort_per_week', 'courses', 'order_courses_by_start_date', 'custom_course_runs_display', 'excluded_course_runs', 'authoring_organizations', 'credit_backing_organizations', 'one_click_purchase_enabled', 'hidden', 'corporate_endorsements', 'faq', 'individual_endorsements', 'job_outlook_items', 'expected_learning_items', 'instructor_ordering', - 'enrollment_count', 'recent_enrollment_count', + 'enrollment_count', 'recent_enrollment_count', 'credit_value', ) save_error = False @@ -183,7 +247,7 @@ def save_model(self, request, obj, form, change): logger.exception('An error occurred while publishing program [%s] to the marketing site.', obj.uuid) - msg = PUBLICATION_FAILURE_MSG_TPL.format(model='program') # pylint: disable=no-member + msg = PUBLICATION_FAILURE_MSG_TPL.format(model='program') messages.add_message(request, messages.ERROR, msg) class Media: @@ -200,13 +264,16 @@ class PathwayAdmin(admin.ModelAdmin): @admin.register(ProgramType) -class ProgramTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'slug',) +class ProgramTypeAdmin(TranslatableAdmin): + fields = ('name_t', 'applicable_seat_types', 'logo_image', 'slug', 'coaching_supported',) + list_display = ('name_t', 'slug') @admin.register(Seat) class SeatAdmin(admin.ModelAdmin): - list_display = ('course_run', 'type') + list_display = ('course_run', 'type', 'draft', 'upgrade_deadline_override',) + raw_id_fields = ('draft_version',) + readonly_fields = ('_upgrade_deadline',) @admin.register(SeatType) @@ -249,7 +316,7 @@ def courses(self, obj): class OrganizationUserRoleInline(admin.TabularInline): # course-meta-data models are importing in publisher app. So just for safe side # to avoid any circular issue importing the publisher model here. - from course_discovery.apps.publisher.models import OrganizationUserRole + from course_discovery.apps.publisher.models import OrganizationUserRole # pylint: disable=import-outside-toplevel model = OrganizationUserRole extra = 3 raw_id_fields = ('user',) @@ -260,7 +327,7 @@ class OrganizationAdmin(admin.ModelAdmin): list_display = ('uuid', 'key', 'name',) inlines = [OrganizationUserRoleInline, ] list_filter = ('partner',) - readonly_fields = ('uuid',) + readonly_fields = ('uuid', 'key') search_fields = ('uuid', 'name', 'key',) @@ -309,38 +376,88 @@ def get_actions(self, request): class VideoAdmin(admin.ModelAdmin): list_display = ('src', 'description',) search_fields = ('src', 'description',) + exclude = ('image',) -class NamedModelAdmin(admin.ModelAdmin): +@admin.register(Prerequisite) +class PrerequisiteAdmin(admin.ModelAdmin): list_display = ('name',) ordering = ('name',) search_fields = ('name',) -class DegreeProgramCurriculumInline(admin.TabularInline): - model = DegreeProgramCurriculum - extra = 1 +@admin.register(LevelType) +class LevelTypeAdmin(SortableAdminMixin, TranslatableAdmin): + list_display = ('name_t', 'sort_value') + search_fields = ('name_t',) + fields = ('name_t',) -class DegreeCourseCurriculumInline(admin.TabularInline): - model = DegreeCourseCurriculum - extra = 1 +class CurriculumProgramMembershipInline(admin.TabularInline): + model = CurriculumProgramMembership + fields = ('program', 'is_active') + autocomplete_fields = ['program'] + extra = 0 + + +class CurriculumCourseMembershipInline(admin.StackedInline): + form = CurriculumCourseMembershipForm + model = CurriculumCourseMembership + readonly_fields = ("custom_course_runs_display", "course_run_exclusions", "get_edit_link",) + + def custom_course_runs_display(self, obj): + return mark_safe('
'.join([str(run) for run in obj.course_runs])) + + custom_course_runs_display.short_description = _('Included course runs') + + def get_edit_link(self, obj=None): + if obj and obj.pk: + url = reverse('admin:{}_{}_change'.format(obj._meta.app_label, obj._meta.model_name), args=[obj.pk]) + return format_html( + """{text}""", + url=url, + text=_("Edit course run exclusions"), + ) + return _("(save and continue editing to create a link)") + + get_edit_link.short_description = _("Edit link") + + extra = 0 -@admin.register(DegreeProgramCurriculum) -class DegreeProgramCurriculumAdmin(admin.ModelAdmin): +class CurriculumCourseRunExclusionInline(admin.TabularInline): + model = CurriculumCourseRunExclusion + autocomplete_fields = ['course_run'] + extra = 0 + + +@admin.register(CurriculumProgramMembership) +class CurriculumProgramMembershipAdmin(admin.ModelAdmin): list_display = ('curriculum', 'program') -@admin.register(DegreeCourseCurriculum) -class DegreeCourseCurriculumAdmin(admin.ModelAdmin): +@admin.register(CurriculumCourseMembership) +class CurriculumCourseMembershipAdmin(admin.ModelAdmin): + form = CurriculumCourseMembershipForm list_display = ('curriculum', 'course') + inlines = (CurriculumCourseRunExclusionInline,) + + +@admin.register(CurriculumCourseRunExclusion) +class CurriculumCourseRunExclusionAdmin(admin.ModelAdmin): + list_display = ("course_membership", "course_run") @admin.register(Curriculum) class CurriculumAdmin(admin.ModelAdmin): - list_display = ('uuid', 'degree') - inlines = (DegreeProgramCurriculumInline, DegreeCourseCurriculumInline) + list_display = ('uuid', 'program', 'name', 'is_active') + inlines = (CurriculumProgramMembershipInline, CurriculumCourseMembershipInline) + + def save_model(self, request, obj, form, change): + try: + super().save_model(request, obj, form, change) + except IntegrityError: + logger.exception('A database integrity error occurred while saving curriculum [%s].', obj.uuid) class CurriculumAdminInline(admin.StackedInline): @@ -392,15 +509,33 @@ class DegreeAdmin(admin.ModelAdmin): 'search_card_ranking', 'search_card_cost', 'search_card_courses', 'overall_ranking', 'campus_image', 'title', 'subtitle', 'title_background_image', 'banner_border_color', 'apply_url', 'overview', 'rankings', 'application_requirements', 'prerequisite_coursework', 'lead_capture_image', 'lead_capture_list_name', - 'micromasters_long_title', 'micromasters_long_description', 'micromasters_url', 'micromasters_background_image', - 'faq', 'costs_fine_print', 'deadlines_fine_print', + 'hubspot_lead_capture_form_id', 'micromasters_long_title', 'micromasters_long_description', 'micromasters_url', + 'micromasters_background_image', 'micromasters_org_name_override', 'faq', 'costs_fine_print', + 'deadlines_fine_print', ) -# Register children of AbstractNamedModel -for model in (LevelType, Prerequisite,): - admin.site.register(model, NamedModelAdmin) + +@admin.register(SearchDefaultResultsConfiguration) +class SearchDefaultResultsConfigurationAdmin(admin.ModelAdmin): + form = SearchDefaultResultsConfigurationForm + list_display = ('index_name',) + + class Media: + js = ('bower_components/jquery-ui/ui/minified/jquery-ui.min.js', + 'js/sortable_select.js') + # Register remaining models using basic ModelAdmin classes for model in (Image, ExpectedLearningItem, SyllabusItem, PersonSocialNetwork, JobOutlookItem, DataLoaderConfig, - DeletePersonDupsConfig, DrupalPublishUuidConfig, ProfileImageDownloadConfig, PersonAreaOfExpertise): + DeletePersonDupsConfig, DrupalPublishUuidConfig, MigrateCommentsToSalesforce, + MigratePublisherToCourseMetadataConfig, ProfileImageDownloadConfig, PersonAreaOfExpertise, + TagCourseUuidsConfig, BackpopulateCourseTypeConfig, RemoveRedirectsConfig, BulkModifyProgramHookConfig, + BackfillCourseRunSlugsConfig): admin.site.register(model) + + +@admin.register(Collaborator) +class CollaboratorAdmin(admin.ModelAdmin): + list_display = ('uuid', 'name', 'image') + readonly_fields = ('uuid', ) + search_fields = ('uuid', 'name') diff --git a/course_discovery/apps/course_metadata/algolia_forms.py b/course_discovery/apps/course_metadata/algolia_forms.py new file mode 100644 index 0000000000..ecfdf23ff7 --- /dev/null +++ b/course_discovery/apps/course_metadata/algolia_forms.py @@ -0,0 +1,27 @@ +from django import forms + +from course_discovery.apps.course_metadata.algolia_models import SearchDefaultResultsConfiguration +from course_discovery.apps.course_metadata.widgets import SortedModelSelect2Multiple + + +class SearchDefaultResultsConfigurationForm(forms.ModelForm): + class Meta: + model = SearchDefaultResultsConfiguration + fields = '__all__' + + widgets = { + # TODO: make this sortable as well (debug sortable-select) + 'courses': SortedModelSelect2Multiple( + url='admin_metadata:course-autocomplete', + attrs={ + 'data-minimum-input-length': 3, + }, + ), + 'programs': SortedModelSelect2Multiple( + url='admin_metadata:program-autocomplete', + attrs={ + 'data-minimum-input-length': 3, + 'class': 'sortable-select', + } + ), + } diff --git a/course_discovery/apps/course_metadata/algolia_models.py b/course_discovery/apps/course_metadata/algolia_models.py new file mode 100644 index 0000000000..8d1288d7b3 --- /dev/null +++ b/course_discovery/apps/course_metadata/algolia_models.py @@ -0,0 +1,359 @@ +import datetime +import itertools + +import pytz +from django.db import models +from django.utils.translation import override +from django.utils.translation import ugettext as _ +from sortedm2m.fields import SortedManyToManyField + +from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus +from course_discovery.apps.course_metadata.models import Course, Program, ProgramType + + +# Utility methods used by both courses and programs +def get_active_language_tag(course): + if course.advertised_course_run and course.advertised_course_run.language: + return course.advertised_course_run.language + return None + + +def get_active_language(course): + if get_active_language_tag(course): + return get_active_language_tag(course).get_search_facet_display(translate=True) + return None + + +def logo_image(owner): + image = getattr(owner, 'logo_image', None) + if image: + return image.url + return None + + +def get_owners(entry): + all_owners = [{'key': o.key, 'logoImageUrl': logo_image(o), 'name': o.name} + for o in entry.authoring_organizations.all()] + return list(filter(lambda owner: owner['logoImageUrl'] is not None, all_owners)) + + +def delegate_attributes(cls): + ''' + Class decorator. For all Algolia fields, when my_instance.attribute is accessed, get the attribute off + my_instance.product rather than my_instance. This allows us to combine two different models into one index. If + my_instance.product doesn't have the attribute, attempts to access it will just return None. + + This doesn't work as well for field names that exist on the underlying Course and Program models so those + fields are prefixed with 'product_' to make them Algolia-specific + ''' + + search_fields = ['partner_names', 'partner_keys', 'product_title', 'primary_description', 'secondary_description', + 'tertiary_description'] + facet_fields = ['availability_level', 'subject_names', 'levels', 'active_languages', 'staff_slugs'] + ranking_fields = ['availability_rank', 'product_recent_enrollment_count', 'promoted_in_spanish_index'] + result_fields = ['product_marketing_url', 'product_card_image_url', 'product_uuid', 'active_run_key', + 'active_run_start', 'active_run_type', 'owners', 'program_types', 'course_titles'] + object_id_field = ['custom_object_id', ] + fields = search_fields + facet_fields + ranking_fields + result_fields + object_id_field + for field in fields: + def _closure(name): + def _wrap(self, *args, **kwargs): # pylint: disable=unused-argument + with override(getattr(self.product, 'language', 'en')): + return getattr(self.product, name, None) + return _wrap + setattr(cls, field, _closure(field)) + return cls + + +def get_course_availability(course): + all_runs = course.course_runs.filter(status=CourseRunStatus.Published) + availability = set() + + for course_run in all_runs: + if course_run.is_current(): + availability.add(_('Available now')) + elif course_run.is_upcoming(): + availability.add(_('Upcoming')) + else: + availability.add(_('Archived')) + + return list(availability) + +# Proxies Program model in order to trick Algolia into thinking this is a single model so it doesn't error. +# No model-specific attributes or methods are actually used. + + +@delegate_attributes +class AlgoliaProxyProduct(Program): + class Meta: + proxy = True + + def __init__(self, product, language='en'): + super().__init__() + self.product = product + self.product.language = language + + def product_type(self): + return getattr(type(self.product), 'product_type', None) + + # should_index is called differently from algoliasearch_django, can't use the delegate_attributes trick + def should_index(self): + return getattr(self.product, 'should_index', True) + + +class AlgoliaBasicModelFieldsMixin(models.Model): + + class Meta: + abstract = True + + @property + def product_title(self): + return self.title + + @property + def product_uuid(self): + return self.uuid + + @property + def product_marketing_url(self): + return self.marketing_url + + @property + def product_recent_enrollment_count(self): + return self.recent_enrollment_count + + +class AlgoliaProxyCourse(Course, AlgoliaBasicModelFieldsMixin): + + product_type = 'Course' + + class Meta: + proxy = True + + @property + def custom_object_id(self): + return 'course-{uuid}'.format(uuid=self.uuid) + + @property + def primary_description(self): + return self.short_description + + @property + def secondary_description(self): + return self.outcome + + @property + def tertiary_description(self): + return self.full_description + + @property + def active_languages(self): + language = get_active_language(self) + if language: + return [language] + return None + + @property + def active_run_key(self): + return getattr(self.advertised_course_run, 'key', None) + + @property + def active_run_start(self): + return getattr(self.advertised_course_run, 'start', None) + + @property + def active_run_type(self): + return getattr(self.advertised_course_run, 'type', None) + + @property + def availability_level(self): + return get_course_availability(self) + + @property + def partner_names(self): + return [org['name'] for org in get_owners(self)] + + @property + def partner_keys(self): + return [org['key'] for org in get_owners(self)] + + @property + def levels(self): + level = getattr(self.level_type, 'name_t', None) + if level: + return [level] + return None + + @property + def subject_names(self): + return [subject.name for subject in self.subjects.all()] + + @property + def program_types(self): + return [program.type.name for program in self.programs.all()] + + @property + def product_card_image_url(self): + if self.image: + return getattr(self.image, 'url', None) + return None + + @property + def owners(self): + return get_owners(self) + + @property + def staff_slugs(self): + staff = [course_run.staff.all() for course_run in self.active_course_runs] + staff = itertools.chain.from_iterable(staff) + return list({person.slug for person in staff}) + + @property + def promoted_in_spanish_index(self): + language_tag = get_active_language_tag(self) + if language_tag: + return language_tag.code.startswith('es') + return False + + @property + def should_index(self): + """Only index courses in the edX catalog with a non-hidden advertiseable course run, at least one owner, and + a marketing url slug""" + return (len(self.owners) > 0 and + self.active_url_slug and + self.partner.name == 'edX' and + self.availability_level and + bool(self.advertised_course_run) and + not self.advertised_course_run.hidden) + + @property + def availability_rank(self): + today_midnight = datetime.datetime.now(pytz.UTC).replace(hour=0, minute=0, second=0, microsecond=0) + if self.advertised_course_run: + if self.advertised_course_run.is_current_and_still_upgradeable(): + return 1 + paid_seat_enrollment_end = self.advertised_course_run.get_paid_seat_enrollment_end() + if paid_seat_enrollment_end and paid_seat_enrollment_end > today_midnight: + return 2 + if datetime.datetime.now(pytz.UTC) >= self.advertised_course_run.start: + return 3 + return self.advertised_course_run.start.timestamp() + return None # Algolia will deprioritize entries where a ranked field is empty + + +class AlgoliaProxyProgram(Program, AlgoliaBasicModelFieldsMixin): + + product_type = 'Program' + + class Meta: + proxy = True + + @property + def product_title(self): + return self.title + + @property + def primary_description(self): + return self.subtitle + + @property + def secondary_description(self): + return self.overview + + @property + def tertiary_description(self): + return ','.join([expected.value for expected in self.expected_learning_items.all()]) + + @property + def custom_object_id(self): + return 'program-{uuid}'.format(uuid=self.uuid) + + @property + def product_card_image_url(self): + if self.card_image: + return self.card_image.url + # legacy field for programs with images hosted outside of discovery + return self.card_image_url + + @property + def subject_names(self): + return [subject.name for subject in self.subjects] + + @property + def partner_names(self): + return [org['name'] for org in get_owners(self)] + + @property + def partner_keys(self): + return [org['key'] for org in get_owners(self)] + + @property + def levels(self): + return list(dict.fromkeys([getattr(course.level_type, 'name_t', None) for course in self.courses.all()])) + + @property + def active_languages(self): + return list(dict.fromkeys([get_active_language(course) for course in self.courses.all()])) + + @property + def expected_learning_items_values(self): + return [item.value for item in self.expected_learning_items.all()] + + @property + def owners(self): + return get_owners(self) + + @property + def staff_slugs(self): + return [person.slug for person in self.staff] + + @property + def course_titles(self): + return [course.title for course in self.courses.all()] + + @property + def program_types(self): + if self.type: + return [self.type.name] + return None + + @property + def availability_level(self): + # Master's programs don't have courses in the same way that our other programs do. + # We got confirmation from masters POs that we should make masters Programs always + # 'Available now' + if self.type and self.type.slug == ProgramType.MASTERS: + return _('Available now') + + all_courses = self.courses.all() + availability = set() + + for course in all_courses: + course_status = get_course_availability(course) + for status in course_status: + availability.add(status) + + return list(availability) + + @property + def promoted_in_spanish_index(self): + all_course_languages = [get_active_language_tag(course) for course in self.courses.all()] + all_course_languages = [tag for tag in all_course_languages if tag is not None] + return any(tag.code.startswith('es') for tag in all_course_languages) + + @property + def should_index(self): + # marketing_url and program_type should never be null, but include as a sanity check + return (len(self.owners) > 0 and + self.marketing_url and + self.program_types and + self.status == ProgramStatus.Active and + self.availability_level and + self.partner.name == 'edX' and + not self.hidden) + + +class SearchDefaultResultsConfiguration(models.Model): + index_name = models.CharField(max_length=32, unique=True) + programs = SortedManyToManyField(Program, blank=True) + courses = SortedManyToManyField(Course, blank=True) diff --git a/course_discovery/apps/course_metadata/apps.py b/course_discovery/apps/course_metadata/apps.py index 57efdf7e69..d288cc8bb8 100644 --- a/course_discovery/apps/course_metadata/apps.py +++ b/course_discovery/apps/course_metadata/apps.py @@ -13,4 +13,4 @@ def ready(self): # to allow PIL to work with these images correctly by setting this variable true ImageFile.LOAD_TRUNCATED_IMAGES = True # noinspection PyUnresolvedReferences - import course_discovery.apps.course_metadata.signals # pylint: disable=unused-variable + import course_discovery.apps.course_metadata.signals # pylint: disable=import-outside-toplevel,unused-import diff --git a/course_discovery/apps/course_metadata/choices.py b/course_discovery/apps/course_metadata/choices.py index 2cfad5196c..6ad9b6aa1c 100644 --- a/course_discovery/apps/course_metadata/choices.py +++ b/course_discovery/apps/course_metadata/choices.py @@ -3,8 +3,20 @@ class CourseRunStatus(DjangoChoices): - Published = ChoiceItem('published', _('Published')) Unpublished = ChoiceItem('unpublished', _('Unpublished')) + LegalReview = ChoiceItem('review_by_legal', _('Awaiting Review from Legal')) + InternalReview = ChoiceItem('review_by_internal', _('Awaiting Internal Review')) + Reviewed = ChoiceItem('reviewed', _('Reviewed')) + Published = ChoiceItem('published', _('Published')) + + INTERNAL_STATUS_TRANSITIONS = ( + InternalReview.value, + Reviewed.value, + ) + + @classmethod + def REVIEW_STATES(cls): + return [cls.LegalReview, cls.InternalReview] class CourseRunPacing(DjangoChoices): @@ -28,3 +40,16 @@ class ReportingType(DjangoChoices): test = ChoiceItem('test', 'test') demo = ChoiceItem('demo', 'demo') other = ChoiceItem('other', 'other') + + +class CertificateType(DjangoChoices): + Honor = ChoiceItem('honor', _('Honor')) + Credit = ChoiceItem('credit', _('Credit')) + Verified = ChoiceItem('verified', _('Verified')) + Professional = ChoiceItem('professional', _('Professional')) + Executive_Education = ChoiceItem('executive-education', _('Executive Education')) + + +class PayeeType(DjangoChoices): + Platform = ChoiceItem('platform', _('Platform')) + Organization = ChoiceItem('organization', _('Organization')) diff --git a/course_discovery/apps/course_metadata/constants.py b/course_discovery/apps/course_metadata/constants.py index b96cf27289..47f2bf65fe 100644 --- a/course_discovery/apps/course_metadata/constants.py +++ b/course_discovery/apps/course_metadata/constants.py @@ -1,10 +1,11 @@ from enum import Enum - COURSE_ID_REGEX = r'[^/+]+(/|\+)[^/+]+' COURSE_RUN_ID_REGEX = r'[^/+]+(/|\+)[^/+]+(/|\+)[^/]+' COURSE_UUID_REGEX = r'[0-9a-f-]+' +MASTERS_PROGRAM_TYPE_SLUG = 'masters' + class PathwayType(Enum): """ Allowed values for Pathway.pathway_type """ diff --git a/course_discovery/apps/course_metadata/data_loaders/__init__.py b/course_discovery/apps/course_metadata/data_loaders/__init__.py index a73b357656..e1201f97ee 100644 --- a/course_discovery/apps/course_metadata/data_loaders/__init__.py +++ b/course_discovery/apps/course_metadata/data_loaders/__init__.py @@ -1,14 +1,8 @@ import abc -import re -import html2text -import markdown from dateutil.parser import parse -from django.utils.functional import cached_property -from edx_rest_api_client.client import EdxRestApiClient -from opaque_keys.edx.keys import CourseKey +from edx_rest_framework_extensions.auth.jwt.decoder import configured_jwt_decode_handler -from course_discovery.apps.core.utils import delete_orphans from course_discovery.apps.course_metadata.models import Image, Video @@ -18,57 +12,37 @@ class AbstractDataLoader(metaclass=abc.ABCMeta): Attributes: api_url (str): URL of the API from which data is loaded partner (Partner): Partner which owns the data for this data loader - access_token (str): OAuth2 access token PAGE_SIZE (int): Number of items to load per API call """ + LOADER_MAX_RETRY = 3 PAGE_SIZE = 50 - MARKDOWN_CLEANUP_REGEX = re.compile(r'^

(.*)

$') - def __init__(self, partner, api_url, access_token=None, token_type=None, max_workers=None, - is_threadsafe=False, **kwargs): + def __init__(self, partner, api_url, max_workers=None, is_threadsafe=False, course_id=None): """ Arguments: partner (Partner): Partner which owns the APIs and data being loaded api_url (str): URL of the API from which data is loaded - access_token (str): OAuth2 access token - token_type (str): The type of access token passed in (e.g. Bearer, JWT) max_workers (int): Number of worker threads to use when traversing paginated responses. is_threadsafe (bool): True if multiple threads can be used to write data. """ - if token_type: - token_type = token_type.lower() - - self.access_token = access_token - self.token_type = token_type self.partner = partner self.api_url = api_url.strip('/') + self.api_client = self.partner.lms_api_client + self.username = self.get_username_from_client(self.api_client) + self.course_id = course_id self.max_workers = max_workers self.is_threadsafe = is_threadsafe - self.username = kwargs.get('username') - - @cached_property - def api_client(self): - """ - Returns an authenticated API client ready to call the API from which data is loaded. - - Returns: - EdxRestApiClient - """ - kwargs = {} - - if self.token_type == 'jwt': - kwargs['jwt'] = self.access_token - else: - kwargs['oauth_access_token'] = self.access_token - - return EdxRestApiClient(self.api_url, **kwargs) @abc.abstractmethod def ingest(self): # pragma: no cover """ Load data for all supported objects (e.g. courses, runs). """ - pass + + def get_username_from_client(self, client): + token = client.get_jwt_access_token() + decoded_jwt = configured_jwt_decode_handler(token) + return decoded_jwt.get('preferred_username') @classmethod def clean_string(cls, s): @@ -84,27 +58,6 @@ def clean_strings(cls, data): and replacing empty strings with None. """ return {k: cls.clean_string(v) for k, v in data.items()} - @classmethod - def clean_html(cls, content): - """Cleans HTML from a string. - - This method converts the HTML to a Markdown string (to remove styles, classes, and other unsupported - attributes), and converts the Markdown back to HTML. - """ - cleaned = content.replace(' ', '') - html_converter = html2text.HTML2Text() - html_converter.wrap_links = False - html_converter.body_width = None - cleaned = html_converter.handle(cleaned).strip() - cleaned = markdown.markdown(cleaned) - cleaned = cls.MARKDOWN_CLEANUP_REGEX.sub(r'\1', cleaned) - - # html2text does not handle ampersands properly. - # See https://github.com/Alir3z4/html2text/issues/109. - cleaned = cleaned.replace('&', '&') - - return cleaned - @classmethod def parse_date(cls, date_string): """ @@ -133,13 +86,7 @@ def get_course_key_from_course_run_key(cls, course_run_key): Returns: str """ - return '{org}+{course}'.format(org=course_run_key.org, course=course_run_key.course) - - @classmethod - def delete_orphans(cls): - """ Remove orphaned objects from the database. """ - for model in (Image, Video): - delete_orphans(model) + return f'{course_run_key.org}+{course_run_key.course}' @classmethod def _get_or_create_media(cls, media_type, url): diff --git a/course_discovery/apps/course_metadata/data_loaders/analytics_api.py b/course_discovery/apps/course_metadata/data_loaders/analytics_api.py index 672e1960e0..3f00b9d086 100644 --- a/course_discovery/apps/course_metadata/data_loaders/analytics_api.py +++ b/course_discovery/apps/course_metadata/data_loaders/analytics_api.py @@ -2,10 +2,8 @@ import logging import pytz - -from django.utils.functional import cached_property - from analyticsclient.client import Client + from course_discovery.apps.course_metadata.data_loaders import AbstractDataLoader from course_discovery.apps.course_metadata.models import CourseRun @@ -16,11 +14,8 @@ class AnalyticsAPIDataLoader(AbstractDataLoader): API_TIMEOUT = 120 # time in seconds - def __init__(self, partner, api_url, access_token=None, token_type=None, max_workers=None, - is_threadsafe=False, **kwargs): - super(AnalyticsAPIDataLoader, self).__init__( - partner, api_url, access_token, token_type, max_workers, is_threadsafe, **kwargs - ) + def __init__(self, partner, api_url, max_workers=None, is_threadsafe=False): + super().__init__(partner, api_url, max_workers, is_threadsafe) # uuid: {course, count, recent_count} self.course_dictionary = {} @@ -32,9 +27,7 @@ def __init__(self, partner, api_url, access_token=None, token_type=None, max_wor partner=partner.short_code) raise Exception(msg) - @cached_property - def api_client(self): - + def analytics_api_client(self): analytics_api_client = Client(base_url=self.partner.analytics_url, auth_token=self.partner.analytics_token, timeout=self.API_TIMEOUT) @@ -43,11 +36,10 @@ def api_client(self): def ingest(self): """ Load data for all course runs. """ - now = datetime.datetime.now(pytz.UTC) # We don't need a high level of precision - looking for ~6months of data six_months_ago = now - datetime.timedelta(days=180) - course_summaries_response = self.api_client.course_summaries() + course_summaries_response = self.analytics_api_client().course_summaries() course_run_summaries = course_summaries_response.course_summaries(recent_date=six_months_ago, fields=['course_id', 'count', @@ -67,7 +59,7 @@ def ingest(self): program.enrollment_count = program_dict['count'] program.recent_enrollment_count = program_dict['recent_count'] program.save(suppress_publication=True) - logger.info('Updating program: {program_uuid}'.format(program_uuid=program.uuid)) + logger.info(f'Updating program: {program.uuid}') def _process_course_run_summary(self, course_run_summary): # Get course run object from course run key @@ -77,7 +69,7 @@ def _process_course_run_summary(self, course_run_summary): try: course_run = CourseRun.objects.get(key__iexact=course_run_key) except CourseRun.DoesNotExist: - logger.info('Course run: [{course_run_key}] not found in DB.'.format(course_run_key=course_run_key)) + logger.info(f'Course run: [{course_run_key}] not found in DB.') return course = course_run.course @@ -85,23 +77,12 @@ def _process_course_run_summary(self, course_run_summary): course_run.enrollment_count = course_run_count course_run.recent_enrollment_count = course_run_recent_count course_run.save(suppress_publication=True) - logger.info('Updating course run: {course_run_key}'.format(course_run_key=course_run_key)) - logger.info('Updating course dictionary for course uuid: {course_uuid}'.format(course_uuid=course.uuid)) # Add course run total to course total in dictionary if course.uuid in self.course_dictionary: - logger.info('incrementing count from {old_count} to {new_count}'.format( - old_count=self.course_dictionary[course.uuid]['count'], - new_count=self.course_dictionary[course.uuid]['count'] + course_run_count)) - logger.info('incrementing recent count from {old_count} to {new_count}'.format( - old_count=self.course_dictionary[course.uuid]['recent_count'], - new_count=self.course_dictionary[course.uuid]['recent_count'] + course_run_recent_count - )) self.course_dictionary[course.uuid]['count'] += course_run_count self.course_dictionary[course.uuid]['recent_count'] += course_run_recent_count else: - logger.info('setting course count to {count}'.format(count=course_run_count)) - logger.info('setting recent count to {count}'.format(count=course_run_recent_count)) self.course_dictionary[course.uuid] = {'course': course, 'count': course_run_count, 'recent_count': course_run_recent_count} @@ -111,24 +92,14 @@ def _process_course_enrollment_count(self, course, count, recent_count): course.enrollment_count = count course.recent_enrollment_count = recent_count course.save() - logger.info('Updating course: {course_uuid}'.format(course_uuid=course.uuid)) - logger.info('Updating program dictionary for course uuid: {course_uuid}'.format(course_uuid=course.uuid)) # Add course count to program dictionary for all programs for program in course.programs.all(): # add course total to program total in dictionary if program.uuid in self.program_dictionary: - logger.info('incrementing from {old_count} to {new_count}'.format( - old_count=self.program_dictionary[program.uuid]['count'], - new_count=self.program_dictionary[program.uuid]['count'] + count)) - logger.info('incrementing from {old_count} to {new_count}'.format( - old_count=self.program_dictionary[program.uuid]['recent_count'], - new_count=self.program_dictionary[program.uuid]['recent_count'] + recent_count)) self.program_dictionary[program.uuid]['count'] += count self.program_dictionary[program.uuid]['recent_count'] += recent_count else: - logger.info('setting count to {count}'.format(count=count)) - logger.info('setting recent count to {count}'.format(count=recent_count)) self.program_dictionary[program.uuid] = {'program': program, 'count': count, 'recent_count': recent_count} diff --git a/course_discovery/apps/course_metadata/data_loaders/api.py b/course_discovery/apps/course_metadata/data_loaders/api.py index a762348f3f..6fd0245eb5 100644 --- a/course_discovery/apps/course_metadata/data_loaders/api.py +++ b/course_discovery/apps/course_metadata/data_loaders/api.py @@ -6,70 +6,38 @@ from decimal import Decimal from io import BytesIO +import backoff import requests -import waffle +from django.conf import settings from django.core.files import File from django.core.management import CommandError +from django.db.models import Q from opaque_keys.edx.keys import CourseKey from course_discovery.apps.core.models import Currency from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus from course_discovery.apps.course_metadata.data_loaders import AbstractDataLoader +from course_discovery.apps.course_metadata.data_loaders.course_type import calculate_course_type from course_discovery.apps.course_metadata.models import ( - Course, CourseEntitlement, CourseRun, Organization, Program, ProgramType, Seat, SeatType, Video + Course, CourseEntitlement, CourseRun, CourseRunType, CourseType, Organization, Person, PersonSocialNetwork, Program, + ProgramType, Seat, SeatType, Subject, SubjectTranslation, Video ) -from course_discovery.apps.publisher.constants import PUBLISHER_ENABLE_READ_ONLY_FIELDS +from course_discovery.apps.course_metadata.utils import push_to_ecommerce_for_course_run, subtract_deadline_delta logger = logging.getLogger(__name__) -class OrganizationsApiDataLoader(AbstractDataLoader): - """ Loads organizations from the Organizations API. """ +def _fatal_code(ex): + """ + Give up if the error indicates that the request was invalid. - def ingest(self): - api_url = self.partner.organizations_api_url - count = None - page = 1 - - logger.info('Refreshing Organizations from %s...', api_url) - - while page: - response = self.api_client.organizations().get(page=page, page_size=self.PAGE_SIZE) - count = response['count'] - results = response['results'] - logger.info('Retrieved %d organizations...', len(results)) - - if response['next']: - page += 1 - else: - page = None - for body in results: - body = self.clean_strings(body) - self.update_organization(body) - - logger.info('Retrieved %d organizations from %s.', count, api_url) - - self.delete_orphans() - - def update_organization(self, body): - key = body['short_name'] - logo = body['logo'] - - defaults = { - 'key': key, - 'partner': self.partner, - 'certificate_logo_image_url': logo, - } - - if not self.partner.has_marketing_site: - defaults.update({ - 'name': body['name'], - 'description': body['description'], - 'logo_image_url': logo, - }) - - Organization.objects.update_or_create(key__iexact=key, partner=self.partner, defaults=defaults) - logger.info('Processed organization "%s"', key) + That means don't retry any 4XX code, except 429, which is rate limiting. + """ + return ( + ex.response is not None and + ex.response.status_code != 429 and + 400 <= ex.response.status_code < 500 + ) # pylint: disable=no-member class CoursesApiDataLoader(AbstractDataLoader): @@ -85,6 +53,7 @@ def ingest(self): self._process_response(response) pagerange = range(initial_page + 1, pages + 1) + logger.info('Looping to request all %d pages...', pages) with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: # pragma: no cover if self.is_threadsafe: @@ -98,26 +67,50 @@ def ingest(self): executor.submit(self._load_data, page) else: for future in [executor.submit(self._make_request, page) for page in pagerange]: - # This time.sleep is to make it very likely that this method does not encounter a 429 status - # code by increasing the amount of time between each code. More details at LEARNER-5560 - # The current crude estimation is for ~3000 courses with a PAGE_SIZE=50 which means this method - # will take ~30 minutes. - # TODO Ticket to gracefully handle 429 https://openedx.atlassian.net/browse/LEARNER-5565 - time.sleep(30) response = future.result() self._process_response(response) logger.info('Retrieved %d course runs from %s.', count, self.partner.courses_api_url) - self.delete_orphans() - def _load_data(self, page): # pragma: no cover """Make a request for the given page and process the response.""" response = self._make_request(page) self._process_response(response) + # The courses endpoint has a 40 requests/minute rate limit. + # This will back off at a rate of 60/120/240 seconds (from the factor 60 and default value of base 2). + # This backoff code can still fail because of the concurrent requests all requesting at the same time. + # So even in the case of entering into the next minute, if we still exceed our limit for that min, + # any requests that failed in both limits are still approaching their max_tries limit. + @backoff.on_exception( + backoff.expo, + factor=60, + max_tries=4, + exception=requests.exceptions.RequestException, + giveup=_fatal_code, + ) def _make_request(self, page): - return self.api_client.courses().get(page=page, page_size=self.PAGE_SIZE, username=self.username) + logger.info('Requesting course run page %d...', page) + params = {'page': page, 'page_size': self.PAGE_SIZE, 'username': self.username} + sub_url = '/courses/' + if self.course_id: + sub_url += self.course_id + + response = self.api_client.get(self.api_url + sub_url, params=params) + self.api_client.close() + + response.raise_for_status() + res = response.json() + if self.course_id: + res = { + 'pagination': { + 'count': 1, + 'num_pages': 1, + }, + 'results': [res], + } + + return res def _process_response(self, response): results = response['results'] @@ -128,23 +121,22 @@ def _process_response(self, response): try: body = self.clean_strings(body) - course_run = self.get_course_run(body) - if course_run: - self.update_course_run(course_run, body) - course = getattr(course_run, 'canonical_for_course', False) - if course and not self.partner.has_marketing_site: - # If the partner have marketing site, - # we should only update the course information from the marketing site. - # Therefore, we don't need to do the statements below - course = self.update_course(course, body) - logger.info('Processed course with key [%s].', course.key) + official_run, draft_run = self.get_course_run(body) + if official_run or draft_run: + self.update_course_run(official_run, draft_run, body) + if not self.partner.uses_publisher: + # Without publisher, we'll use Studio as the source of truth for course data + official_course = getattr(official_run, 'canonical_for_course', None) + draft_course = getattr(draft_run, 'canonical_for_course', None) + if official_course or draft_course: + self.update_course(official_course, draft_course, body) else: course, created = self.get_or_create_course(body) course_run = self.create_course_run(course, body) if created: course.canonical_course_run = course_run course.save() - except: # pylint: disable=bare-except + except Exception: # pylint: disable=broad-except msg = 'An error occurred while updating {course_run} from {api_url}'.format( course_run=course_run_id, api_url=self.partner.courses_api_url @@ -152,22 +144,54 @@ def _process_response(self, response): logger.exception(msg) def get_course_run(self, body): + """ + Returns: + Tuple of (official, draft) versions of the run. + """ course_run_key = body['id'] - try: - return CourseRun.objects.get(key__iexact=course_run_key) - except CourseRun.DoesNotExist: - return None + run = CourseRun.objects.filter_drafts(key__iexact=course_run_key).first() + if not run: + return None, None + elif run.draft: + return run.official_version, run + else: + return run, run.draft_version - def update_course_run(self, course_run, body): - validated_data = self.format_course_run_data(body) - self._update_instance(course_run, validated_data, suppress_publication=True) + def update_course_run(self, official_run, draft_run, body): + run = draft_run or official_run - logger.info('Processed course run with UUID [%s].', course_run.uuid) + validated_data = self.format_course_run_data(body) + end_has_updated = validated_data.get('end') != run.end + self._update_instance(official_run, validated_data, suppress_publication=True) + self._update_instance(draft_run, validated_data, suppress_publication=True) + if end_has_updated: + self._update_verified_deadline_for_course_run(official_run) + self._update_verified_deadline_for_course_run(draft_run) + has_upgrade_deadline_override = run.seats.filter(upgrade_deadline_override__isnull=False) + if not has_upgrade_deadline_override and official_run: + push_to_ecommerce_for_course_run(official_run) + + logger.info('Processed course run with UUID [%s].', run.uuid) def create_course_run(self, course, body): defaults = self.format_course_run_data(body, course=course) - return CourseRun.objects.create(**defaults) + # Set type to be the same as the most recent run as a best guess. + # Else mark the run type as empty and RCM will upgrade if it can. + latest_run = course.course_runs.order_by('-created').first() + if latest_run and latest_run.type: + defaults['type'] = latest_run.type + else: + defaults['type'] = CourseRunType.objects.get(slug=CourseRunType.EMPTY) + + # Course will always be an official version. But if it _does_ have a draft version, the run should too. + if course.draft_version: + # Start with draft version and then make official (since our utility functions expect that flow) + defaults['course'] = course.draft_version + draft_run = CourseRun.objects.create(**defaults, draft=True) + return draft_run.update_or_create_official_version(notify_services=False) + else: + return CourseRun.objects.create(**defaults) def get_or_create_course(self, body): course_run_key = CourseKey.from_string(body['id']) @@ -177,6 +201,10 @@ def get_or_create_course(self, body): # separators when constructing the create request defaults['key'] = course_key defaults['partner'] = self.partner + defaults['type'] = CourseType.objects.get(slug=CourseType.EMPTY) + + draft_version = Course.everything.filter(key__iexact=course_key, partner=self.partner, draft=True).first() + defaults['draft_version'] = draft_version course, created = Course.objects.get_or_create(key__iexact=course_key, partner=self.partner, defaults=defaults) @@ -192,52 +220,55 @@ def get_or_create_course(self, body): return (course, created) - def update_course(self, course, body): + def update_course(self, official_course, draft_course, body): validated_data = self.format_course_data(body) - self._update_instance(course, validated_data) + self._update_instance(official_course, validated_data) + self._update_instance(draft_course, validated_data) + course = official_course or draft_course logger.info('Processed course with key [%s].', course.key) - return course + def _update_verified_deadline_for_course_run(self, course_run): + seats = course_run.seats.filter(type=Seat.VERIFIED) if course_run and course_run.end else [] + for seat in seats: + seat.upgrade_deadline = subtract_deadline_delta( + seat.course_run.end, settings.PUBLISHER_UPGRADE_DEADLINE_DAYS + ) + seat.save() def _update_instance(self, instance, validated_data, **kwargs): + if not instance: + return + + updated = False + for attr, value in validated_data.items(): - setattr(instance, attr, value) + if getattr(instance, attr) != value: + setattr(instance, attr, value) + updated = True - instance.save(**kwargs) + if updated: + instance.save(**kwargs) def format_course_run_data(self, body, course=None): defaults = { 'key': body['id'], + 'start': self.parse_date(body['start']), 'end': self.parse_date(body['end']), 'enrollment_start': self.parse_date(body['enrollment_start']), 'enrollment_end': self.parse_date(body['enrollment_end']), 'hidden': body.get('hidden', False), + 'license': body.get('license') or '', # license cannot be None + 'title_override': body['name'], # we support Studio edits, even though Publisher also owns titles + 'pacing_type': self.get_pacing_type(body), + 'invite_only': body.get('invitation_only', False), } - # NOTE: The license field is non-nullable. - defaults['license'] = body.get('license') or '' - - start = self.parse_date(body['start']) - pacing_type = self.get_pacing_type(body) - - # When the switch is active, dates and pacing type should come from the Course API. - if waffle.switch_is_active(PUBLISHER_ENABLE_READ_ONLY_FIELDS): - defaults.update({ - 'start': start, - 'pacing_type': pacing_type - }) - - # When using a marketing site, only dates (excluding start) should come from the Course API. - if not self.partner.has_marketing_site: + if not self.partner.uses_publisher: defaults.update({ - 'start': start, - 'card_image_url': body['media'].get('image', {}).get('raw'), - 'title_override': body['name'], 'short_description_override': body['short_description'], 'video': self.get_courserun_video(body), - 'status': CourseRunStatus.Published, - 'pacing_type': pacing_type, + 'status': CourseRunStatus.Unpublished, 'mobile_available': body.get('mobile_available') or False, }) @@ -251,6 +282,11 @@ def format_course_data(self, body): 'title': body['name'], } + if not self.partner.uses_publisher: + defaults.update({ + 'card_image_url': body['media'].get('image', {}).get('raw'), + }) + return defaults def get_pacing_type(self, body): @@ -281,11 +317,8 @@ class EcommerceApiDataLoader(AbstractDataLoader): LOADER_MAX_RETRY = 2 - def __init__(self, partner, api_url, access_token=None, token_type=None, max_workers=None, - is_threadsafe=False, **kwargs): - super(EcommerceApiDataLoader, self).__init__( - partner, api_url, access_token, token_type, max_workers, is_threadsafe, **kwargs - ) + def __init__(self, partner, api_url, max_workers=None, is_threadsafe=False, **kwargs): + super().__init__(partner, api_url, max_workers, is_threadsafe, **kwargs) self.initial_page = 1 self.enrollment_skus = [] self.entitlement_skus = [] @@ -300,33 +333,20 @@ def __init__(self, partner, api_url, access_token=None, token_type=None, max_wor self.enrollment_code_lock = threading.Lock() def ingest(self): - attempt_count = 0 - - while (attempt_count == 0 or - (self.processing_failure_occurred and attempt_count < EcommerceApiDataLoader.LOADER_MAX_RETRY)): - attempt_count += 1 - if self.processing_failure_occurred and attempt_count > 1: # pragma: no cover - logger.info('Processing failure occurred attempting {attempt_count} of {max}...'.format( - attempt_count=attempt_count, - max=EcommerceApiDataLoader.LOADER_MAX_RETRY - )) - - logger.info('Refreshing ecommerce data from %s...', self.partner.ecommerce_api_url) - self._load_ecommerce_data() - - if self.processing_failure_occurred: # pragma: no cover - logger.warning('Processing failure occurred caused by an exception on at least on of the threads, ' - 'blocking deletes.') - if attempt_count >= EcommerceApiDataLoader.LOADER_MAX_RETRY: - raise CommandError('Max retries exceeded and Ecommerce Data Loader failed to successfully load') - else: - # If no errors were detected clean up Orphans - self.delete_orphans() - self._delete_entitlements() + logger.info('Refreshing ecommerce data from %s...', self.partner.ecommerce_api_url) + self._load_ecommerce_data() + + if self.processing_failure_occurred: # pragma: no cover + logger.warning( + 'Processing failure occurred caused by an exception on at least on of the threads, ' + 'blocking deletes.' + ) + raise CommandError('Ecommerce Data Loader failed to successfully load') + self._delete_entitlements() def _load_ecommerce_data(self): course_runs = self._request_course_runs(self.initial_page) - entitlements = self._request_entitlments(self.initial_page) + entitlements = self._request_entitlements(self.initial_page) enrollment_codes = self._request_enrollment_codes(self.initial_page) self.entitlement_skus = [] @@ -350,45 +370,42 @@ def _load_ecommerce_data(self): if self.is_threadsafe: for page in pageranges['course_runs']: - executor.submit(self._load_course_runs_data, page) + executor.submit(self._request_course_runs, page).add_done_callback( + lambda future: self._check_future_and_process(future, self._process_course_runs) + ) for page in pageranges['entitlements']: - executor.submit(self._load_entitlements_data, page) + executor.submit(self._request_entitlements, page).add_done_callback( + lambda future: self._check_future_and_process(future, self.process_entitlements) + ) for page in pageranges['enrollment_codes']: - executor.submit(self._load_enrollment_codes_data, page) + executor.submit(self._request_enrollment_codes, page).add_done_callback( + lambda future: self._check_future_and_process(future, self.process_enrollment_codes) + ) else: # Process in batches and wait for the result from the futures pagerange = pageranges['course_runs'] - for future in [executor.submit(self._request_course_runs, page) for page in pagerange]: - check_exception = future.exception() - if check_exception is None: - response = future.result() - self._process_course_runs(response) - else: - logger.exception(check_exception) - # Protect against deletes if exceptions occurred - self.processing_failure_occurred = True + for future in concurrent.futures.as_completed( + executor.submit(self._request_course_runs, page) for page in pagerange + ): + self._check_future_and_process( + future, self._process_course_runs, + ) pagerange = pageranges['entitlements'] - for future in [executor.submit(self._request_entitlments, page) for page in pagerange]: - check_exception = future.exception() - if check_exception is None: - response = future.result() - self._process_entitlements(response) - else: - logger.exception(check_exception) - # Protect against deletes if exceptions occurred - self.processing_failure_occurred = True + for future in concurrent.futures.as_completed( + executor.submit(self._request_entitlements, page) for page in pagerange + ): + self._check_future_and_process( + future, self._process_entitlements, + ) pagerange = pageranges['enrollment_codes'] - for future in [executor.submit(self._request_enrollment_codes, page) for page in pagerange]: - check_exception = future.exception() - if check_exception is None: - response = future.result() - self._process_enrollment_codes(response) - else: - logger.exception(check_exception) - # Protect against deletes if exceptions occurred - self.processing_failure_occurred = True + for future in concurrent.futures.as_completed( + executor.submit(self._request_enrollment_codes, page) for page in pagerange + ): + self._check_future_and_process( + future, self._process_enrollment_codes, + ) logger.info('Expected %d course seats, %d course entitlements, and %d enrollment codes from %s.', course_runs['count'], entitlements['count'], @@ -400,6 +417,16 @@ def _load_ecommerce_data(self): self.enrollment_code_count, self.partner.ecommerce_api_url) + # Try to upgrade empty run types to real ones, now that we have seats from ecommerce + empty_course_type = CourseType.objects.get(slug=CourseType.EMPTY) + empty_course_run_type = CourseRunType.objects.get(slug=CourseRunType.EMPTY) + has_empty_type = (Q(type=empty_course_type, course_runs__seats__isnull=False) | + Q(course_runs__type=empty_course_run_type, course_runs__seats__isnull=False)) + for course in Course.everything.filter(has_empty_type, partner=self.partner).distinct().iterator(): + if not calculate_course_type(course, commit=True): + logger.warning('Calculating course type failure occurred for [%s].', course) + self.processing_failure_occurred = True + if (self.course_run_count != course_runs['count'] or self.entitlement_count != entitlements['count'] or self.enrollment_code_count != enrollment_codes['count']): # pragma: no cover @@ -411,43 +438,44 @@ def _pagerange(self, count): pages = int(math.ceil(count / self.PAGE_SIZE)) return range(self.initial_page + 1, pages + 1) - def _load_course_runs_data(self, page): # pragma: no cover - """Make a request for the given page and process the response.""" - try: - course_runs = self._request_course_runs(page) - self._process_course_runs(course_runs) - - except requests.exceptions.RequestException as ex: - logger.exception(ex) - self.processing_failure_occurred = True - - def _load_entitlements_data(self, page): # pragma: no cover - """Make a request for the given page and process the response.""" - try: - entitlements = self._request_entitlments(page) - self._process_entitlements(entitlements) - - except requests.exceptions.RequestException as ex: - logger.exception(ex) - self.processing_failure_occurred = True - - def _load_enrollment_codes_data(self, page): # pragma: no cover - """Make a request for the given page and process the response.""" - try: - enrollment_codes = self._request_enrollment_codes(page) - self._process_enrollment_codes(enrollment_codes) - except requests.exceptions.RequestException as ex: - logger.exception(ex) - self.processing_failure_occurred = True - + @backoff.on_exception( + backoff.expo, + requests.exceptions.RequestException, + max_tries=5 + ) def _request_course_runs(self, page): - return self.api_client.courses().get(page=page, page_size=self.PAGE_SIZE, include_products=True) + params = {'page': page, 'page_size': self.PAGE_SIZE, 'include_products': True} + response = self.api_client.get(self.api_url + '/courses/', params=params).json() + self.api_client.close() + + return response + + @backoff.on_exception( + backoff.expo, + requests.exceptions.RequestException, + max_tries=5 + ) + def _request_entitlements(self, page): + params = {'page': page, 'page_size': self.PAGE_SIZE, 'product_class': 'Course Entitlement'} + response = self.api_client.get(self.api_url + '/products/', params=params).json() + self.api_client.close() + + return response + + @backoff.on_exception( + backoff.expo, + requests.exceptions.RequestException, + max_tries=5 + ) + def _request_enrollment_codes(self, page): + params = {'page': page, 'page_size': self.PAGE_SIZE, 'product_class': 'Enrollment Code'} + if self.course_id: + params['course_id'] = self.course_id - def _request_entitlments(self, page): - return self.api_client.products().get(page=page, page_size=self.PAGE_SIZE, product_class='Course Entitlement') + response = self.api_client.get(self.api_url + '/products/', params=params).json() + self.api_client.close() - def _request_enrollment_codes(self, page): - return self.api_client.products().get(page=page, page_size=self.PAGE_SIZE, product_class='Enrollment Code') + return response def _process_course_runs(self, response): results = response['results'] @@ -495,13 +523,23 @@ def _delete_entitlements(self): logger.info(msg) entitlements_to_delete.delete() + def _check_future_and_process(self, future, process_fn): + check_exception = future.exception() + if check_exception is None: + response = future.result() + process_fn(response) + else: + logger.exception(check_exception) + # Protect against deletes if exceptions occurred + self.processing_failure_occurred = True + def update_seats(self, body): course_run_key = body['id'] try: course_run = CourseRun.objects.get(key__iexact=course_run_key) except CourseRun.DoesNotExist: logger.warning('Could not find course run [%s]', course_run_key) - return None + return for product_body in body['products']: if product_body['structure'] != 'child': @@ -512,7 +550,15 @@ def update_seats(self, body): # Remove seats which no longer exist for that course run certificate_types = [self.get_certificate_type(product) for product in body['products'] if product['structure'] == 'child'] - course_run.seats.exclude(type__in=certificate_types).delete() + + seats_to_remove = course_run.seats.exclude(type__slug__in=certificate_types) + if seats_to_remove.count() > 0: + logger.info( + 'Removing seats [%s] for course run with key [%s].', + ', '.join(s.type.slug for s in seats_to_remove), + course_run_key, + ) + seats_to_remove.delete() def update_seat(self, course_run, product_body): stock_record = product_body['stockrecords'][0] @@ -524,11 +570,28 @@ def update_seat(self, course_run, product_body): currency = Currency.objects.get(code=currency_code) except Currency.DoesNotExist: logger.warning("Could not find currency [%s]", currency_code) - return None + return attributes = {attribute['name']: attribute['value'] for attribute in product_body['attribute_values']} - seat_type = attributes.get('certificate_type', Seat.AUDIT) + certificate_type = attributes.get('certificate_type', Seat.AUDIT) + try: + seat_type = SeatType.objects.get(slug=certificate_type) + except SeatType.DoesNotExist: + msg = ('Could not find seat type {seat_type} while loading seat with sku {sku} for course run with key ' + '{key}'.format(seat_type=certificate_type, sku=sku, key=course_run.key)) + logger.warning(msg) + self.processing_failure_occurred = True + return + if not course_run.type.empty and not course_run.type.tracks.filter(seat_type=seat_type).exists(): + logger.warning( + 'Seat type {seat_type} is not compatible with course run type {run_type} for course run {key}'.format( + seat_type=seat_type.slug, run_type=course_run.type.slug, key=course_run.key, + ) + ) + self.processing_failure_occurred = True + return + credit_provider = attributes.get('credit_provider') credit_hours = attributes.get('credit_hours') @@ -542,13 +605,16 @@ def update_seat(self, course_run, product_body): 'credit_hours': credit_hours, } - course_run.seats.update_or_create( + _, created = course_run.seats.update_or_create( type=seat_type, credit_provider=credit_provider, currency=currency, defaults=defaults ) + if created: + logger.info('Created seat for course with key [%s] and sku [%s].', course_run.key, sku) + def validate_stockrecord(self, stockrecords, title, product_class): """ Argument: @@ -657,6 +723,15 @@ def update_entitlement(self, body): mode=mode_name, title=title, sku=sku ) logger.warning(msg) + self.processing_failure_occurred = True + return None + if not course.type.empty and mode not in course.type.entitlement_types.all(): + logger.warning( + 'Seat type {seat_type} is not compatible with course type {course_type} for course {uuid}'.format( + seat_type=mode.slug, course_type=course.type.slug, uuid=course_uuid, + ) + ) + self.processing_failure_occurred = True return None defaults = { @@ -664,7 +739,6 @@ def update_entitlement(self, body): 'price': price, 'currency': currency, 'sku': sku, - 'expires': self.parse_date(body['expires']) } msg = 'Creating entitlement {title} with sku {sku} for partner {partner}'.format( title=title, sku=sku, partner=self.partner @@ -717,6 +791,7 @@ def update_enrollment_code(self, body): title=title, sku=sku, partner=self.partner ) logger.info(msg) + course_run.seats.update_or_create(type=seat_type, defaults=defaults) return sku @@ -733,12 +808,9 @@ class ProgramsApiDataLoader(AbstractDataLoader): image_height = 480 XSERIES = None - def __init__(self, partner, api_url, access_token=None, token_type=None, max_workers=None, - is_threadsafe=False, **kwargs): - super(ProgramsApiDataLoader, self).__init__( - partner, api_url, access_token, token_type, max_workers, is_threadsafe, **kwargs - ) - self.XSERIES = ProgramType.objects.get(name='XSeries') + def __init__(self, partner, api_url, max_workers=None, is_threadsafe=False): + super().__init__(partner, api_url, max_workers, is_threadsafe) + self.XSERIES = ProgramType.objects.get(translations__name_t='XSeries') def ingest(self): api_url = self.partner.programs_api_url @@ -748,12 +820,17 @@ def ingest(self): logger.info('Refreshing programs from %s...', api_url) while page: - response = self.api_client.programs.get(page=page, page_size=self.PAGE_SIZE) - count = response['count'] - results = response['results'] + params = {'page': page, 'page_size': self.PAGE_SIZE} + response = self.api_client.get(self.api_url + '/programs/', params=params) + self.api_client.close() + + response.raise_for_status() + response_json = response.json() + count = response_json['count'] + results = response_json['results'] logger.info('Retrieved %d programs...', len(results)) - if response['next']: + if response_json['next']: page += 1 else: page = None @@ -821,7 +898,7 @@ def _update_program_organizations(self, body, program): program.authoring_organizations.add(*organizations) def _get_banner_image_url(self, body): - image_key = 'w{width}h{height}'.format(width=self.image_width, height=self.image_height) + image_key = f'w{self.image_width}h{self.image_height}' image_url = body.get('banner_image_urls', {}).get(image_key) return image_url @@ -841,3 +918,264 @@ def _update_program_banner_image(self, body, program): program.save() else: logger.exception('Loading the banner image %s for program %s failed', image_url, program.title) + + +class WordPressApiDataLoader(AbstractDataLoader): + """ + Loads course runs from the Courses API in WordPress. + """ + + def ingest(self): + """ + Load courses data from the WordPress. + """ + logger.info('Refreshing Courses Data from WordPress %s...', self.partner.marketing_site_api_url) + initial_page = 1 + response = self._make_request(initial_page) + count = response['pagination']['count'] + pages = response['pagination']['num_pages'] + self._process_response(response) + + pagerange = range(initial_page + 1, pages + 1) + logger.info('Looping to request all %d WordPress pages...', pages) + + with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: + if self.is_threadsafe: + for page in pagerange: + time.sleep(30) + executor.submit(self._load_data, page) + else: + for future in [executor.submit(self._make_request, page) for page in pagerange]: + response = future.result() + self._process_response(response) + + logger.info('Retrieved %d course from %s.', count, self.partner.marketing_site_api_url) + + def _make_request(self, page): + """ + Send request to WordPress. + """ + logger.info('Requesting WordPress course run page %d...', page) + params = {'page': page, 'page_size': self.PAGE_SIZE} + if self.course_id: + params['course_id'] = self.course_id + + auth = requests.auth.HTTPBasicAuth( + settings.WORDPRESS_APP_AUTH_USERNAME, + settings.WORDPRESS_APP_AUTH_PASSWORD + ) + + with requests.get(self.api_url, auth=auth, params=params) as response: + response.raise_for_status() + return response.json() + + def _load_data(self, page): + """ + Make a request for the given page and process the response. + """ + response = self._make_request(page) + self._process_response(response) + + def _add_course_instructors(self, course_instructors, course_run): + """ + Create and add instructors to a course run. + """ + course_run.staff.clear() + for course_instructor in course_instructors: + course_instructor['partner'] = course_run.course.partner + instructor_socials = course_instructor.pop('instructor_socials') + instructor, created = Person.objects.get_or_create( + marketing_id=course_instructor['marketing_id'], + partner=course_instructor['partner'], + defaults=course_instructor + ) + if created: + for instructor_social in instructor_socials: + PersonSocialNetwork.objects.create( + person=instructor, + type=instructor_social['field_name'], + title=instructor_social['field_name'].upper(), + url=instructor_social['url'], + ) + + else: + for key, value in course_instructor.items(): + setattr(instructor, key, value) + + instructor.save() + + socials = list(instructor.person_networks.all()) + for index, instructor_social in enumerate(socials): + instructor_social.url = instructor_socials[index]['url'] + + PersonSocialNetwork.objects.bulk_update(socials, ['url']) + + instructor.given_name = instructor.given_name.title() + instructor.save() + + if not course_run.staff.filter(uuid=instructor.uuid).exists(): + course_run.staff.add(instructor) + + def _add_course_subjects(self, categories, course_run): + """ + Create and add course subjects to a course run. + """ + course_run.course.subjects.clear() + for category in categories: + subject, created = Subject.objects.get_or_create( + marketing_id=category['id'], + partner=self.partner, + defaults={ + 'description': category['description'], + 'marketing_url': category['permalink'], + 'name': category['title'], + 'slug': category['slug'] + } + ) + + if not created: + subject.description = category['description'] + subject.marketing_url = category['permalink'] + subject.name = category['title'] + subject.slug = category['slug'] + subject.save() + + if subject and category.get('title_translations', None): + for language_code, translated_title in category['title_translations'].items(): + subject_translation, __ = SubjectTranslation.objects.get_or_create( + master_id=subject.pk, + language_code=language_code + ) + subject_translation.name = translated_title if translated_title else category['title'] + subject_translation.save() + + course_run.course.subjects.add(subject) + + def _process_course_status(self, status): + """ + Helper function that process course status. + """ + return CourseRunStatus.Published if status == 'publish' else CourseRunStatus.Unpublished + + def _process_response(self, response): + """ + Process the response from the WordPress. + """ + results = response['results'] + logger.info('Retrieved %d WordPress course runs...', len(results)) + + for body in results: + course_run_key = body['course_id'] + try: + body = self.clean_strings(body) + course_run = CourseRun.objects.get(key__iexact=course_run_key) + course_run.short_description_override = body['excerpt'] + course_run.full_description_override = body['description'] + course_run.featured = body['featured'] + course_run.card_image_url = body['featured_image_url'] + course_run.slug = body['slug'] + course_run.is_marketing_price_set = body['price'] + course_run.marketing_price_value = body['price_value'] + course_run.is_marketing_price_hidden = body['hide_price'] + course_run.yt_video_url = body['yt_video_url'] + course_run.course_duration_override = body['course_duration_override'] + course_run.course_training_packages = body['course_training_packages'] + course_run.course_department = body['course_department'] + course_run.course_certifications = body['course_certifications'] + course_run.course_format = body['course_format'] + course_run.course_difficulty_level = body['course_difficulty_level'] + course_run.course_language = body['course_language'] + course_run.status = self._process_course_status(body['status']) + + course_run.tags.clear() + for tag in body['tags']: + course_run.tags.add(tag) + + course_run.save() + self._add_course_subjects(body['categories'], course_run) + self._add_course_instructors(body['course_instructors'], course_run) + except CourseRun.DoesNotExist: + logger.exception('Could not find course run [%s]', course_run_key) + except Exception: # pylint: disable=broad-except + msg = 'An error occurred while updating {course_run} from {api_url}'.format( + course_run=course_run_key, + api_url=self.partner.marketing_site_api_url + ) + logger.exception(msg) + + +class CourseRatingApiDataLoader(AbstractDataLoader): + """ + Loads courses rating from the Courses Rating API in LMS. + """ + + def ingest(self): + """ + Load courses rating data from the LMS. + """ + logger.info('Refreshing Courses Rating Data from LMS %s...', self.partner.lms_url + '/api/v1/course_average_rating/') + initial_page = 1 + response = self._make_request(initial_page) + count = response['count'] + pages = response['num_pages'] + self._process_response(response) + + page_range = range(initial_page + 1, pages + 1) + logger.info('Looping to request all %d courses rating pages...', pages) + + with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: + if self.is_threadsafe: + for page in page_range: + time.sleep(30) + executor.submit(self._load_data, page) + else: + for future in [executor.submit(self._make_request, page) for page in page_range]: + response = future.result() + self._process_response(response) + + logger.info('Retrieved %d course from %s.', count, self.partner.lms_url) + + def _make_request(self, page): + """ + Send request to LMS course rating api. + """ + logger.info('Requesting course rating page %d...', page) + params = {'page': page, 'page_size': self.PAGE_SIZE} + response = self.api_client.get(self.partner.lms_url + '/api/v1/course_average_rating/', params=params) + self.api_client.close() + response.raise_for_status() + + return response.json() + + def _load_data(self, page): + """ + Make a request for the given page and process the response. + """ + response = self._make_request(page) + self._process_response(response) + + def _process_response(self, response): + """ + Process the response from the LMS course rating api. + """ + results = response['results'] + logger.info('Retrieved %d LMS course rating api..', len(results)) + + for body in results: + course_run_key = body['course'] + try: + body = self.clean_strings(body) + course_run = CourseRun.objects.get(key__iexact=course_run_key) + course_run.average_rating = body['average_rating'] + course_run.total_raters = body['total_raters'] + course_run.save() + + except CourseRun.DoesNotExist: + logger.exception('Could not find course run [%s]', course_run_key) + + except Exception: # pylint: disable=broad-except + msg = 'An error occurred while updating {course_run} from {api_url}'.format( + course_run=course_run_key, + api_url=self.partner.lms_url + '/api/v1/course_average_rating/' + ) + logger.exception(msg) diff --git a/course_discovery/apps/course_metadata/data_loaders/course_type.py b/course_discovery/apps/course_metadata/data_loaders/course_type.py new file mode 100644 index 0000000000..1982c65c31 --- /dev/null +++ b/course_discovery/apps/course_metadata/data_loaders/course_type.py @@ -0,0 +1,138 @@ +import logging + +from django.db import transaction +from django.utils.translation import ugettext as _ + +from course_discovery.apps.course_metadata.models import CourseRunType, CourseType + +logger = logging.getLogger(__name__) + + +def _is_matching_run_type(run, run_type): + run_seat_types = set(run.seats.values_list('type', flat=True)) + type_seat_types = set(run_type.tracks.values_list('seat_type__slug', flat=True)) + return run_seat_types == type_seat_types + + +def _do_entitlements_match(course, course_type): + course_entitlement_modes = set(course.entitlements.values_list('mode', flat=True)) + type_entitlement_modes = set(course_type.entitlement_types.values_list('id', flat=True)) + + # Allow old courses without entitlements by checking if it has any first + mismatched_entitlements = course_entitlement_modes and course_entitlement_modes != type_entitlement_modes + mismatched_existing_course_type = not course.type.empty and course.type != course_type + if mismatched_entitlements or mismatched_existing_course_type: + if mismatched_entitlements and not course.type.empty and course.type == course_type: + logger.info( + _("Existing course type {type} for {key} ({id}) doesn't match its own entitlements.").format( + type=course.type.name, key=course.key, id=course.id, + ) + ) + return False + + return True + + +def _match_course_type(course, course_type, commit=False, mismatches=None): + matches = {} + + # First, early exit if entitlements don't match. + if not _do_entitlements_match(course, course_type): + return False + if course.type.empty: + matches[course] = course_type + + course_run_types = course_type.course_run_types.order_by('created') + + if mismatches and course_type.slug in mismatches: + # Using .order_by() here to reset the default ordering on these so we can eventually do the + # order by created. This has to do with what operations are allowed on a union'ed QuerySet and that + # our TimeStampedModels come with a default ordering. + unmatched_course_run_types = CourseRunType.objects.filter( + slug__in=mismatches[course_type.slug] + ).order_by() + course_run_types = course_run_types.order_by().union(unmatched_course_run_types).order_by('created') + + # Now, let's look at seat types too. If any of our CourseRunType children match a run, we'll take it. + for run in course.course_runs.order_by('key'): # ordered just for visible message reliability + # Catch existing type data that doesn't match this attempted type + if not run.type.empty and run.type not in course_run_types: + logger.info( + _("Existing run type {run_type} for {key} ({id}) doesn't match course type {type}." + "Skipping type.").format(run_type=run.type.name, key=run.key, id=run.id, type=course_type.name) + ) + return False + + run_types = course_run_types if run.type.empty else [run.type] + match = None + for run_type in run_types: + if _is_matching_run_type(run, run_type): + match = run_type + break + + if not match: + if not run.type.empty: + logger.info(_("Existing run type {run_type} for {key} ({id}) doesn't match its own seats.").format( + run_type=run.type.name, key=run.key, id=run.id, + )) + return False + + if run.type.empty: + matches[run] = match + + # OK, everything has a matching type! Course and all our runs! Yay! + + if not matches: + # We already had *all* our type fields filled out, no need to do anything (if we actively didn't match, + # we'd have already early exited False) + return True + + logger.info( + _('Course {key} ({id}) matched type {type}').format(key=course.key, id=course.id, type=course_type.name) + ) + + if commit: + try: + with transaction.atomic(): + for obj, obj_type in matches.items(): + obj.type = obj_type + obj.save() + except Exception: # pylint: disable=broad-except + logger.exception(_('Could not convert course {key} ({id}) to type {type}').format( + key=course.key, id=course.id, type=course_type.name + )) + return False + + return True + + +# This has a fair bit of testing, but it's over in test_backpopulate_course_type.py +def calculate_course_type(course, course_types=None, commit=False, mismatches=None): + """ + Calculate and set a CourseType or CourseRunType for the course and all runs in it, if possible. + + This method is designed to help fill out the new-style 'type' fields for Courses and CourseRuns. + These fields are a more explicit declaration for what sort of enrollment modes a course supports. + Whereas before, you'd have to examine the seats and entitlements for a course/run to see what sort of + course it was (i.e. is it credit? is it verified?). + + Which is what this command does - it tries to match the existing seat/entitlement profile for a course and + its runs. Then set a matching CourseType and CourseRunType for each. + + This is idempotent. + This does not change existing type fields. + But it will validate existing type fields (catch any that don't match the seat/entitlement profile). + This fills in any missing gaps (like a new rerun without a type in a course with a type). + If there are multiple matching CourseTypes, this will prefer the one that was created earlier. + If this can't find or assign a type for a course or any run inside that course, it will log it and return False. + This updates both draft and official rows (but does not require the same result for each). + """ + if not course_types: + course_types = CourseType.objects.order_by('created') + + # Go through all types, and use the first one that matches. No sensible thing to do if multiple matched... + for course_type in course_types: + if _match_course_type(course, course_type, commit=commit, mismatches=mismatches): + return True + + return False diff --git a/course_discovery/apps/course_metadata/data_loaders/marketing_site.py b/course_discovery/apps/course_metadata/data_loaders/marketing_site.py deleted file mode 100644 index 1bca1c91ba..0000000000 --- a/course_discovery/apps/course_metadata/data_loaders/marketing_site.py +++ /dev/null @@ -1,539 +0,0 @@ -import abc -import concurrent.futures -import datetime -import logging -import re -from urllib.parse import parse_qs, urlencode, urlparse -from uuid import UUID - -import pytz -from dateutil import rrule -from django.utils.functional import cached_property -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey - -from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus -from course_discovery.apps.course_metadata.data_loaders import AbstractDataLoader -from course_discovery.apps.course_metadata.models import ( - AdditionalPromoArea, Course, CourseRun, LevelType, Organization, Person, Subject -) -from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClient -from course_discovery.apps.ietf_language_tags.models import LanguageTag - -logger = logging.getLogger(__name__) - - -class AbstractMarketingSiteDataLoader(AbstractDataLoader): - def __init__(self, partner, api_url, access_token=None, token_type=None, max_workers=None, - is_threadsafe=False, **kwargs): - super(AbstractMarketingSiteDataLoader, self).__init__( - partner, api_url, access_token, token_type, max_workers, is_threadsafe, **kwargs - ) - - if not (self.partner.marketing_site_api_username and self.partner.marketing_site_api_password): - msg = 'Marketing Site API credentials are not properly configured for Partner [{partner}]!'.format( - partner=partner.short_code) - raise Exception(msg) - - @cached_property - def api_client(self): - - marketing_site_api_client = MarketingSiteAPIClient( - self.partner.marketing_site_api_username, - self.partner.marketing_site_api_password, - self.api_url - ) - - return marketing_site_api_client.api_session - - def get_query_kwargs(self): - return { - 'type': self.node_type, - 'max-depth': 2, - 'load-entity-refs': 'file', - } - - def ingest(self): - """ Load data for all supported objects (e.g. courses, runs). """ - initial_page = 0 - response = self._request(initial_page) - self._process_response(response) - - data = response.json() - if 'next' in data: - # Add one to avoid requesting the first page again and to make sure - # we get the last page when range() is used below. - pages = [self._extract_page(url) + 1 for url in (data['first'], data['last'])] - pagerange = range(*pages) - - with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor: - if self.is_threadsafe: # pragma: no cover - for page in pagerange: - executor.submit(self._load_data, page) - else: - for future in [executor.submit(self._request, page) for page in pagerange]: - response = future.result() - self._process_response(response) - - def _load_data(self, page): # pragma: no cover - """Make a request for the given page and process the response.""" - response = self._request(page) - self._process_response(response) - - def _request(self, page): - """Make a request to the marketing site.""" - kwargs = {'page': page} - kwargs.update(self.get_query_kwargs()) - - qs = urlencode(kwargs) - url = '{root}/node.json?{qs}'.format(root=self.api_url, qs=qs) - - return self.api_client.get(url) - - def _check_status_code(self, response): - """Check the status code on a response from the marketing site.""" - status_code = response.status_code - if status_code != 200: - msg = 'Failed to retrieve data from {url}\nStatus Code: {status}\nBody: {body}'.format( - url=response.url, status=status_code, body=response.content) - logger.error(msg) - raise Exception(msg) - - def _extract_page(self, url): - """Extract page number from a marketing site URL.""" - qs = parse_qs(urlparse(url).query) - - return int(qs['page'][0]) - - def _process_response(self, response): - """Process a response from the marketing site.""" - self._check_status_code(response) - - data = response.json() - for node in data['list']: - try: - url = node['url'] - node = self.clean_strings(node) - self.process_node(node) - except: # pylint: disable=bare-except - logger.exception('Failed to load %s.', url) - - def _get_nested_url(self, field): - """ Helper method that retrieves the nested `url` field in the specified field, if it exists. - This works around the fact that Drupal represents empty objects as arrays instead of objects.""" - field = field or {} - return field.get('url') - - @abc.abstractmethod - def process_node(self, data): # pragma: no cover - pass - - @abc.abstractproperty - def node_type(self): # pragma: no cover - pass - - -class SubjectMarketingSiteDataLoader(AbstractMarketingSiteDataLoader): - @property - def node_type(self): - return 'subject' - - def process_node(self, data): - slug = data['field_subject_url_slug'] - if ('language' not in data) or (data['language'] == 'und'): - language_code = 'en' - else: - language_code = data['language'] - defaults = { - 'uuid': data['uuid'], - 'name': data['title'], - 'description': self.clean_html(data['body']['value']), - 'subtitle': self.clean_html(data['field_subject_subtitle']['value']), - 'card_image_url': self._get_nested_url(data.get('field_subject_card_image')), - # NOTE (CCB): This is not a typo. Yes, the banner image for subjects is in a field with xseries in the name. - 'banner_image_url': self._get_nested_url(data.get('field_xseries_banner_image')) - } - - # There is a bug with django-parler when using django's update_or_create() so we manually update or create. - try: - subject = Subject.objects.get(slug=slug, partner=self.partner) - subject.set_current_language(language_code) - for key, value in defaults.items(): - setattr(subject, key, value) - subject.save() - except Subject.DoesNotExist: - new_values = {'slug': slug, 'partner': self.partner, '_current_language': language_code} - new_values.update(defaults) - subject = Subject(**new_values) - subject.save() - - logger.info('Processed subject with slug [%s].', slug) - return subject - - -class SchoolMarketingSiteDataLoader(AbstractMarketingSiteDataLoader): - @property - def node_type(self): - return 'school' - - def process_node(self, data): - # NOTE: Some titles in Drupal have the form "UC BerkeleyX" however, course keys (for which we use the - # organization key) cannot contain spaces. - key = data['title'].replace(' ', '') - uuid = UUID(data['uuid']) - - defaults = { - 'name': data['field_school_name'], - 'description': self.clean_html(data['field_school_description']['value']), - 'logo_image_url': self._get_nested_url(data.get('field_school_image_logo')), - 'banner_image_url': self._get_nested_url(data.get('field_school_image_banner')), - 'marketing_url_path': 'school/' + data['field_school_url_slug'], - 'partner': self.partner, - } - - try: - school = Organization.objects.get(uuid=uuid, partner=self.partner) - Organization.objects.filter(pk=school.pk).update(**defaults) - logger.info('Updated school with key [%s].', school.key) - except Organization.DoesNotExist: - # NOTE: Some organizations' keys do not match the title. For example, "UC BerkeleyX" courses use - # BerkeleyX as the key. Those fixes will be made manually after initial import, and we don't want to - # override them with subsequent imports. Thus, we only set the key when creating a new organization. - defaults['key'] = key - defaults['uuid'] = uuid - school = Organization.objects.create(**defaults) - logger.info('Created school with key [%s].', school.key) - - self.set_tags(school, data) - - logger.info('Processed school with key [%s].', school.key) - return school - - def set_tags(self, school, data): - tags = [] - mapping = { - 'field_school_is_founder': 'founder', - 'field_school_is_charter': 'charter', - 'field_school_is_contributor': 'contributor', - 'field_school_is_partner': 'partner', - } - - for field, tag in mapping.items(): - if data.get(field, False): - tags.append(tag) - - school.tags.set(*tags, clear=True) - - -class SponsorMarketingSiteDataLoader(AbstractMarketingSiteDataLoader): - @property - def node_type(self): - return 'sponsorer' - - def process_node(self, data): - uuid = data['uuid'] - body = (data['body'] or {}).get('value') - - if body: - body = self.clean_html(body) - - defaults = { - 'key': data['url'].split('/')[-1], - 'name': data['title'], - 'description': body, - 'logo_image_url': data['field_sponsorer_image']['url'], - } - sponsor, __ = Organization.objects.update_or_create(uuid=uuid, partner=self.partner, defaults=defaults) - - logger.info('Processed sponsor with UUID [%s].', uuid) - return sponsor - - -class CourseMarketingSiteDataLoader(AbstractMarketingSiteDataLoader): - LANGUAGE_MAP = { - 'English': 'en-us', - '日本語': 'ja', - '繁體中文': 'zh-Hant', - 'Indonesian': 'id', - 'Italian': 'it-it', - 'Korean': 'ko', - 'Simplified Chinese': 'zh-Hans', - 'Deutsch': 'de-de', - 'Español': 'es-es', - 'Français': 'fr-fr', - 'Nederlands': 'nl-nl', - 'Português': 'pt-pt', - 'Pусский': 'ru', - 'Svenska': 'sv-se', - 'Türkçe': 'tr', - 'العربية': 'ar-sa', - 'हिंदी': 'hi', - '中文': 'zh-cmn', - } - - @property - def node_type(self): - return 'course' - - @classmethod - def get_language_tags_from_names(cls, names): - language_codes = [cls.LANGUAGE_MAP.get(name) for name in names] - return LanguageTag.objects.filter(code__in=language_codes) - - def get_query_kwargs(self): - kwargs = super(CourseMarketingSiteDataLoader, self).get_query_kwargs() - # NOTE (CCB): We need to include the nested taxonomy_term data since that is where the - # language information is stored. - kwargs['load-entity-refs'] = 'file,taxonomy_term' - return kwargs - - def process_node(self, data): - - if not data.get('field_course_uuid'): - course_run = self.get_course_run(data) - - if course_run: - self.update_course_run(course_run, data) - if self.get_course_run_status(data) == CourseRunStatus.Published: - # Only update the course object with published course about page - try: - course = self.update_course(course_run.course, data) - self.set_subjects(course, data) - self.set_authoring_organizations(course, data) - logger.info( - 'Processed course with key [%s] based on the data from courserun [%s]', - course.key, - course_run.key - ) - except AttributeError: - pass - else: - logger.info( - 'Course_run [%s] is unpublished, so the course [%s] related is not updated.', - data['field_course_id'], - course_run.course.number - ) - else: - created = False - # If the page is not generated from discovery service - # Do shall then attempt to create a course out of it - try: - course, created = self.get_or_create_course(data) - course_run = self.create_course_run(course, data) - except InvalidKeyError: - logger.error('Invalid course key [%s].', data['field_course_id']) - - if created: - course.canonical_course_run = course_run - course.save() - else: - logger.info( - 'Course_run [%s] has uuid [%s] already on course about page. No need to ingest', - data['field_course_id'], - data['field_course_uuid'] - ) - - def get_course_run(self, data): - course_run_key = data['field_course_id'] - try: - return CourseRun.objects.get(key__iexact=course_run_key) - except CourseRun.DoesNotExist: - return None - - def update_course_run(self, course_run, data): - validated_data = self.format_course_run_data(data, course_run.course) - self._update_instance(course_run, validated_data, suppress_publication=True) - self.set_course_run_staff(course_run, data) - self.set_course_run_transcript_languages(course_run, data) - - logger.info('Processed course run with UUID [%s].', course_run.uuid) - - def create_course_run(self, course, data): - defaults = self.format_course_run_data(data, course) - - course_run = CourseRun.objects.create(**defaults) - self.set_course_run_staff(course_run, data) - self.set_course_run_transcript_languages(course_run, data) - - return course_run - - def get_or_create_course(self, data): - defaults = self.format_course_data(data, set_key=True) - key = defaults['key'] - - course, created = Course.objects.get_or_create(key__iexact=key, partner=self.partner, defaults=defaults) - - if created: - self.set_subjects(course, data) - self.set_authoring_organizations(course, data) - - return (course, created) - - def update_course(self, course, data): - validated_data = self.format_course_data(data) - self._update_instance(course, validated_data) - - return course - - def _update_instance(self, instance, validated_data, **kwargs): - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save(**kwargs) - - def format_course_run_data(self, data, course): - uuid = data['uuid'] - key = data['field_course_id'] - language_tags = self._extract_language_tags(data['field_course_languages']) - language = language_tags[0] if language_tags else None - start = data.get('field_course_start_date') - start = datetime.datetime.fromtimestamp(int(start), tz=pytz.UTC) if start else None - end = data.get('field_course_end_date') - end = datetime.datetime.fromtimestamp(int(end), tz=pytz.UTC) if end else None - weeks_to_complete = data.get('field_course_required_weeks') - min_effort, max_effort = self.get_min_max_effort_per_week(data) - - defaults = { - 'key': key, - 'uuid': uuid, - 'title_override': self.clean_html(data['field_course_course_title']['value']), - 'language': language, - 'card_image_url': self._get_nested_url(data.get('field_course_image_promoted')), - 'status': self.get_course_run_status(data), - 'start': start, - 'pacing_type': self.get_pacing_type(data), - 'hidden': self.get_hidden(data), - 'weeks_to_complete': None, - 'mobile_available': data.get('field_course_enrollment_mobile') or False, - 'video': course.video, - 'course': course, - # We want to consume the same value for the override here to stay consistent with the marketing site - 'short_description_override': self.clean_html(data['field_course_sub_title_long']['value']) or None, - 'min_effort': min_effort, - 'max_effort': max_effort, - 'outcome': (data.get('field_course_what_u_will_learn', {}) or {}).get('value') - } - - if weeks_to_complete: - defaults['weeks_to_complete'] = int(weeks_to_complete) - elif start and end: - weeks_to_complete = rrule.rrule(rrule.WEEKLY, dtstart=start, until=end).count() - defaults['weeks_to_complete'] = int(weeks_to_complete) - - return defaults - - def format_course_data(self, data, set_key=False): - # Parse key up front to ensure it's a valid key. - # If the course is so messed up that we can't even parse the key, we don't want it. - course_run_key = CourseKey.from_string(data['field_course_id']) - - defaults = { - 'title': self.clean_html(data['field_course_course_title']['value']), - 'full_description': self.get_description(data), - 'video': self.get_video(data), - 'short_description': self.clean_html(data['field_course_sub_title_long']['value']), - 'level_type': self.get_level_type(data['field_course_level']), - 'card_image_url': self._get_nested_url(data.get('field_course_image_promoted')), - 'outcome': (data.get('field_course_what_u_will_learn', {}) or {}).get('value'), - 'syllabus_raw': (data.get('field_course_syllabus', {}) or {}).get('value'), - 'prerequisites_raw': (data.get('field_course_prerequisites', {}) or {}).get('value'), - 'extra_description': self.get_extra_description(data) - } - - if set_key: - # Only set key/number values if we're asked to, normally we don't want to change these in Discovery - defaults['key'] = self.get_course_key_from_course_run_key(course_run_key) - defaults['number'] = data['field_course_code'] - - return defaults - - def get_description(self, data): - description = (data.get('field_course_body', {}) or {}).get('value') - description = description or (data.get('field_course_description', {}) or {}).get('value') - description = description or '' - description = self.clean_html(description) - return description - - def get_course_run_status(self, data): - return CourseRunStatus.Published if bool(int(data['status'])) else CourseRunStatus.Unpublished - - def get_level_type(self, name): - level_type = None - - if name: - level_type, __ = LevelType.objects.get_or_create(name=name) - - return level_type - - def get_video(self, data): - video_url = self._get_nested_url(data.get('field_course_video') or data.get('field_product_video')) - image_url = self._get_nested_url(data.get('field_course_image_featured_card')) - return self.get_or_create_video(video_url, image_url) - - def get_pacing_type(self, data): - self_paced = data.get('field_course_self_paced', False) - return CourseRunPacing.Self if self_paced else CourseRunPacing.Instructor - - def get_hidden(self, data): - # 'couse' [sic]. The field is misspelled on Drupal. ಠ_ಠ - hidden = data.get('field_couse_is_hidden', False) - return hidden is True - - def get_min_max_effort_per_week(self, data): - """ - Parse effort value from drupal course data which have specific format. - """ - effort_per_week = data.get('field_course_effort', '') - min_effort = None - max_effort = None - # Ignore effort values in minutes - if not effort_per_week or 'minutes' in effort_per_week: - return min_effort, max_effort - - effort_values = [int(keyword) for keyword in re.split(r'\s|-|–|,|\+|~', effort_per_week) if keyword.isdigit()] - if len(effort_values) == 1: - max_effort = effort_values[0] - if len(effort_values) == 2: - min_effort = effort_values[0] - max_effort = effort_values[1] - - return min_effort, max_effort - - def _get_objects_by_uuid(self, object_type, raw_objects_data): - uuids = [_object.get('uuid') for _object in raw_objects_data] - return object_type.objects.filter(uuid__in=uuids) - - def _extract_language_tags(self, raw_objects_data): - language_names = [_object['name'].strip() for _object in raw_objects_data] - return self.get_language_tags_from_names(language_names) - - def get_extra_description(self, raw_objects_data): - extra_title = raw_objects_data.get('field_course_extra_desc_title', None) - if extra_title == 'null': - extra_title = None - extra_description = (raw_objects_data.get('field_course_extra_description', {}) or {}).get('value') - if extra_title or extra_description: - extra, _ = AdditionalPromoArea.objects.get_or_create( - title=extra_title, - description=extra_description - ) - return extra - - def set_authoring_organizations(self, course, data): - schools = self._get_objects_by_uuid(Organization, data['field_course_school_node']) - course.authoring_organizations.clear() - course.authoring_organizations.add(*schools) - - def set_subjects(self, course, data): - subjects = self._get_objects_by_uuid(Subject, data['field_course_subject']) - course.subjects.clear() - course.subjects.add(*subjects) # pylint: disable=not-an-iterable - - def set_course_run_staff(self, course_run, data): - staff = self._get_objects_by_uuid(Person, data['field_course_staff']) - course_run.staff.clear() - course_run.staff.add(*staff) - - def set_course_run_transcript_languages(self, course_run, data): - language_tags = self._extract_language_tags(data['field_course_video_locale_lang']) - course_run.transcript_languages.clear() - course_run.transcript_languages.add(*language_tags) diff --git a/course_discovery/apps/course_metadata/data_loaders/tests/mixins.py b/course_discovery/apps/course_metadata/data_loaders/tests/mixins.py index c987499bf7..8fb9e09621 100644 --- a/course_discovery/apps/course_metadata/data_loaders/tests/mixins.py +++ b/course_discovery/apps/course_metadata/data_loaders/tests/mixins.py @@ -1,34 +1,25 @@ +from unittest import mock + import responses -from edx_rest_api_client.auth import SuppliedJwtAuth -from edx_rest_api_client.client import EdxRestApiClient +from course_discovery.apps.api.v1.tests.test_views.mixins import OAuth2Mixin from course_discovery.apps.course_metadata.tests.factories import PartnerFactory -ACCESS_TOKEN = 'secret' -ACCESS_TOKEN_TYPE = 'JWT' - - -class ApiClientTestMixin(object): - def test_api_client(self): - """ Verify the property returns an API client with the correct authentication. """ - loader = self.loader_class(self.partner, self.api_url, ACCESS_TOKEN, ACCESS_TOKEN_TYPE) - client = loader.api_client - self.assertIsInstance(client, EdxRestApiClient) - # NOTE (CCB): My initial preference was to mock the constructor and ensure the correct auth arguments - # were passed. However, that seems nearly impossible. This is the next best alternative. It is brittle, and - # may break if we ever change the underlying request class of EdxRestApiClient. - self.assertIsInstance(client._store['session'].auth, SuppliedJwtAuth) # pylint: disable=protected-access - # pylint: disable=not-callable -class DataLoaderTestMixin(object): +class DataLoaderTestMixin(OAuth2Mixin): loader_class = None partner = None def setUp(self): - super(DataLoaderTestMixin, self).setUp() - self.partner = PartnerFactory() - self.loader = self.loader_class(self.partner, self.api_url, ACCESS_TOKEN, ACCESS_TOKEN_TYPE) + super().setUp() + self.partner = PartnerFactory(lms_url='http://127.0.0.1:8000') + self.mock_access_token() + with mock.patch( + 'course_discovery.apps.course_metadata.data_loaders.configured_jwt_decode_handler', + return_value={'preferred_username': 'test_username'}, + ): + self.loader = self.loader_class(self.partner, self.api_url) @property def api_url(self): # pragma: no cover @@ -38,10 +29,9 @@ def assert_api_called(self, expected_num_calls, check_auth=True): """ Asserts the API was called with the correct number of calls, and the appropriate Authorization header. """ self.assertEqual(len(responses.calls), expected_num_calls) if check_auth: - self.assertEqual(responses.calls[0].request.headers['Authorization'], 'JWT {}'.format(ACCESS_TOKEN)) + # 'JWT abcd' is the default value that comes from the mock_access_token function called in setUp + self.assertEqual(responses.calls[1].request.headers['Authorization'], 'JWT abcd') def test_init(self): """ Verify the constructor sets the appropriate attributes. """ self.assertEqual(self.loader.partner.short_code, self.partner.short_code) - self.assertEqual(self.loader.access_token, ACCESS_TOKEN) - self.assertEqual(self.loader.token_type, ACCESS_TOKEN_TYPE.lower()) diff --git a/course_discovery/apps/course_metadata/data_loaders/tests/mock_data.py b/course_discovery/apps/course_metadata/data_loaders/tests/mock_data.py index 235cc901cc..574e6281c4 100644 --- a/course_discovery/apps/course_metadata/data_loaders/tests/mock_data.py +++ b/course_discovery/apps/course_metadata/data_loaders/tests/mock_data.py @@ -4,6 +4,21 @@ 'title': 'A partial course', } +STAGE_TAG_FIELD_RESPONSE_DATA = [ + { + 'uri': 'https://www.edx.org/taxonomy_term/1721', + 'id': '1721', + 'resource': 'taxonomy_term', + 'uuid': 'c53b2abf-d4bd-429f-a0a5-211aac95487d' + }, + { + 'uri': 'https://www.edx.org/taxonomy_term/1706', + 'id': '1706', + 'resource': 'taxonomy_term', + 'uuid': '57dc60cb-e05c-464c-900f-333d60d249d1' + } +] + EXISTING_COURSE_AND_RUN_DATA = ( { 'course_run_key': 'course-v1:SC+BreadX+3T2015', @@ -95,8 +110,8 @@ COURSES_API_BODY_ORIGINAL = { 'effort': None, 'end': None, - 'enrollment_start': None, - 'enrollment_end': None, + 'enrollment_start': '2015-05-15T13:00:00Z', + 'enrollment_end': '2015-06-29T13:00:00Z', 'id': 'course-v1:KyotoUx+000x+3T2016', 'media': { 'course_image': { @@ -204,7 +219,7 @@ "attribute_values": [ { "name": "certificate_type", - "value": "honor" + "value": "audit" } ], "stockrecords": [ @@ -1315,7 +1330,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': None, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'An introduction to the intellectual enterprises of computer science and the ' 'art of programming.', 'field_course_length_weeks': None, @@ -1327,7 +1342,7 @@ 'field_course_required_weeks': None, 'field_course_required_days': None, 'field_course_required_hours': None, - 'node_id': '254', + 'nid': '254', 'vid': '8078', 'is_new': False, 'type': 'course', @@ -1666,7 +1681,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': False, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'PH207x is the online adaptation of material from the Harvard School of Public ' 'Health\u0027s classes in epidemiology and biostatistics.', 'field_course_length_weeks': '13 weeks', @@ -1678,7 +1693,7 @@ 'field_course_required_weeks': '4', 'field_course_required_days': '0', 'field_course_required_hours': '0', - 'node_id': '354', + 'nid': '354', 'vid': '112156', 'is_new': False, 'type': 'course', @@ -1935,7 +1950,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': None, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'A survey of ancient Greek literature focusing on classical concepts of the ' 'hero and how they can inform our understanding of the human condition.', 'field_course_length_weeks': '23 weeks', @@ -1947,7 +1962,7 @@ 'field_course_required_weeks': None, 'field_course_required_days': None, 'field_course_required_hours': None, - 'node_id': '563', + 'nid': '563', 'vid': '8080', 'is_new': False, 'type': 'course', @@ -2206,7 +2221,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': None, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'ORIGINAL A survey of ancient Greek literature focusing on classical concepts of' ' the hero and how they can inform our understanding of the human condition.', 'field_course_length_weeks': '23 weeks', @@ -2218,7 +2233,7 @@ 'field_course_required_weeks': None, 'field_course_required_days': None, 'field_course_required_hours': None, - 'node_id': '563', + 'nid': '563', 'vid': '8080', 'is_new': False, 'type': 'course', @@ -2476,7 +2491,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': None, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'UPDATED A survey of ancient Greek literature focusing on classical concepts of' ' the hero and how they can inform our understanding of the human condition.', 'field_course_length_weeks': '23 weeks', @@ -2488,7 +2503,7 @@ 'field_course_required_weeks': None, 'field_course_required_days': None, 'field_course_required_hours': None, - 'node_id': '563', + 'nid': '563', 'vid': '8080', 'is_new': False, 'type': 'course', @@ -2746,7 +2761,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': None, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'NEW_RUN A survey of ancient Greek literature focusing on classical concepts of' ' the hero and how they can inform our understanding of the human condition.', 'field_course_length_weeks': '23 weeks', @@ -2758,7 +2773,7 @@ 'field_course_required_weeks': None, 'field_course_required_days': None, 'field_course_required_hours': None, - 'node_id': '563', + 'nid': '563', 'vid': '8080', 'is_new': False, 'type': 'course', @@ -2784,7 +2799,6 @@ 'vuuid': 'e0f8c80a-b377-4546-b247-1c94ab3a218a' } - DISCOVERY_CREATED_MARKETING_SITE_API_COURSE_BODY = { 'field_course_uuid': 'f0f8c80a-b377-4546-b547-1c94ab3a218a', 'field_course_code': 'CB22x', @@ -3018,7 +3032,7 @@ 'field_course_has_prerequisites': True, 'field_course_enrollment_credit': None, 'field_course_is_disabled': None, - 'field_course_tags': [], + 'field_course_tags': STAGE_TAG_FIELD_RESPONSE_DATA, 'field_course_sub_title_short': 'NEW_RUN A survey of ancient Greek literature focusing on classical concepts of' ' the hero and how they can inform our understanding of the human condition.', 'field_course_length_weeks': '23 weeks', @@ -3030,7 +3044,7 @@ 'field_course_required_weeks': None, 'field_course_required_days': None, 'field_course_required_hours': None, - 'node_id': '563', + 'nid': '563', 'vid': '8080', 'is_new': False, 'type': 'course', @@ -3055,3 +3069,81 @@ 'uuid': '6b8b779f-f567-4e98-aa41-a265d6fa073a', 'vuuid': 'e0f8c80a-b377-4546-b247-1c94ab3a218a' } + +WORDPRESS_API_BODIES = [ + { + 'course_id': 'course-v1:edX+DemoX+Demo_Course', + 'slug': 'demo-course', + 'permalink': 'https://example.com/course/demo-course', + 'title': 'Demo Course', + 'description': 'This is long description', + 'excerpt': 'This is short description', + 'featured': True, + 'categories': [ + { + 'id': '1', + 'title': 'Technology', + 'slug': 'technology', + 'description': 'Technology category description', + 'permalink': 'http://example.com/category/technology', + 'title_translations': { + 'en': 'Technology', + 'ar': 'تكنولوجيا', + 'fr': 'La technologie', + 'es': 'Tecnología', + }, + }, + { + 'id': '2', + 'title': 'General', + 'slug': 'general', + 'description': 'General category description', + 'permalink': 'http://example.com/category/general', + 'title_translations': { + 'en': 'General', + 'ar': 'عام', + 'fr': 'Général', + 'es': 'General', + }, + } + ], + 'tags': ['tag1', 'tag2', 'tag3'], + 'featured_image_url': 'http://example.com/demo-course-image.jpg', + 'course_instructors': [ + { + 'given_name': 'Test instructor', + 'bio': 'This is a test instructor', + 'email': 'test@admin.com', + 'designation': 'SQA', + 'profile_image_url': 'http://example.com/demo-course-image.jpg', + 'marketing_id': 100, + 'marketing_url': 'http://example.com/demo-course-image', + 'phone_number': '12345', + 'website': 'http://example.com', + 'instructor_socials': [ + { + 'field_name': 'facebook', + 'url': 'http://facebook.com' + } + ] + } + ], + 'price': True, + 'price_value': '$100', + 'hide_price': False, + 'status': 'publish' + } +] + +COURSE_AVERAGE_RATING_API_BODIES = [ + { + 'course': 'course-v1:KyotoUx+000x+2T2016', + 'average_rating': '4.00', + 'total_raters': 2, + }, + { + 'course': 'course-v1:MITx+0.111x+2T2015', + 'average_rating': '5.00', + 'total_raters': 3, + }, +] diff --git a/course_discovery/apps/course_metadata/data_loaders/tests/test_analytics_api.py b/course_discovery/apps/course_metadata/data_loaders/tests/test_analytics_api.py index 8250aa8670..71fdf6d17e 100644 --- a/course_discovery/apps/course_metadata/data_loaders/tests/test_analytics_api.py +++ b/course_discovery/apps/course_metadata/data_loaders/tests/test_analytics_api.py @@ -46,15 +46,13 @@ def _define_course_metadata(self): # Create a program with all of the courses we created program = ProgramFactory() - program.courses = courses.values() - program.save() + program.courses.set(courses.values()) @responses.activate def test_ingest(self): self._define_course_metadata() - url = '{root_url}course_summaries/'.format(root_url=self.api_url) - + url = f'{self.api_url}course_summaries/' responses.add( method=responses.GET, url=url, @@ -68,8 +66,8 @@ def test_ingest(self): expected_course_enrollment_counts = {} course_runs = CourseRun.objects.all() for course_run in course_runs: - self.assertTrue(course_run.enrollment_count > 0) - self.assertTrue(course_run.recent_enrollment_count > 0) + self.assertGreater(course_run.enrollment_count, 0) + self.assertGreater(course_run.recent_enrollment_count, 0) course = course_run.course if course.key in expected_course_enrollment_counts.keys(): expected_course_enrollment_counts[course.key]['count'] += course_run.enrollment_count diff --git a/course_discovery/apps/course_metadata/data_loaders/tests/test_api.py b/course_discovery/apps/course_metadata/data_loaders/tests/test_api.py index 5a3f7e8484..97c242c9cf 100644 --- a/course_discovery/apps/course_metadata/data_loaders/tests/test_api.py +++ b/course_discovery/apps/course_metadata/data_loaders/tests/test_api.py @@ -1,29 +1,38 @@ import datetime import json from decimal import Decimal +from unittest import mock import ddt -import mock +import pytz import responses +from django.core.management import CommandError +from django.http.response import HttpResponse from django.test import TestCase +from edx_django_utils.cache import TieredCache from pytz import UTC -from waffle.testutils import override_switch +from slumber.exceptions import HttpClientError from course_discovery.apps.core.tests.utils import mock_api_callback, mock_jpeg_callback from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus from course_discovery.apps.course_metadata.data_loaders.api import ( - AbstractDataLoader, CoursesApiDataLoader, EcommerceApiDataLoader, OrganizationsApiDataLoader, ProgramsApiDataLoader + AbstractDataLoader, + CoursesApiDataLoader, + CourseRatingApiDataLoader, + EcommerceApiDataLoader, + ProgramsApiDataLoader, + WordPressApiDataLoader, + _fatal_code, ) from course_discovery.apps.course_metadata.data_loaders.tests import JPEG, JSON, mock_data -from course_discovery.apps.course_metadata.data_loaders.tests.mixins import ApiClientTestMixin, DataLoaderTestMixin +from course_discovery.apps.course_metadata.data_loaders.tests.mixins import DataLoaderTestMixin from course_discovery.apps.course_metadata.models import ( - Course, CourseEntitlement, CourseRun, Organization, Program, ProgramType, Seat, SeatType + Course, CourseEntitlement, CourseRun, CourseRunType, CourseType, Organization, Person, Program, ProgramType, Seat, + SeatType ) from course_discovery.apps.course_metadata.tests.factories import ( - CourseEntitlementFactory, CourseFactory, CourseRunFactory, ImageFactory, OrganizationFactory, SeatFactory, - VideoFactory + CourseEntitlementFactory, CourseFactory, CourseRunFactory, OrganizationFactory, SeatFactory, SeatTypeFactory ) -from course_discovery.apps.publisher.constants import PUBLISHER_ENABLE_READ_ONLY_FIELDS LOGGER_PATH = 'course_discovery.apps.course_metadata.data_loaders.api.logger' @@ -54,102 +63,9 @@ def test_parse_date(self): dt = datetime.datetime.utcnow() self.assertEqual(AbstractDataLoader.parse_date(dt.isoformat()), dt) - def test_delete_orphans(self): - """ Verify the delete_orphans method deletes orphaned instances. """ - instances = (ImageFactory(), VideoFactory(),) - AbstractDataLoader.delete_orphans() - - for instance in instances: - self.assertFalse(instance.__class__.objects.filter(pk=instance.pk).exists()) - - def test_clean_html(self): - """ Verify the method removes unnecessary HTML attributes. """ - data = ( - ('', '',), - ('

Hello!

', 'Hello!'), - ('Testing', 'Testing'), - ('Hello&world !', 'Hello&world!') - ) - - for content, expected in data: - self.assertEqual(AbstractDataLoader.clean_html(content), expected) - - -@ddt.ddt -class OrganizationsApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCase): - loader_class = OrganizationsApiDataLoader - - @property - def api_url(self): - return self.partner.organizations_api_url - - def mock_api(self): - bodies = mock_data.ORGANIZATIONS_API_BODIES - url = self.api_url + 'organizations/' - responses.add_callback( - responses.GET, - url, - callback=mock_api_callback(url, bodies), - content_type=JSON - ) - return bodies - - def assert_organization_loaded(self, body, partner_has_marketing_site=True): - """ Assert an Organization corresponding to the specified data body was properly loaded into the database. """ - organization = Organization.objects.get(key=AbstractDataLoader.clean_string(body['short_name'])) - if not partner_has_marketing_site: - self.assertEqual(organization.name, AbstractDataLoader.clean_string(body['name'])) - self.assertEqual(organization.description, AbstractDataLoader.clean_string(body['description'])) - self.assertEqual(organization.logo_image_url, AbstractDataLoader.clean_string(body['logo'])) - self.assertEqual(organization.certificate_logo_image_url, AbstractDataLoader.clean_string(body['logo'])) - - @responses.activate - @ddt.data(True, False) - def test_ingest(self, partner_has_marketing_site): - """ Verify the method ingests data from the Organizations API. """ - api_data = self.mock_api() - if not partner_has_marketing_site: - self.partner.marketing_site_url_root = None - self.partner.save() - - self.assertEqual(Organization.objects.count(), 0) - - self.loader.ingest() - - # Verify the API was called with the correct authorization header - self.assert_api_called(1) - - # Verify the Organizations were created correctly - expected_num_orgs = len(api_data) - self.assertEqual(Organization.objects.count(), expected_num_orgs) - - for datum in api_data: - self.assert_organization_loaded(datum, partner_has_marketing_site) - - # Verify multiple calls to ingest data do NOT result in data integrity errors. - self.loader.ingest() - - @responses.activate - def test_ingest_respects_partner(self): - """ - Existing organizations with the same key but linked to different partners - shouldn't cause organization data loading to fail. - """ - api_data = self.mock_api() - key = api_data[1]['short_name'] - - OrganizationFactory(key=key, partner=self.partner) - OrganizationFactory(key=key) - - assert Organization.objects.count() == 2 - - self.loader.ingest() - - assert Organization.objects.count() == len(api_data) + 1 - @ddt.ddt -class CoursesApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCase): +class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase): loader_class = CoursesApiDataLoader @property @@ -168,97 +84,158 @@ def mock_api(self, bodies=None): ) return bodies - def assert_course_run_loaded( - self, - body, partner_has_marketing_site=True, - is_publisher_read_only_switch_active=True - ): + def test_fatal_code(self): + response_with_200 = HttpResponse(status=200) + response_with_400 = HttpResponse(status=400) + response_with_429 = HttpResponse(status=429) + response_with_504 = HttpResponse(status=504) + self.assertFalse(_fatal_code(HttpClientError(response=response_with_200))) + self.assertTrue(_fatal_code(HttpClientError(response=response_with_400))) + self.assertFalse(_fatal_code(HttpClientError(response=response_with_429))) + self.assertFalse(_fatal_code(HttpClientError(response=response_with_504))) + + def assert_course_run_loaded(self, body, partner_uses_publisher=True, draft=False, new_pub=False): """ Assert a CourseRun corresponding to the specified data body was properly loaded into the database. """ # Validate the Course course_key = '{org}+{key}'.format(org=body['org'], key=body['number']) organization = Organization.objects.get(key=body['org']) - course = Course.objects.get(key=course_key) + course = Course.everything.get(key=course_key, draft=draft) - self.assertEqual(course.title, body['name']) self.assertListEqual(list(course.authoring_organizations.all()), [organization]) # Validate the course run course_run = course.course_runs.get(key=body['id']) expected_values = { - 'title': self.loader.clean_string(body['name']), - 'short_description': self.loader.clean_string(body['short_description']), + 'start': self.loader.parse_date(body['start']), 'end': self.loader.parse_date(body['end']), 'enrollment_start': self.loader.parse_date(body['enrollment_start']), 'enrollment_end': self.loader.parse_date(body['enrollment_end']), 'card_image_url': None, - 'title_override': None, + 'title_override': body['name'], 'short_description_override': None, 'video': None, 'hidden': body.get('hidden', False), 'license': body.get('license', ''), } - start = self.loader.parse_date(body['start']) - pacing_type = self.loader.get_pacing_type(body) + if not partner_uses_publisher or new_pub: + expected_values['pacing_type'] = self.loader.get_pacing_type(body) - if not partner_has_marketing_site: + if not partner_uses_publisher: expected_values.update({ - 'start': start, - 'card_image_url': body['media'].get('image', {}).get('raw'), - 'title_override': body['name'], + 'card_image_url': None, 'short_description_override': self.loader.clean_string(body['short_description']), 'video': self.loader.get_courserun_video(body), 'status': CourseRunStatus.Published, - 'pacing_type': pacing_type, + 'pacing_type': self.loader.get_pacing_type(body), 'mobile_available': body['mobile_available'] or False, }) - if is_publisher_read_only_switch_active: - expected_values.update({ - 'start': start, - 'pacing_type': pacing_type - }) + # Check if the course card_image_url was correctly updated + self.assertEqual(course.card_image_url, body['media'].get('image', {}).get('raw'),) for field, value in expected_values.items(): - self.assertEqual(getattr(course_run, field), value, 'Field {} is invalid.'.format(field)) + self.assertEqual(getattr(course_run, field), value, f'Field {field} is invalid.') return course_run @responses.activate - @ddt.unpack @ddt.data( (True, True), (True, False), (False, True), - (False, False) + (False, False), ) - def test_ingest(self, partner_has_marketing_site, is_publisher_read_only_switch_active): + @ddt.unpack + def test_ingest(self, partner_uses_publisher, on_new_publisher): """ Verify the method ingests data from the Courses API. """ - with override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=is_publisher_read_only_switch_active): - api_data = self.mock_api() - if not partner_has_marketing_site: - self.partner.marketing_site_url_root = None - self.partner.save() + TieredCache.dangerous_clear_all_tiers() + api_data = self.mock_api() + if not partner_uses_publisher: + self.partner.publisher_url = None + self.partner.save() - self.assertEqual(Course.objects.count(), 0) - self.assertEqual(CourseRun.objects.count(), 0) + self.assertEqual(Course.objects.count(), 0) + self.assertEqual(CourseRun.objects.count(), 0) + # Assume that while we are relying on ORGS_ON_OLD_PUBLISHER it will never be empty + with self.settings(ORGS_ON_OLD_PUBLISHER='MITx' if not on_new_publisher else 'OTHER'): self.loader.ingest() - # Verify the API was called with the correct authorization header - self.assert_api_called(1) + # Verify the API was called with the correct authorization header + self.assert_api_called(4) + + # Verify the CourseRuns were created correctly + expected_num_course_runs = len(api_data) + self.assertEqual(CourseRun.objects.count(), expected_num_course_runs) + + for datum in api_data: + self.assert_course_run_loaded(datum, partner_uses_publisher, new_pub=on_new_publisher) - # Verify the CourseRuns were created correctly - expected_num_course_runs = len(api_data) - self.assertEqual(CourseRun.objects.count(), expected_num_course_runs) + # Verify multiple calls to ingest data do NOT result in data integrity errors. + self.loader.ingest() + + @responses.activate + @mock.patch('course_discovery.apps.course_metadata.data_loaders.api.push_to_ecommerce_for_course_run') + def test_ingest_verified_deadline(self, mock_push_to_ecomm): + """ Verify the method ingests data from the Courses API. """ + TieredCache.dangerous_clear_all_tiers() + api_data = self.mock_api() - for datum in api_data: - self.assert_course_run_loaded(datum, partner_has_marketing_site, is_publisher_read_only_switch_active) + self.assertEqual(Course.objects.count(), 0) + self.assertEqual(CourseRun.objects.count(), 0) - # Verify multiple calls to ingest data do NOT result in data integrity errors. + # Assume that while we are relying on ORGS_ON_OLD_PUBLISHER it will never be empty + with self.settings(ORGS_ON_OLD_PUBLISHER='OTHER'): self.loader.ingest() + # Verify the API was called with the correct authorization header + self.assert_api_called(4) + + runs = CourseRun.objects.all() + # Run with a verified entitlement, but no change in end date + run1 = runs[0] + run1.seats.add(SeatFactory(course_run=run1, type=SeatTypeFactory.verified())) + run1.save() + + # Run with a verified entitlement, and the end date has changed + run2 = runs[1] + run2.seats.add(SeatFactory( + course_run=run2, + type=SeatTypeFactory.verified(), + upgrade_deadline=datetime.datetime.now(pytz.UTC), + )) + original_run2_deadline = run2.seats.first().upgrade_deadline + run2.end = datetime.datetime.now(pytz.UTC) + run2.save() + + # Run with a credit entitlement, and the end date has changed should not + run3 = runs[2] + run3.seats.add(SeatFactory( + course_run=run3, + type=SeatTypeFactory.credit(), + upgrade_deadline=None, + )) + run3.end = datetime.datetime.now(pytz.UTC) + run3.save() + + # Verify the CourseRuns were created correctly + expected_num_course_runs = len(api_data) + self.assertEqual(CourseRun.objects.count(), expected_num_course_runs) + + # Verify multiple calls to ingest data do NOT result in data integrity errors. + self.loader.ingest() + calls = [ + mock.call(run2), + mock.call(run3), + ] + mock_push_to_ecomm.assert_has_calls(calls) + # Make sure the verified seat with a course run end date is changed + self.assertNotEqual(original_run2_deadline, run2.seats.first().upgrade_deadline) + # Make sure the credit seat with a course run end date is unchanged + self.assertIsNone(run3.seats.first().upgrade_deadline) + @responses.activate def test_ingest_exception_handling(self): """ Verify the data loader properly handles exceptions during processing of the data from the API. """ @@ -268,7 +245,7 @@ def test_ingest_exception_handling(self): with mock.patch(LOGGER_PATH) as mock_logger: self.loader.ingest() self.assertEqual(mock_logger.exception.call_count, len(api_data)) - msg = 'An error occurred while updating {0} from {1}'.format( + msg = 'An error occurred while updating {} from {}'.format( api_data[-1]['id'], self.partner.courses_api_url ) @@ -276,7 +253,7 @@ def test_ingest_exception_handling(self): @responses.activate @ddt.data(True, False) - def test_ingest_canonical(self, partner_has_marketing_site): + def test_ingest_canonical(self, partner_uses_publisher): """ Verify the method ingests data from the Courses API. """ self.assertEqual(Course.objects.count(), 0) self.assertEqual(CourseRun.objects.count(), 0) @@ -287,8 +264,8 @@ def test_ingest_canonical(self, partner_has_marketing_site): mock_data.COURSES_API_BODY_UPDATED, ]) - if not partner_has_marketing_site: - self.partner.marketing_site_url_root = None + if not partner_uses_publisher: + self.partner.publisher_url = None self.partner.save() self.loader.ingest() @@ -308,8 +285,8 @@ def test_ingest_canonical(self, partner_has_marketing_site): # Verify second course not used to update course self.assertNotEqual(mock_data.COURSES_API_BODY_SECOND['name'], course.title) - if partner_has_marketing_site: - # Verify the course remains unchanged by api update if we have marketing site + if partner_uses_publisher: + # Verify the course remains unchanged by api update if we have publisher self.assertEqual(mock_data.COURSES_API_BODY_ORIGINAL['name'], course.title) else: # Verify updated canonical course used to update course @@ -317,6 +294,145 @@ def test_ingest_canonical(self, partner_has_marketing_site): # Verify the updated course run updated the original course run self.assertEqual(mock_data.COURSES_API_BODY_UPDATED['hidden'], course_run_orig.hidden) + def assert_run_and_course_updated(self, datum, run, exists, draft, partner_uses_publisher): + course_key = '{org}+{number}'.format(org=datum['org'], number=datum['number']) + run_key = datum['id'] + if run: + self.assert_course_run_loaded(datum, partner_uses_publisher, draft=draft) + if partner_uses_publisher and exists: # will not update course + self.assertNotEqual(Course.everything.get(key=course_key, draft=draft).title, datum['name']) + else: + self.assertEqual(Course.everything.get(key=course_key, draft=draft).title, datum['name']) + else: + self.assertFalse(CourseRun.everything.filter(key=run_key, draft=draft).exists()) + self.assertFalse(Course.everything.filter(key=course_key, draft=draft).exists()) + + @responses.activate + @ddt.data( + (True, True, True), + (True, False, True), + (False, True, True), + (False, False, True), + (True, True, False), + (True, False, False), + (False, True, False), + (False, False, False), + ) + @ddt.unpack + def test_ingest_handles_draft(self, official_exists, draft_exists, partner_uses_publisher): + """ + Verify the method ingests data from the Courses API, and updates both official and draft versions. + """ + datum = mock_data.COURSES_API_BODY_ORIGINAL + self.mock_api([datum]) + + if not partner_uses_publisher: + self.partner.publisher_url = None + self.partner.save() + + official_run = None + draft_run = None + + course_key = '{org}+{number}'.format(org=datum['org'], number=datum['number']) + run_key = datum['id'] + official_course_kwargs = {} + official_run_kwargs = {} + all_courses = set() + all_runs = set() + audit_run_type = CourseRunType.objects.get(slug=CourseRunType.AUDIT) + if draft_exists or official_exists: + org = OrganizationFactory(key=datum['org']) + if draft_exists: + draft_course = Course.objects.create(partner=self.partner, key=course_key, title='Title', draft=True) + draft_run = CourseRun.objects.create(course=draft_course, key=run_key, type=audit_run_type, draft=True) + draft_course.canonical_course_run = draft_run + draft_course.save() + draft_course.authoring_organizations.add(org) + official_course_kwargs = {'draft_version': draft_course} + official_run_kwargs = {'draft_version': draft_run} + all_courses.add(draft_course) + all_runs.add(draft_run) + if official_exists: + official_course = Course.objects.create(partner=self.partner, key=course_key, title='Title', + **official_course_kwargs) + official_run = CourseRun.objects.create(course=official_course, key=run_key, type=audit_run_type, + **official_run_kwargs) + official_course.canonical_course_run = official_run + official_course.save() + official_course.authoring_organizations.add(org) + all_courses.add(official_course) + all_runs.add(official_run) + + self.loader.ingest() + + if draft_exists or official_exists: + self.assertEqual(set(Course.everything.all()), all_courses) + self.assertEqual(set(CourseRun.everything.all()), all_runs) + else: + # We should have made official versions of the data + official_course = Course.everything.get() + official_run = CourseRun.everything.get() + self.assertFalse(official_course.draft) + self.assertFalse(official_run.draft) + self.assertEqual(official_course.canonical_course_run, official_run) + + self.assert_run_and_course_updated(datum, draft_run, draft_exists, True, partner_uses_publisher) + self.assert_run_and_course_updated(datum, official_run, official_exists, False, partner_uses_publisher) + + @responses.activate + def test_ingest_studio_made_run_with_existing_draft_course(self): + """ + Verify that we correctly stitch up a course run freshly made in Studio but with existing Publisher content. + """ + datum = mock_data.COURSES_API_BODY_ORIGINAL + self.mock_api([datum]) + + course_key = '{org}+{number}'.format(org=datum['org'], number=datum['number']) + Course.objects.create(partner=self.partner, key=course_key, title='Title', draft=True) + + self.loader.ingest() + + # We expect an official version of the course to be created (which points to the draft) and both a draft version + # and official version of the course run to be created. + + draft_course = Course.everything.get(partner=self.partner, key=course_key, draft=True) + official_course = Course.everything.get(partner=self.partner, key=course_key, draft=False) + self.assertEqual(official_course.draft_version, draft_course) + self.assertEqual(draft_course.course_runs.count(), 1) + self.assertEqual(official_course.course_runs.count(), 1) + + draft_run = draft_course.course_runs.first() + official_run = official_course.course_runs.first() + self.assertNotEqual(draft_run, official_run) + self.assertEqual(official_run.draft_version, draft_run) + + def test_assigns_types(self): + """ + Verify we set the special empty course and course run types when creating courses and runs. + And that we copy the type of the most recent run if it exists. + """ + # First, confirm that we make new courses and runs with the empty type. + self.mock_api([mock_data.COURSES_API_BODY_ORIGINAL]) + self.loader.ingest() + + course_run = CourseRun.objects.get(key=mock_data.COURSES_API_BODY_ORIGINAL['id']) + self.assertIsNotNone(course_run.type) + self.assertEqual(course_run.type.slug, CourseRunType.EMPTY) + + course = course_run.course + self.assertIsNotNone(course.type) + self.assertEqual(course.type.slug, CourseType.EMPTY) + + # Now confirm that we will copy the last run type if available. + course_run.type = CourseRunType.objects.get(slug=CourseRunType.VERIFIED_AUDIT) + course_run.save(suppress_publication=True) + responses.reset() + self.mock_api([mock_data.COURSES_API_BODY_SECOND]) + self.loader.ingest() + + course_run2 = CourseRun.objects.get(key=mock_data.COURSES_API_BODY_SECOND['id']) + self.assertEqual(course_run2.type, course_run.type) + def test_get_pacing_type_field_missing(self): """ Verify the method returns None if the API response does not include a pacing field. """ self.assertIsNone(self.loader.get_pacing_type({})) @@ -358,7 +474,7 @@ def test_get_courserun_video(self, uri, expected_video_src): @ddt.ddt -class EcommerceApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCase): +class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase): loader_class = EcommerceApiDataLoader @property @@ -367,15 +483,23 @@ def api_url(self): def mock_courses_api(self): # Create existing seats to be removed by ingest - audit_run = CourseRunFactory(title_override='audit', key='audit/course/run') - verified_run = CourseRunFactory(title_override='verified', key='verified/course/run') - credit_run = CourseRunFactory(title_override='credit', key='credit/course/run') - no_currency_run = CourseRunFactory(title_override='no currency', key='nocurrency/course/run') - - SeatFactory(course_run=audit_run, type=Seat.PROFESSIONAL) - SeatFactory(course_run=verified_run, type=Seat.PROFESSIONAL) - SeatFactory(course_run=credit_run, type=Seat.PROFESSIONAL) - SeatFactory(course_run=no_currency_run, type=Seat.PROFESSIONAL) + audit_run_type = CourseRunType.objects.get(slug=CourseRunType.AUDIT) + credit_run_type = CourseRunType.objects.get(slug=CourseRunType.CREDIT_VERIFIED_AUDIT) + verified_run_type = CourseRunType.objects.get(slug=CourseRunType.VERIFIED_AUDIT) + audit_run = CourseRunFactory(title_override='audit', key='audit/course/run', type=audit_run_type, + course__partner=self.partner) + verified_run = CourseRunFactory(title_override='verified', key='verified/course/run', type=verified_run_type, + course__partner=self.partner) + credit_run = CourseRunFactory(title_override='credit', key='credit/course/run', type=credit_run_type, + course__partner=self.partner) + no_currency_run = CourseRunFactory(title_override='no currency', key='nocurrency/course/run', + type=verified_run_type, course__partner=self.partner) + + professional_type = SeatTypeFactory.professional() + SeatFactory(course_run=audit_run, type=professional_type) + SeatFactory(course_run=verified_run, type=professional_type) + SeatFactory(course_run=credit_run, type=professional_type) + SeatFactory(course_run=no_currency_run, type=professional_type) bodies = mock_data.ECOMMERCE_API_BODIES url = self.api_url + 'courses/' @@ -390,7 +514,7 @@ def mock_courses_api(self): def mock_products_api(self, alt_course=None, alt_currency=None, alt_mode=None, has_stockrecord=True, valid_stockrecord=True, product_class=None): """ Return a new Course Entitlement and Enrollment Code to be added by ingest """ - course = CourseFactory() + course = CourseFactory(type=CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT), partner=self.partner) # If product_class is given, make sure it's either entitlement or enrollment_code if product_class: @@ -467,7 +591,7 @@ def mock_products_api(self, alt_course=None, alt_currency=None, alt_mode=None, h data['entitlement']['results'][0]["stockrecords"].append(stockrecord) data['enrollment_code']['results'][0]["stockrecords"].append(stockrecord) - url = '{url}products/'.format(url=self.api_url) + url = f'{self.api_url}products/' responses.add( responses.GET, @@ -564,10 +688,11 @@ def assert_seats_loaded(self, body, mock_products): credit_hours = att['value'] bulk_sku = self.get_product_bulk_sku(certificate_type, course_run, mock_products) - seat = course_run.seats.get(type=certificate_type, credit_provider=credit_provider, currency=price_currency) + seat = course_run.seats.get(type__slug=certificate_type, credit_provider=credit_provider, + currency=price_currency) self.assertEqual(seat.course_run, course_run) - self.assertEqual(seat.type, certificate_type) + self.assertEqual(seat.type.slug, certificate_type) self.assertEqual(seat.price, price) self.assertEqual(seat.currency.code, price_currency) self.assertEqual(seat.credit_provider, credit_provider) @@ -581,7 +706,6 @@ def assert_entitlements_loaded(self, body): body = [d for d in body if d['product_class'] == 'Course Entitlement'] self.assertEqual(CourseEntitlement.objects.count(), len(body)) for datum in body: - expires = datum['expires'] attributes = {attribute['name']: attribute['value'] for attribute in datum['attribute_values']} course = Course.objects.get(uuid=attributes['UUID']) stock_record = datum['stockrecords'][0] @@ -594,7 +718,6 @@ def assert_entitlements_loaded(self, body): entitlement = course.entitlements.get(mode=mode) - self.assertEqual(entitlement.expires, expires) self.assertEqual(entitlement.course, course) self.assertEqual(entitlement.price, price) self.assertEqual(entitlement.currency.code, price_currency) @@ -610,7 +733,7 @@ def assert_enrollment_codes_loaded(self, body): bulk_sku = stock_record['partner_sku'] mode_name = attributes['seat_type'] - seat = course_run.seats.get(type=mode_name) + seat = course_run.seats.get(type__slug=mode_name) self.assertEqual(seat.course_run, course_run) self.assertEqual(seat.bulk_sku, bulk_sku) @@ -618,6 +741,7 @@ def assert_enrollment_codes_loaded(self, body): @responses.activate def test_ingest(self): """ Verify the method ingests data from the E-Commerce API. """ + TieredCache.dangerous_clear_all_tiers() courses_api_data = self.mock_courses_api() loaded_course_run_data = courses_api_data[:-1] loaded_seat_data = courses_api_data[:-2] @@ -632,7 +756,7 @@ def test_ingest(self): self.loader.ingest() # Verify the API was called with the correct authorization header - self.assert_api_called(3) + self.assert_api_called(4) for datum in loaded_seat_data: self.assert_seats_loaded(datum, products_api_data) @@ -689,16 +813,48 @@ def test_invalid_stockrecord(self, product_class): ) mock_logger.warning.assert_any_call(msg) + def test_invalid_seat_types_for_course_type(self): + self.mock_courses_api() + + # Assign CourseType and CourseRunType values, which will conflict with the attempted verified seat type + course_run = CourseRun.objects.get(key='verified/course/run') + course_run.type = CourseRunType.objects.get(slug=CourseRunType.PROFESSIONAL) + course_run.save() + course = course_run.course + course.type = CourseType.objects.get(slug=CourseType.PROFESSIONAL) + course.save() + + self.mock_products_api(alt_course=str(course.uuid)) + + with mock.patch(LOGGER_PATH) as mock_logger: + with self.assertRaises(CommandError): + self.loader.ingest() + mock_logger.warning.assert_any_call( + 'Seat type verified is not compatible with course type professional for course {uuid}'.format( + uuid=course.uuid + ) + ) + mock_logger.warning.assert_any_call( + 'Seat type verified is not compatible with course run type professional for course run {key}'.format( + key=course_run.key, + ) + ) + + course.refresh_from_db() + course_run.refresh_from_db() + self.assertEqual(course.entitlements.count(), 0) + self.assertEqual(course_run.seats.count(), 0) + @responses.activate @ddt.data( - ('a01354b1-c0de-4a6b-c5de-ab5c6d869e76', None, None, 'entitlement'), - ('a01354b1-c0de-4a6b-c5de-ab5c6d869e76', None, None, 'enrollment_code'), - (None, "NRC", None, 'enrollment_code'), - (None, None, "notamode", 'entitlement'), - (None, None, "notamode", 'enrollment_code') + ('a01354b1-c0de-4a6b-c5de-ab5c6d869e76', None, None, 'entitlement', False), + ('a01354b1-c0de-4a6b-c5de-ab5c6d869e76', None, None, 'enrollment_code', False), + (None, "NRC", None, 'enrollment_code', False), + (None, None, "notamode", 'entitlement', True), + (None, None, "notamode", 'enrollment_code', True) ) @ddt.unpack - def test_ingest_fails(self, alt_course, alt_currency, alt_mode, product_class): + def test_ingest_fails(self, alt_course, alt_currency, alt_mode, product_class, raises): """ Verify the proper warnings are logged when data objects are not present. """ self.mock_courses_api() self.mock_products_api( @@ -708,7 +864,11 @@ def test_ingest_fails(self, alt_course, alt_currency, alt_mode, product_class): product_class=product_class ) with mock.patch(LOGGER_PATH) as mock_logger: - self.loader.ingest() + if raises: + with self.assertRaises(CommandError): + self.loader.ingest() + else: + self.loader.ingest() msg = self.compose_warning_log(alt_course, alt_currency, alt_mode, product_class) mock_logger.warning.assert_any_call(msg) @@ -731,9 +891,57 @@ def test_get_certificate_type(self, product, expected_certificate_type): """ Verify the method returns the correct certificate type""" self.assertEqual(self.loader.get_certificate_type(product), expected_certificate_type) + @responses.activate + def test_upgrade_empty_types(self): + """ Verify that we try to fill in any empty course or run types after loading seats. """ + self.mock_courses_api() + self.mock_products_api() + + # Set everything to empty, so we can upgrade them + empty_type = CourseType.objects.get(slug=CourseType.EMPTY) + Course.everything.update(type=empty_type) + empty_run_type = CourseRunType.objects.get(slug=CourseRunType.EMPTY) + CourseRun.everything.update(type=empty_run_type) + + # However, make sure we notice when a run is set, but the course is empty. + credit_run = CourseRun.objects.get(key='credit/course/run') + credit_run.type = CourseRunType.objects.get(slug=CourseRunType.CREDIT_VERIFIED_AUDIT) + credit_run.save() + + # And also make sure we notice when a run is empty even though the course is not. + # Also set it to the wrong type, to test that we fail when we can't find a match + audit_run = CourseRun.objects.get(key='audit/course/run') + audit_run.course.type = CourseType.objects.get(slug=CourseType.PROFESSIONAL) + audit_run.course.save() + + with self.assertRaises(CommandError): + self.loader.ingest() + + # Audit will have failed to match and nothing should have changed + audit_run = CourseRun.objects.get(key='audit/course/run') + self.assertEqual(audit_run.type.slug, CourseRunType.EMPTY) + self.assertEqual(audit_run.course.type.slug, CourseType.PROFESSIONAL) + + verified_run = CourseRun.objects.get(key='verified/course/run') + self.assertEqual(verified_run.type.slug, CourseRunType.VERIFIED_AUDIT) + self.assertEqual(verified_run.course.type.slug, CourseType.VERIFIED_AUDIT) + + credit_run = CourseRun.objects.get(key='credit/course/run') + self.assertEqual(credit_run.type.slug, CourseType.CREDIT_VERIFIED_AUDIT) + self.assertEqual(credit_run.course.type.slug, CourseType.CREDIT_VERIFIED_AUDIT) + + # Let's fix audit's type and try again. + audit_run.course.type = empty_type + audit_run.course.save() + self.loader.ingest() + + audit_run = CourseRun.objects.get(key='audit/course/run') + self.assertEqual(audit_run.type.slug, CourseType.AUDIT) + self.assertEqual(audit_run.course.type.slug, CourseType.AUDIT) + @ddt.ddt -class ProgramsApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCase): +class ProgramsApiDataLoaderTests(DataLoaderTestMixin, TestCase): loader_class = ProgramsApiDataLoader @property @@ -781,7 +989,7 @@ def assert_program_loaded(self, body): for attr in ('subtitle', 'status', 'marketing_slug',): self.assertEqual(getattr(program, attr), AbstractDataLoader.clean_string(body[attr])) - self.assertEqual(program.type, ProgramType.objects.get(name='XSeries')) + self.assertEqual(program.type, ProgramType.objects.get(translations__name_t='XSeries')) keys = [org['key'] for org in body['organizations']] expected_organizations = list(Organization.objects.filter(key__in=keys)) @@ -821,13 +1029,14 @@ def assert_program_banner_image_loaded(self, body): @responses.activate def test_ingest(self): """ Verify the method ingests data from the Organizations API. """ + TieredCache.dangerous_clear_all_tiers() api_data = self.mock_api() self.assertEqual(Program.objects.count(), 0) self.loader.ingest() # Verify the API was called with the correct authorization header - self.assert_api_called(2) + self.assert_api_called(6) # Verify the Programs were created correctly self.assertEqual(Program.objects.count(), len(api_data)) @@ -855,6 +1064,7 @@ def test_ingest_with_missing_organizations(self): @responses.activate def test_ingest_with_existing_banner_image(self): + TieredCache.dangerous_clear_all_tiers() programs = self.mock_api() for program_data in programs: @@ -869,8 +1079,166 @@ def test_ingest_with_existing_banner_image(self): self.loader.ingest() # Verify the API was called with the correct authorization header - self.assert_api_called(2) + self.assert_api_called(6) for program in programs: self.assert_program_loaded(program) self.assert_program_banner_image_loaded(program) + + +@ddt.ddt +class WordPressApiDataLoaderTests(DataLoaderTestMixin, TestCase): + loader_class = WordPressApiDataLoader + + @property + def api_url(self): + return self.partner.marketing_site_api_url + + def mock_api(self): + """ + Mock the WordPress course run API. + """ + bodies = mock_data.WORDPRESS_API_BODIES + url = self.api_url.strip('/') + responses.add_callback( + responses.GET, + url, + callback=mock_api_callback(url, bodies, pagination=True), + content_type=JSON + ) + return bodies + + def assert_tags_equal(self, qs, tags, sort=True, attr="name"): + """ + Helper function to assert the course tags. + """ + got = [getattr(obj, attr) for obj in qs] + if sort: + got.sort() + tags.sort() + + assert got == tags + + def test_ingest(self): + """ + Test the WordPress data loader. + """ + TieredCache.dangerous_clear_all_tiers() + api_data = self.mock_api() + expected_course = api_data[0] + CourseRunFactory( + key=expected_course['course_id'], + title_override=expected_course['title'] + ) + + self.loader.ingest() + + course = CourseRun.objects.filter(key__iexact=expected_course['course_id']).first() + assert course.slug == expected_course['slug'] + assert course.short_description_override == expected_course['excerpt'] + assert course.full_description_override == expected_course['description'] + assert course.featured == expected_course['featured'] + assert course.card_image_url == expected_course['featured_image_url'] + assert course.is_marketing_price_set == expected_course['price'] + assert course.marketing_price_value == expected_course['price_value'] + assert course.is_marketing_price_hidden == expected_course['hide_price'] + self.assert_tags_equal(course.tags.all(), expected_course['tags']) + + for category in expected_course['categories']: + subject = course.subjects.get(slug=category['slug']) + assert subject.slug == category['slug'] + assert subject.name == category['title'] + assert subject.description == category['description'] + + for course_instructor in expected_course['course_instructors']: + instructor = Person.objects.get(given_name=course_instructor['given_name'].title()) + assert instructor.given_name == course_instructor['given_name'].title() + assert instructor.designation == course_instructor['designation'] + assert instructor.email == course_instructor['email'] + assert instructor.bio == course_instructor['bio'] + assert instructor.profile_image_url == course_instructor['profile_image_url'] + assert instructor.marketing_id == course_instructor['marketing_id'] + assert instructor.marketing_url == course_instructor['marketing_url'] + assert instructor.phone_number == course_instructor['phone_number'] + assert instructor.website == course_instructor['website'] + assert instructor.person_networks.first().type == course_instructor['instructor_socials'][0]['field_name'] + assert instructor.person_networks.first().url == course_instructor['instructor_socials'][0]['url'] + + def test_ingest_with_fail(self): + """ + Test that data loader raise logs errors if course run not found. + """ + TieredCache.dangerous_clear_all_tiers() + api_data = self.mock_api() + + with LogCapture('course_discovery.apps.course_metadata.data_loaders.api') as logger: + self.loader.ingest() + logger.check_present( + ( + 'course_discovery.apps.course_metadata.data_loaders.api', + 'ERROR', + 'Could not find course run [{course_run}]'.format( + course_run=api_data[0]['course_id'] + ) + ) + ) + + +@ddt.ddt +class CourseRatingApiDataLoaderTests(DataLoaderTestMixin, TestCase): + loader_class = CourseRatingApiDataLoader + + @property + def api_url(self): + return self.partner.lms_url + '/api/v1/course_average_rating/' + + def mock_api(self, bodies=None): + if not bodies: + bodies = mock_data.COURSE_AVERAGE_RATING_API_BODIES + url = self.api_url + responses.add_callback( + responses.GET, + url, + callback=mock_api_callback(url, bodies), + content_type=JSON + ) + return bodies + + def test_ingest(self): + """ + Test the Course Rating data loader. + """ + TieredCache.dangerous_clear_all_tiers() + api_data = self.mock_api() + expected_course = api_data[0] + CourseRunFactory( + title_override='audit', + key=expected_course['course'], + average_rating=expected_course['average_rating'], + total_raters=expected_course['total_raters'] + ) + + self.loader.ingest() + + course = CourseRun.objects.filter(key__iexact=expected_course['course']).first() + assert course.average_rating == Decimal(expected_course['average_rating']) + assert course.total_raters == expected_course['total_raters'] + + def test_ingest_with_fail(self): + """ + Test that data loader raise logs errors if course run not found. + """ + TieredCache.dangerous_clear_all_tiers() + api_data = self.mock_api() + + with LogCapture('course_discovery.apps.course_metadata.data_loaders.api') as logger: + self.loader.ingest() + logger.check_present( + ( + 'course_discovery.apps.course_metadata.data_loaders.api', + 'ERROR', + 'Could not find course run [{course_run}]'.format( + course_run=api_data[0]['course'] + ) + ) + ) diff --git a/course_discovery/apps/course_metadata/data_loaders/tests/test_marketing_site.py b/course_discovery/apps/course_metadata/data_loaders/tests/test_marketing_site.py deleted file mode 100644 index 26192e7510..0000000000 --- a/course_discovery/apps/course_metadata/data_loaders/tests/test_marketing_site.py +++ /dev/null @@ -1,647 +0,0 @@ -import datetime -import json -import math -from urllib.parse import parse_qs, urlparse -from uuid import UUID - -import ddt -import mock -import pytz -import responses -from dateutil import rrule -from django.test import TestCase -from opaque_keys.edx.keys import CourseKey -from testfixtures import LogCapture - -from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus -from course_discovery.apps.course_metadata.data_loaders.marketing_site import logger as marketing_site_logger -from course_discovery.apps.course_metadata.data_loaders.marketing_site import ( - CourseMarketingSiteDataLoader, SchoolMarketingSiteDataLoader, SponsorMarketingSiteDataLoader, - SubjectMarketingSiteDataLoader -) -from course_discovery.apps.course_metadata.data_loaders.tests import JSON, mock_data -from course_discovery.apps.course_metadata.data_loaders.tests.mixins import DataLoaderTestMixin -from course_discovery.apps.course_metadata.models import Course, Organization, Subject -from course_discovery.apps.course_metadata.tests import factories -from course_discovery.apps.ietf_language_tags.models import LanguageTag - -ENGLISH_LANGUAGE_TAG = LanguageTag(code='en-us', name='English - United States') -LOGGER_PATH = 'course_discovery.apps.course_metadata.data_loaders.marketing_site.logger' - - -class AbstractMarketingSiteDataLoaderTestMixin(DataLoaderTestMixin): - mocked_data = [] - - @property - def api_url(self): - return self.partner.marketing_site_url_root - - def mock_api_callback(self, url, data): - """ Paginate the data, one item per page. """ - - def request_callback(request): - count = len(data) - - # Use the querystring to determine which page should be returned. Default to page 1. - # Note that the values of the dict returned by `parse_qs` are lists, hence the `[1]` default value. - qs = parse_qs(urlparse(request.path_url).query) - page = int(qs.get('page', [0])[0]) - page_size = 1 - - body = { - 'list': [data[page]], - 'first': '{}?page={}'.format(url, 0), - 'last': '{}?page={}'.format(url, math.ceil(count / page_size) - 1), - } - - if (page * page_size) < count - 1: - next_page = page + 1 - next_url = '{}?page={}'.format(url, next_page) - body['next'] = next_url - - return 200, {}, json.dumps(body) - - return request_callback - - def mock_api(self): - bodies = self.mocked_data - url = self.api_url + 'node.json' - - responses.add_callback( - responses.GET, - url, - callback=self.mock_api_callback(url, bodies), - content_type=JSON - ) - - return bodies - - def mock_login_response(self, failure=False): - url = self.api_url + 'user' - landing_url = '{base}admin'.format(base=self.api_url) - status = 500 if failure else 302 - adding_headers = {} - - if not failure: - adding_headers['Location'] = landing_url - responses.add(responses.POST, url, status=status, adding_headers=adding_headers) - - responses.add( - responses.GET, - landing_url, - status=(500 if failure else 200) - ) - - responses.add( - responses.GET, - '{root}restws/session/token'.format(root=self.api_url), - body='test token', - content_type='text/html', - status=200 - ) - - def mock_api_failure(self): - url = self.api_url + 'node.json' - responses.add(responses.GET, url, status=500) - - @responses.activate - def test_ingest_with_api_failure(self): - self.mock_login_response() - self.mock_api_failure() - - with self.assertRaises(Exception): - self.loader.ingest() - - @responses.activate - def test_ingest_exception_handling(self): - """ Verify the data loader properly handles exceptions during processing of the data from the API. """ - self.mock_login_response() - api_data = self.mock_api() - - with mock.patch.object(self.loader, 'clean_strings', side_effect=Exception): - with mock.patch(LOGGER_PATH) as mock_logger: - self.loader.ingest() - self.assertEqual(mock_logger.exception.call_count, len(api_data)) - calls = [mock.call('Failed to load %s.', datum['url']) for datum in api_data] - mock_logger.exception.assert_has_calls(calls) - - @responses.activate - def test_api_client_login_failure(self): - self.mock_login_response(failure=True) - with self.assertRaises(Exception): - self.loader.api_client # pylint: disable=pointless-statement - - def test_constructor_without_credentials(self): - """ Verify the constructor raises an exception if the Partner has no marketing site credentials set. """ - self.partner.marketing_site_api_username = None - with self.assertRaises(Exception): - self.loader_class(self.partner, self.api_url) # pylint: disable=not-callable - - -class SubjectMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixin, TestCase): - loader_class = SubjectMarketingSiteDataLoader - mocked_data = mock_data.MARKETING_SITE_API_SUBJECT_BODIES - - def assert_subject_loaded(self, data): - slug = data['field_subject_url_slug'] - subject = Subject.objects.get(slug=slug, partner=self.partner) - expected_values = { - 'uuid': UUID(data['uuid']), - 'name': data['title'], - 'description': self.loader.clean_html(data['body']['value']), - 'subtitle': self.loader.clean_html(data['field_subject_subtitle']['value']), - 'card_image_url': data['field_subject_card_image']['url'], - 'banner_image_url': data['field_xseries_banner_image']['url'], - } - - for field, value in expected_values.items(): - self.assertEqual(getattr(subject, field), value) - - @responses.activate - def test_ingest_create(self): - self.mock_login_response() - api_data = self.mock_api() - - self.loader.ingest() - - for datum in api_data: - self.assert_subject_loaded(datum) - - @responses.activate - def test_ingest_update(self): - self.mock_login_response() - api_data = self.mock_api() - for data in api_data: - subject_data = { - 'uuid': UUID(data['uuid']), - 'name': data['title'], - 'description': self.loader.clean_html(data['body']['value']), - 'subtitle': self.loader.clean_html(data['field_subject_subtitle']['value']), - 'card_image_url': data['field_subject_card_image']['url'], - 'banner_image_url': data['field_xseries_banner_image']['url'], - } - slug = data['field_subject_url_slug'] - - Subject.objects.create(slug=slug, partner=self.partner, **subject_data) - - self.loader.ingest() - - for datum in api_data: - self.assert_subject_loaded(datum) - - -class SchoolMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixin, TestCase): - loader_class = SchoolMarketingSiteDataLoader - mocked_data = mock_data.MARKETING_SITE_API_SCHOOL_BODIES - - def assert_school_loaded(self, data): - school = Organization.objects.get(uuid=UUID(data['uuid']), partner=self.partner) - expected_values = { - 'key': data['title'], - 'name': data['field_school_name'], - 'description': self.loader.clean_html(data['field_school_description']['value']), - 'logo_image_url': data['field_school_image_logo']['url'], - 'banner_image_url': data['field_school_image_banner']['url'], - 'marketing_url_path': 'school/' + data['field_school_url_slug'], - } - - for field, value in expected_values.items(): - self.assertEqual(getattr(school, field), value) - - self.assertEqual(sorted(school.tags.names()), ['charter', 'founder']) - - @responses.activate - def test_ingest(self): - self.mock_login_response() - schools = self.mock_api() - - self.loader.ingest() - - for school in schools: - self.assert_school_loaded(school) - - # If the key of an organization changes, the data loader should continue updating the - # organization by matching on the UUID. - school = Organization.objects.get(key='MITx', partner=self.partner) - # NOTE (CCB): As an MIT alum, this makes me feel dirty. IHTFT(est)! - modified_key = 'MassTechX' - school.key = modified_key - school.save() - - count = Organization.objects.count() - self.loader.ingest() - school.refresh_from_db() - - assert Organization.objects.count() == count - assert school.key == modified_key - - -class SponsorMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixin, TestCase): - loader_class = SponsorMarketingSiteDataLoader - mocked_data = mock_data.MARKETING_SITE_API_SPONSOR_BODIES - - def assert_sponsor_loaded(self, data): - uuid = data['uuid'] - school = Organization.objects.get(uuid=uuid, partner=self.partner) - - body = (data['body'] or {}).get('value') - - if body: - body = self.loader.clean_html(body) - - expected_values = { - 'key': data['url'].split('/')[-1], - 'name': data['title'], - 'description': body, - 'logo_image_url': data['field_sponsorer_image']['url'], - } - - for field, value in expected_values.items(): - self.assertEqual(getattr(school, field), value) - - @responses.activate - def test_ingest(self): - self.mock_login_response() - sponsors = self.mock_api() - - self.loader.ingest() - - for sponsor in sponsors: - self.assert_sponsor_loaded(sponsor) - - -@ddt.ddt -class CourseMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixin, TestCase): - loader_class = CourseMarketingSiteDataLoader - mocked_data = mock_data.UNIQUE_MARKETING_SITE_API_COURSE_BODIES - - def _get_uuids(self, items): - return [item['uuid'] for item in items] - - def mock_api(self): - bodies = super().mock_api() - - data_map = { - factories.SubjectFactory: 'field_course_subject', - factories.OrganizationFactory: 'field_course_school_node', - factories.PersonFactory: 'field_course_staff', - } - - for factory, field in data_map.items(): - uuids = set() - - for body in bodies: - uuids.update(self._get_uuids(body.get(field, []))) - - for uuid in uuids: - factory(uuid=uuid, partner=self.partner) - - return bodies - - def test_get_language_tags_from_names(self): - names = ('English', '中文', None) - expected = list(LanguageTag.objects.filter(code__in=('en-us', 'zh-cmn'))) - self.assertEqual(list(self.loader.get_language_tags_from_names(names)), expected) - - def test_get_level_type(self): - self.assertIsNone(self.loader.get_level_type(None)) - - name = 'Advanced' - self.assertEqual(self.loader.get_level_type(name).name, name) - - def test_get_extra_description(self): - self.assertIsNone(self.loader.get_extra_description({})) - - extra_description_raw = { - 'field_course_extra_desc_title': 'null', - 'field_course_extra_description': {} - } - - extra_description = self.loader.get_extra_description(extra_description_raw) - self.assertIsNone(extra_description) - - title = 'additional' - description = 'promo' - extra_description_raw = { - 'field_course_extra_desc_title': title, - 'field_course_extra_description': { - 'value': description - } - } - extra_description = self.loader.get_extra_description(extra_description_raw) - self.assertEqual(extra_description.title, title) - self.assertEqual(extra_description.description, description) - - @ddt.unpack - @ddt.data( - ('0', CourseRunStatus.Unpublished), - ('1', CourseRunStatus.Published), - ) - def test_get_course_run_status(self, marketing_site_status, expected): - data = {'status': marketing_site_status} - self.assertEqual(self.loader.get_course_run_status(data), expected) - - @ddt.data( - (True, True), - ('foo', False), - ('', False), - (None, False), - ) - @ddt.unpack - def test_get_hidden(self, hidden, expected): - """Verify that the get_hidden method returns the correct Boolean value.""" - data = {'field_couse_is_hidden': hidden} - self.assertEqual(self.loader.get_hidden(data), expected) - - @ddt.data( - (None, None, None), - ('Browse at your own pace.', None, None), - ('1.5 - 3.5 hours/week', None, None), - ('8 hours/week', None, 8), - ('2.5-5 hours.', None, 5), - ('5+ hours per week', None, 5), - ('3 horas por semana', None, 3), - ('1 - 1.5 hours per week', None, 1), - ('6 hours of video/300 multiple choice questions', None, 6), - ('6 to 9 hours/week', 6, 9), - ('4-6 hours per week', 4, 6), - ('About 5-12 hrs/week.', 5, 12), - ('4 - 8 hours/week | 小时/周', 4, 8), - ('6 horas/semana, 6 hours/week', 6, 6), - ('Estimated effort: 4–5 hours per week.', 4, 5), - ('4-6 hours per week depending on the background of the student.', 4, 6), - ('每周 2-3 小时 | 2-3 hours per week', None, None), - ('Part 1: 3 hours; Part 2: 4 hours; Part 3: 2 hours', None, None), - ('From 10 - 60 minutes, or as much time as you want.', None, None), - ('3-4 hours per unit (recommended pace: 1 unit per week)', None, None), - ('5-8 hours/week; 2-3 hours for lectures; 3-5 hours for homework/self-study', None, None), - ) - @ddt.unpack - def test_get_min_max_effort_per_week(self, course_effort_string, expected_min_effort, expected_max_effort): - """ - Verify that the method `get_min_max_effort_per_week` correctly parses - most of the the effort values which have specific format and maps them - to min effort and max effort values. - """ - data = {'field_course_effort': course_effort_string} - min_effort, max_effort = self.loader.get_min_max_effort_per_week(data) - self.assertEqual(min_effort, expected_min_effort) - self.assertEqual(max_effort, expected_max_effort) - - def test_get_hidden_missing(self): - """Verify that the get_hidden method can cope with a missing field.""" - self.assertEqual(self.loader.get_hidden({}), False) - - @ddt.data( - {'field_course_body': {'value': 'Test'}}, - {'field_course_description': {'value': 'Test'}}, - {'field_course_description': {'value': 'Test2'}, 'field_course_body': {'value': 'Test'}}, - ) - def test_get_description(self, data): - self.assertEqual(self.loader.get_description(data), 'Test') - - def test_get_video(self): - """Verify that method gets video from any of 'field_course_video' or 'field_product_video.'""" - image_url = 'https://example.com/image.jpg' - video_url = 'https://example.com/video.mp4' - data = { - 'field_course_video': {'url': video_url}, - 'field_course_image_featured_card': {'url': image_url} - } - video = self.loader.get_video(data) - self.assertEqual(video.src, video_url) - self.assertEqual(video.image.src, image_url) - - data = { - 'field_product_video': {'url': video_url}, - 'field_course_image_featured_card': {'url': image_url} - } - video = self.loader.get_video(data) - self.assertEqual(video.src, video_url) - self.assertEqual(video.image.src, image_url) - - self.assertIsNone(self.loader.get_video({})) - - @ddt.unpack - @ddt.data( - (True, CourseRunPacing.Self), - (False, CourseRunPacing.Instructor), - (None, CourseRunPacing.Instructor), - ('', CourseRunPacing.Instructor), - ) - def test_get_pacing_type(self, data_value, expected_pacing_type): - data = {'field_course_self_paced': data_value} - self.assertEqual(self.loader.get_pacing_type(data), expected_pacing_type) - - @ddt.data( - {'field_course_id': ''}, - {'field_course_id': 'EPtestx'}, - {'field_course_id': 'Paradigms-comput'}, - {'field_course_id': 'Bio Course ID'} - ) - def test_process_node(self, data): - with LogCapture(marketing_site_logger.name) as lc: - self.loader.process_node(data) - lc.check( - ( - marketing_site_logger.name, - 'ERROR', - 'Invalid course key [{}].'.format(data['field_course_id']) - ) - ) - - def assert_course_loaded(self, data): - course = self._get_course(data) - - expected_values = { - 'title': self.loader.clean_html(data['field_course_course_title']['value']), - 'number': data['field_course_code'], - 'full_description': self.loader.get_description(data), - 'video': self.loader.get_video(data), - 'short_description': self.loader.clean_html(data['field_course_sub_title_long']['value']), - 'level_type': self.loader.get_level_type(data['field_course_level']), - 'card_image_url': (data.get('field_course_image_promoted') or {}).get('url'), - 'outcome': (data.get('field_course_what_u_will_learn', {}) or {}).get('value'), - 'syllabus_raw': (data.get('field_course_syllabus', {}) or {}).get('value'), - 'prerequisites_raw': (data.get('field_course_prerequisites', {}) or {}).get('value'), - } - - for field, value in expected_values.items(): - self.assertEqual(getattr(course, field), value) - - # Verify the subject and authoring organization relationships - data_map = { - course.subjects: 'field_course_subject', - course.authoring_organizations: 'field_course_school_node', - } - - self.validate_relationships(data, data_map) - - def assert_no_override_unpublished_course_fields(self, data): - course = self._get_course(data) - - expected_values = { - 'title': data['field_course_course_title']['value'], - 'full_description': self.loader.get_description(data), - 'short_description': self.loader.clean_html(data['field_course_sub_title_short']), - 'card_image_url': (data.get('field_course_image_promoted') or {}).get('url'), - } - - for field, value in expected_values.items(): - self.assertNotEqual(getattr(course, field), value) - - def validate_relationships(self, data, data_map): - for relationship, field in data_map.items(): - expected = sorted(self._get_uuids(data.get(field, []))) - actual = list(relationship.order_by('uuid').values_list('uuid', flat=True)) - actual = [str(item) for item in actual] - self.assertListEqual(actual, expected, 'Data not properly pulled from {}'.format(field)) - - def assert_course_run_loaded(self, data): - course = self._get_course(data) - course_run = course.course_runs.get(uuid=data['uuid']) - language_names = [language['name'] for language in data['field_course_languages']] - language = self.loader.get_language_tags_from_names(language_names).first() - start = data.get('field_course_start_date') - start = datetime.datetime.fromtimestamp(int(start), tz=pytz.UTC) if start else None - end = data.get('field_course_end_date') - end = datetime.datetime.fromtimestamp(int(end), tz=pytz.UTC) if end else None - weeks_to_complete = data.get('field_course_required_weeks') - - expected_values = { - 'key': data['field_course_id'], - 'title_override': self.loader.clean_html(data['field_course_course_title']['value']), - 'language': language, - 'card_image_url': (data.get('field_course_image_promoted') or {}).get('url'), - 'status': self.loader.get_course_run_status(data), - 'start': start, - 'pacing_type': self.loader.get_pacing_type(data), - 'hidden': self.loader.get_hidden(data), - 'mobile_available': data['field_course_enrollment_mobile'] or False, - 'short_description_override': self.loader.clean_html(data['field_course_sub_title_long']['value']) or None, - 'outcome': (data.get('field_course_what_u_will_learn', {}) or {}).get('value'), - } - - if weeks_to_complete: - expected_values['weeks_to_complete'] = int(weeks_to_complete) - elif start and end: - weeks_to_complete = rrule.rrule(rrule.WEEKLY, dtstart=start, until=end).count() - expected_values['weeks_to_complete'] = int(weeks_to_complete) - - for field, value in expected_values.items(): - self.assertEqual(getattr(course_run, field), value) - - # Verify the staff relationship - self.validate_relationships(data, {course_run.staff: 'field_course_staff'}) - - language_names = [language['name'] for language in data['field_course_video_locale_lang']] - expected_transcript_languages = self.loader.get_language_tags_from_names(language_names) - self.assertEqual(list(course_run.transcript_languages.all()), list(expected_transcript_languages)) - - return course_run - - def _get_course(self, data): - course_run_key = CourseKey.from_string(data['field_course_id']) - return Course.objects.get(key=self.loader.get_course_key_from_course_run_key(course_run_key), - partner=self.partner) - - @responses.activate - def test_ingest(self): - self.mock_login_response() - data = self.mock_api() - - self.loader.ingest() - - for datum in data: - self.assert_course_run_loaded(datum) - self.assert_course_loaded(datum) - - @responses.activate - def test_update_does_not_change_key(self): - # First make a course run & course with different keys than will be ingested - course = factories.CourseFactory(key='HarvardX/Previous', number='Previous', title='Foo') # bad course code - factories.CourseRunFactory(key='HarvardX/PH207x/2012_Fall', course=course) - - # Now ingest - self.mock_login_response() - self.mock_api() - self.loader.ingest() - - # Test that key/number didn't change (even though other fields did) - course.refresh_from_db() - self.assertTrue(course.title.startswith('Health in Numbers:')) - self.assertEqual(course.key, 'HarvardX/Previous') - self.assertEqual(course.number, 'Previous') - - @responses.activate - def test_course_run_creation(self): - self.mocked_data = [ - mock_data.ORIGINAL_MARKETING_SITE_API_COURSE_BODY, - mock_data.NEW_RUN_MARKETING_SITE_API_COURSE_BODY, - mock_data.UPDATED_MARKETING_SITE_API_COURSE_BODY, - ] - self.mock_login_response() - self.mock_api() - - self.loader.ingest() - - course_run = self.assert_course_run_loaded(mock_data.UPDATED_MARKETING_SITE_API_COURSE_BODY) - self.assert_course_loaded(mock_data.UPDATED_MARKETING_SITE_API_COURSE_BODY) - self.assertTrue(course_run.canonical_for_course) - - course_run = self.assert_course_run_loaded(mock_data.NEW_RUN_MARKETING_SITE_API_COURSE_BODY) - course = course_run.course - - new_run_title = mock_data.NEW_RUN_MARKETING_SITE_API_COURSE_BODY['field_course_course_title']['value'] - self.assertNotEqual(course.title, new_run_title) - with self.assertRaises(AttributeError): - course_run.canonical_for_course # pylint: disable=pointless-statement - - @responses.activate - def test_discovery_created_course_run(self): - self.mocked_data = [ - mock_data.DISCOVERY_CREATED_MARKETING_SITE_API_COURSE_BODY - ] - - self.mock_login_response() - self.mock_api() - - with LogCapture(marketing_site_logger.name) as lc: - self.loader.ingest() - lc.check( - ( - marketing_site_logger.name, - 'INFO', - 'Course_run [{}] has uuid [{}] already on course about page. No need to ingest'.format( - mock_data.DISCOVERY_CREATED_MARKETING_SITE_API_COURSE_BODY['field_course_id'], - mock_data.DISCOVERY_CREATED_MARKETING_SITE_API_COURSE_BODY['field_course_uuid']) - ) - ) - - @responses.activate - def test_discovery_unpublished_course_run(self): - self.mocked_data = [ - mock_data.UPDATED_MARKETING_SITE_API_COURSE_BODY, - mock_data.ORIGINAL_MARKETING_SITE_API_COURSE_BODY - ] - - self.mock_login_response() - self.mock_api() - - with LogCapture(marketing_site_logger.name) as lc: - self.loader.ingest() - lc.check( - ( - marketing_site_logger.name, - 'INFO', - 'Processed course run with UUID [{}].'.format( - mock_data.UPDATED_MARKETING_SITE_API_COURSE_BODY['uuid']) - ), - ( - marketing_site_logger.name, - 'INFO', - 'Course_run [{}] is unpublished, so the course [{}] related is not updated.'.format( - mock_data.ORIGINAL_MARKETING_SITE_API_COURSE_BODY['field_course_id'], - mock_data.ORIGINAL_MARKETING_SITE_API_COURSE_BODY['field_course_code']) - ) - ) diff --git a/course_discovery/apps/course_metadata/emails.py b/course_discovery/apps/course_metadata/emails.py new file mode 100644 index 0000000000..ab21a6ba1e --- /dev/null +++ b/course_discovery/apps/course_metadata/emails.py @@ -0,0 +1,297 @@ +import datetime +import logging +from urllib.parse import urljoin + +import dateutil.parser +from django.conf import settings +from django.core.mail.message import EmailMultiAlternatives +from django.template.loader import get_template +from django.utils.translation import ugettext as _ +from opaque_keys.edx.keys import CourseKey + +from course_discovery.apps.core.models import User +from course_discovery.apps.publisher.choices import InternalUserRole +from course_discovery.apps.publisher.constants import LEGAL_TEAM_GROUP_NAME +from course_discovery.apps.publisher.utils import is_email_notification_enabled + +logger = logging.getLogger(__name__) + + +def log_missing_project_coordinator(key, org, template_name): + """ Print a log message about an unregistered project coordinator. + + This is separated out to avoid duplicating strings in multiple places. Checks for why we might be missing the + PC and then logs the correct error message. + + Arguments: + key (str): The course key for this email + org (Object): the relevant Organization object for the course run's course + template_name (str): name of the email template this was for, used in the log message + """ + if not org: + logger.info( + _('Not sending notification email for template {template} because no organization is defined ' + 'for course {course}').format(template=template_name, course=key) + ) + else: + logger.info( + _('Not sending notification email for template {template} because no project coordinator is defined ' + 'for organization {org}').format(template=template_name, org=org.key) + ) + + +def get_project_coordinator(org): + """ Get the registered project coordinator for an organization. + + Only returns the first one. Technically the database supports multiple. But in practice, we only use one. + Requires a OrganizationUserRole to be set up first. + + Arguments: + org (Object): Organization object + + Returns: + Object: a User object or None if no project coordinator is registered + """ + # Model imports here to avoid a circular import + from course_discovery.apps.publisher.models import OrganizationUserRole # pylint: disable=import-outside-toplevel + + if not org: + return None + + role = OrganizationUserRole.objects.filter(organization=org, + role=InternalUserRole.ProjectCoordinator).first() + return role.user if role else None + + +def send_email(template_name, subject, to_users, recipient_name, + course_run=None, course=None, context=None, project_coordinator=None): + """ Send an email template out to the given users with some standard context variables. + + Arguments: + template_name (str): path to template without filename extension + subject (str): subject line for the email + to_users (list(Object)): a list of User objects to send the email to, if they have notifications enabled + recipient_name (str): a string to use to greet the user (use a team name if multiple users) + course_run (Object): CourseRun object + course (Object): Course object + context (dict): additional context for the template + project_coordinator (Object): optional optimization if you have the PC User already, to prevent a lookup + """ + course = course or course_run.course + partner = course.partner + org = course.authoring_organizations.first() + project_coordinator = project_coordinator or get_project_coordinator(org) + if not project_coordinator: + log_missing_project_coordinator(course.key, org, template_name) + return + + publisher_url = partner.publisher_url + if not publisher_url: + logger.info( + _('Not sending notification email for template {template} because no publisher URL is defined ' + 'for partner {partner}').format(template=template_name, partner=partner.short_code) + ) + return + + studio_url = partner.studio_url + if not studio_url: + logger.info( + _('Not sending notification email for template {template} because no studio URL is defined ' + 'for partner {partner}').format(template=template_name, partner=partner.short_code) + ) + return + + base_context = {} + if course_run: + run_studio_url = urljoin(studio_url, 'course/{}'.format(course_run.key)) + review_url = urljoin(publisher_url, 'courses/{}'.format(course.uuid)) + base_context.update({ + 'course_name': course_run.title, + 'course_key': course_run.key, + 'course_run_number': CourseKey.from_string(course_run.key).run, + 'recipient_name': recipient_name, + 'platform_name': settings.PLATFORM_NAME, + 'org_name': org.name, + 'contact_us_email': project_coordinator.email, + 'course_page_url': review_url, + 'studio_url': run_studio_url, + 'preview_url': course_run.marketing_url, + }) + elif course: + base_context.update({ + 'course_name': course.title, + 'course_key': course.key, + 'recipient_name': recipient_name, + 'platform_name': settings.PLATFORM_NAME, + 'org_name': org.name, + 'contact_us_email': project_coordinator.email, + }) + if context: + base_context.update(context) + + txt_template = template_name + '.txt' + html_template = template_name + '.html' + template = get_template(txt_template) + plain_content = template.render(base_context) + template = get_template(html_template) + html_content = template.render(base_context) + + to_addresses = [u.email for u in to_users if is_email_notification_enabled(u)] + if not to_addresses: + return + + email_msg = EmailMultiAlternatives( + subject, plain_content, settings.PUBLISHER_FROM_EMAIL, to_addresses + ) + email_msg.attach_alternative(html_content, 'text/html') + + try: + email_msg.send() + except Exception: # pylint: disable=broad-except + logger.exception('Failed to send email notification for template %s, with subject "%s"', + template_name, subject) + + +def send_email_to_legal(course_run, template_name, subject, context=None): + """ Send a specific email template to all legal team members for a course run. + + Arguments: + course_run (Object): CourseRun object + template_name (str): path to template without filename extension + subject (str): subject line for the email + context (dict): additional context for the template + """ + to_users = User.objects.filter(groups__name=LEGAL_TEAM_GROUP_NAME) + send_email(template_name, subject, to_users, _('legal team'), context=context, course_run=course_run) + + +def send_email_to_project_coordinator(course_run, template_name, subject, context=None): + """ Send a specific email template to the project coordinator for a course run. + + Arguments: + course_run (Object): CourseRun object + template_name (str): path to template without filename extension + subject (str): subject line for the email + context (dict): additional context for the template + """ + org = course_run.course.authoring_organizations.first() + project_coordinator = get_project_coordinator(org) + if not project_coordinator: + log_missing_project_coordinator(course_run.course.key, org, template_name) + return + + recipient_name = project_coordinator.full_name or project_coordinator.username + send_email(template_name, subject, [project_coordinator], recipient_name, context=context, + project_coordinator=project_coordinator, course_run=course_run) + + +def send_email_to_editors(course_run, template_name, subject, context=None): + """ Send a specific email template to all editors for a course run. + + Arguments: + course_run (Object): CourseRun object + template_name (str): path to template without filename extension + subject (str): subject line for the email + context (dict): additional context for the template + """ + # Model imports here to avoid a circular import + from course_discovery.apps.course_metadata.models import CourseEditor # pylint: disable=import-outside-toplevel + + editors = CourseEditor.course_editors(course_run.course) + send_email(template_name, subject, editors, _('course team'), context=context, course_run=course_run) + + +def send_email_for_legal_review(course_run): + """ Send email when a course run is submitted for legal review. + + Arguments: + course_run (Object): CourseRun object + """ + subject = _('Legal review requested: {title}').format(title=course_run.title) + send_email_to_legal(course_run, 'course_metadata/email/legal_review', subject) + + +def send_email_for_internal_review(course_run): + """ Send email when a course run is submitted for internal review. + + Arguments: + course_run (Object): CourseRun object + """ + lms_admin_url = course_run.course.partner.lms_admin_url + restricted_url = lms_admin_url and (lms_admin_url.rstrip('/') + '/embargo/restrictedcourse/') + + subject = _('Review requested: {key} - {title}').format(title=course_run.title, key=course_run.key) + send_email_to_project_coordinator(course_run, 'course_metadata/email/internal_review', subject, context={ + 'restricted_admin_url': restricted_url, + }) + + +def send_email_for_reviewed(course_run): + """ Send email when a course run is successfully reviewed. + + Arguments: + course_run (Object): CourseRun object + """ + subject = _('Review complete: {title}').format(title=course_run.title) + go_live = course_run.go_live_date + if go_live and go_live < datetime.datetime.now(datetime.timezone.utc): + go_live = None + send_email_to_editors(course_run, 'course_metadata/email/reviewed', subject, context={ + 'go_live_date': go_live and go_live.strftime('%x'), + }) + + +def send_email_for_go_live(course_run): + """ Send email when a course run has successfully gone live and is now publicly available. + + Arguments: + course_run (Object): CourseRun object + """ + # We internally use the phrase "go live", but to users, we say "published" + subject = _('Published: {title}').format(title=course_run.title) + send_email_to_editors(course_run, 'course_metadata/email/go_live', subject) + + # PCs like to see the key too + subject = _('Published: {key} - {title}').format(title=course_run.title, key=course_run.key) + send_email_to_project_coordinator(course_run, 'course_metadata/email/go_live', subject) + + +def send_email_for_comment(comment, course, author): + """ Send the emails for a comment. + + Arguments: + comment (Dict): Comment dict returned from salesforce.py + course (Course): Course object for the comment + author (User): User object who made the post request + """ + # Model imports here to avoid a circular import + from course_discovery.apps.course_metadata.models import CourseEditor # pylint: disable=import-outside-toplevel + + subject = _('Comment added: {title}').format( + title=course.title + ) + + org = course.authoring_organizations.first() + project_coordinator = get_project_coordinator(org) + recipients = list(CourseEditor.course_editors(course)) + if project_coordinator: + recipients.append(project_coordinator) + + # remove email of comment owner if exists + recipients = filter(lambda x: x.email != author.email, recipients) + + context = { + 'comment_message': comment.get('comment'), + 'user_name': author.username, + 'course_name': course.title, + 'comment_date': dateutil.parser.parse(comment.get('created')), + 'page_url': '{url}/courses/{path}'.format( + url=course.partner.publisher_url.strip('/'), path=course.uuid + ) + } + + try: + send_email('course_metadata/email/comment', subject, recipients, '', + course=course, context=context, project_coordinator=project_coordinator) + except Exception: # pylint: disable=broad-except + logger.exception('Failed to send email notifications for comment on course %s', course.uuid) diff --git a/course_discovery/apps/course_metadata/exceptions.py b/course_discovery/apps/course_metadata/exceptions.py index 554eae0dbf..8eab9355aa 100644 --- a/course_discovery/apps/course_metadata/exceptions.py +++ b/course_discovery/apps/course_metadata/exceptions.py @@ -1,3 +1,7 @@ +class EcommerceSiteAPIClientException(Exception): + pass + + class MarketingSiteAPIClientException(Exception): pass @@ -43,5 +47,5 @@ def __init__(self, message): self.message = '{exception_msg} {suffix}'.format(exception_msg=message, suffix=suffix) -class RedirectCreateError(MarketingSitePublisherException): +class UnpublishError(MarketingSitePublisherException): pass diff --git a/course_discovery/apps/course_metadata/fields.py b/course_discovery/apps/course_metadata/fields.py new file mode 100644 index 0000000000..9de0ecca1b --- /dev/null +++ b/course_discovery/apps/course_metadata/fields.py @@ -0,0 +1,18 @@ +from django.db import models + +from course_discovery.apps.course_metadata.validators import validate_html + + +class HtmlField(models.TextField): + def __init__(self, **kwargs): + validators = set(kwargs.pop('validators', [])) + validators.add(validate_html) + super().__init__(validators=validators, **kwargs) + + +class NullHtmlField(HtmlField): + def __init__(self, **kwargs): + kwargs.setdefault('blank', True) + kwargs.setdefault('default', None) + kwargs.setdefault('null', True) + super().__init__(**kwargs) diff --git a/course_discovery/apps/course_metadata/forms.py b/course_discovery/apps/course_metadata/forms.py index 0ee1045bf9..13e20a5703 100644 --- a/course_discovery/apps/course_metadata/forms.py +++ b/course_discovery/apps/course_metadata/forms.py @@ -1,4 +1,3 @@ -from dal import autocomplete from django import forms from django.core.exceptions import ValidationError from django.forms.utils import ErrorList @@ -6,22 +5,7 @@ from course_discovery.apps.course_metadata.choices import ProgramStatus from course_discovery.apps.course_metadata.models import Course, CourseRun, Pathway, Program - - -def filter_choices_to_render_with_order_preserved(self, selected_choices): - """ - Preserves ordering of selected_choices when creating the choices queryset. - - See https://codybonney.com/creating-a-queryset-from-a-list-while-preserving-order-using-django. - - django-autocomplete's definition of this method on QuerySetSelectMixin loads selected choices in - order of primary key instead of the order in which the choices are actually stored. - """ - clauses = ' '.join(['WHEN id={} THEN {}'.format(pk, i) for i, pk in enumerate(selected_choices)]) - ordering = 'CASE {} END'.format(clauses) - self.choices.queryset = self.choices.queryset.filter( - pk__in=[c for c in selected_choices if c] - ).extra(select={'ordering': ordering}, order_by=('ordering',)) +from course_discovery.apps.course_metadata.widgets import SortedModelSelect2Multiple class ProgramAdminForm(forms.ModelForm): @@ -29,32 +13,29 @@ class Meta: model = Program fields = '__all__' - # Monkey patch filter_choices_to_render with our own definition which preserves ordering. - autocomplete.ModelSelect2Multiple.filter_choices_to_render = filter_choices_to_render_with_order_preserved - widgets = { - 'courses': autocomplete.ModelSelect2Multiple( + 'courses': SortedModelSelect2Multiple( url='admin_metadata:course-autocomplete', attrs={ 'data-minimum-input-length': 3, 'class': 'sortable-select', }, ), - 'authoring_organizations': autocomplete.ModelSelect2Multiple( + 'authoring_organizations': SortedModelSelect2Multiple( url='admin_metadata:organisation-autocomplete', attrs={ 'data-minimum-input-length': 3, 'class': 'sortable-select', } ), - 'credit_backing_organizations': autocomplete.ModelSelect2Multiple( + 'credit_backing_organizations': SortedModelSelect2Multiple( url='admin_metadata:organisation-autocomplete', attrs={ 'data-minimum-input-length': 3, 'class': 'sortable-select', } ), - 'instructor_ordering': autocomplete.ModelSelect2Multiple( + 'instructor_ordering': SortedModelSelect2Multiple( url='admin_metadata:person-autocomplete', attrs={ 'data-minimum-input-length': 3, @@ -106,14 +87,7 @@ class CourseAdminForm(forms.ModelForm): class Meta: model = Course fields = '__all__' - widgets = { - 'canonical_course_run': autocomplete.ModelSelect2( - url='admin_metadata:course-run-autocomplete', - attrs={ - 'data-minimum-input-length': 3, - } - ), - } + exclude = ('slug', 'url_slug', ) class PathwayAdminForm(forms.ModelForm): diff --git a/course_discovery/apps/course_metadata/index.py b/course_discovery/apps/course_metadata/index.py new file mode 100644 index 0000000000..2ac01a8755 --- /dev/null +++ b/course_discovery/apps/course_metadata/index.py @@ -0,0 +1,161 @@ + +from algoliasearch_django import AlgoliaIndex, register + +from course_discovery.apps.course_metadata.algolia_models import ( + AlgoliaProxyCourse, AlgoliaProxyProduct, AlgoliaProxyProgram, SearchDefaultResultsConfiguration +) + + +class BaseProductIndex(AlgoliaIndex): + language = None + + # Bit of a hack: Override get_queryset to return all wrapped versions of all courses and programs rather than an + # actual queryset to get around the fact that courses and programs have different fields and therefore cannot be + # combined in a union of querysets. AlgoliaIndex only uses get_queryset as an iterable, so an array works as well. + + def get_queryset(self): # pragma: no cover + if not self.language: + raise Exception('Cannot update Algolia index \'{index_name}\'. No language set'.format( + index_name=self.index_name)) + qs1 = [AlgoliaProxyProduct(course, self.language) for course in AlgoliaProxyCourse.objects.all()] + qs2 = [AlgoliaProxyProduct(program, self.language) for program in AlgoliaProxyProgram.objects.all()] + return qs1 + qs2 + + def generate_empty_query_rule(self, rule_object_id, product_type, results): + promoted_results = [{'objectID': '{type}-{uuid}'.format(type=product_type, uuid=result.uuid), + 'position': index} for index, result in enumerate(results)] + return { + 'objectID': rule_object_id, + 'condition': { + 'pattern': '', + 'anchoring': 'is', + 'alternatives': False + }, + 'consequence': { + 'promote': promoted_results, + 'filterPromotes': True + } + } + + def get_rules(self): + rules_config = SearchDefaultResultsConfiguration.objects.filter(index_name=self.index_name).first() + if rules_config: + course_rule = self.generate_empty_query_rule('course-empty-query-rule', 'course', + rules_config.courses.all()) + program_rule = self.generate_empty_query_rule('program-empty-query-rule', 'program', + rules_config.programs.all()) + return [course_rule, program_rule] + return [] + + # Rules aren't automatically set in regular reindex_all, so set them explicitly + def reindex_all(self, batch_size=1000): + super().reindex_all(batch_size) + self._AlgoliaIndex__index.replace_all_rules(self.get_rules()) # pylint: disable=no-member + + +class EnglishProductIndex(BaseProductIndex): + language = 'en' + + search_fields = (('product_title', 'title'), ('partner_names', 'partner'), 'partner_keys', + 'primary_description', 'secondary_description', 'tertiary_description') + facet_fields = (('availability_level', 'availability'), ('subject_names', 'subject'), ('levels', 'level'), + ('active_languages', 'language'), ('product_type', 'product'), ('program_types', 'program_type'), + ('staff_slugs', 'staff')) + ranking_fields = ('availability_rank', ('product_recent_enrollment_count', 'recent_enrollment_count')) + result_fields = (('product_marketing_url', 'marketing_url'), ('product_card_image_url', 'card_image_url'), + ('product_uuid', 'uuid'), 'active_run_key', 'active_run_start', 'active_run_type', 'owners', + 'course_titles') + # Algolia needs this + object_id_field = (('custom_object_id', 'objectID'), ) + fields = search_fields + facet_fields + ranking_fields + result_fields + object_id_field + settings = { + 'searchableAttributes': [ + 'unordered(title)', # AG best practice: position of the search term in plain text fields doesn't matter + 'partner', + 'partner_keys', + 'unordered(primary_description)', + 'unordered(secondary_description)', + 'unordered(tertiary_description)', + ], + 'attributesForFaceting': ['partner', 'availability', 'subject', 'level', 'language', 'product', 'program_type', + 'filterOnly(staff)'], + 'customRanking': ['asc(availability_rank)', 'desc(recent_enrollment_count)'] + } + index_name = 'product' + should_index = 'should_index' + + +class SpanishProductIndex(BaseProductIndex): + language = 'es_419' + + search_fields = (('product_title', 'title'), ('partner_names', 'partner'), 'partner_keys', + 'primary_description', 'secondary_description', 'tertiary_description') + facet_fields = (('availability_level', 'availability'), ('subject_names', 'subject'), ('levels', 'level'), + ('active_languages', 'language'), ('product_type', 'product'), ('program_types', 'program_type'), + ('staff_slugs', 'staff')) + ranking_fields = ('availability_rank', ('product_recent_enrollment_count', 'recent_enrollment_count'), + 'promoted_in_spanish_index') + result_fields = (('product_marketing_url', 'marketing_url'), ('product_card_image_url', 'card_image_url'), + ('product_uuid', 'uuid'), 'active_run_key', 'active_run_start', 'active_run_type', 'owners', + 'course_titles') + # Algolia uses objectID as unique identifier. Can't use straight uuids because a program and a course could + # have the same one, so we add 'course' or 'program' as a prefix + object_id_field = (('custom_object_id', 'objectID'), ) + fields = search_fields + facet_fields + ranking_fields + result_fields + object_id_field + settings = { + 'searchableAttributes': [ + 'unordered(title)', # Algolia best practice: position of the term in plain text fields doesn't matter + 'partner', + 'partner_keys', + 'unordered(primary_description)', + 'unordered(secondary_description)', + 'unordered(tertiary_description)', + ], + 'attributesForFaceting': ['partner', 'availability', 'subject', 'level', 'language', 'product', 'program_type', + 'filterOnly(staff)'], + 'customRanking': ['desc(promoted_in_spanish_index)', 'asc(availability_rank)', 'desc(recent_enrollment_count)'] + } + index_name = 'spanish_product' + should_index = 'should_index' + + +# Standard algoliasearch_django pattern for populating 2 indices with one model. These are the signatures and structure +# AlgoliaIndex expects, so ignore warnings +# pylint: disable=no-member,super-init-not-called,dangerous-default-value +class ProductMetaIndex(AlgoliaIndex): + model_index = [] + + def __init__(self, model, client, settings): + self.model_index = [ + EnglishProductIndex(model, client, settings), + SpanishProductIndex(model, client, settings), + ] + + def update_obj_index(self, instance): + for indexer in self.model_index: + indexer.update_obj_index(instance) + + def delete_obj_index(self, instance): + for indexer in self.model_index: + indexer.delete_obj_index(instance) + + def raw_search(self, query='', params={}): + res = {} + for indexer in self.model_index: + res[indexer.name] = indexer.raw_search(query, params) + return res + + def set_settings(self): + for indexer in self.model_index: + indexer.set_settings() + + def clear_index(self): + for indexer in self.model_index: + indexer.clear_index() + + def reindex_all(self, batch_size=1000): + for indexer in self.model_index: + indexer.reindex_all(batch_size) + + +register(AlgoliaProxyProduct, index_cls=ProductMetaIndex) diff --git a/course_discovery/apps/course_metadata/lookups.py b/course_discovery/apps/course_metadata/lookups.py index 87f86fd270..a8388d2eec 100644 --- a/course_discovery/apps/course_metadata/lookups.py +++ b/course_discovery/apps/course_metadata/lookups.py @@ -3,14 +3,13 @@ from dal import autocomplete from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q -from django.template.loader import render_to_string -from .models import Course, CourseRun, Organization, Person +from .models import Course, CourseRun, Organization, Person, Program class CourseAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): - if self.request.user.is_authenticated() and self.request.user.is_staff: + if self.request.user.is_authenticated and self.request.user.is_staff: qs = Course.objects.all() if self.q: qs = qs.filter(Q(key__icontains=self.q) | Q(title__icontains=self.q)) @@ -22,8 +21,13 @@ def get_queryset(self): class CourseRunAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): - if self.request.user.is_authenticated() and self.request.user.is_staff: + if self.request.user.is_authenticated and self.request.user.is_staff: qs = CourseRun.objects.all().select_related('course') + + filter_by_course = self.forwarded.get('course', None) + if filter_by_course: + qs = qs.filter(course=filter_by_course) + if self.q: qs = qs.filter(Q(key__icontains=self.q) | Q(course__title__icontains=self.q)) @@ -34,7 +38,7 @@ def get_queryset(self): class OrganizationAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): - if self.request.user.is_authenticated() and self.request.user.is_staff: + if self.request.user.is_authenticated and self.request.user.is_staff: qs = Organization.objects.all() if self.q: @@ -45,6 +49,19 @@ def get_queryset(self): return [] +class ProgramAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + if self.request.user.is_authenticated and self.request.user.is_staff: + qs = Program.objects.all() + + if self.q: + qs = qs.filter(title__icontains=self.q) + + return qs + + return [] + + class PersonAutocomplete(LoginRequiredMixin, autocomplete.Select2QuerySetView): def get_queryset(self): words = self.q and self.q.split() @@ -53,6 +70,7 @@ def get_queryset(self): # Match each word separately queryset = Person.objects.all() + for word in words: # Progressively filter the same queryset - every word must match something queryset = queryset.filter(Q(given_name__icontains=word) | Q(family_name__icontains=word)) @@ -66,20 +84,3 @@ def get_queryset(self): pass return queryset - - def get_result_label(self, result): - http_referer = self.request.META.get('HTTP_REFERER') - if http_referer and '/admin/' in http_referer: - return super(PersonAutocomplete, self).get_result_label(result) - else: - context = { - 'uuid': result.uuid, - 'profile_image': result.get_profile_image_url, - 'full_name': result.full_name, - 'position': result.position if hasattr(result, 'position') else None, - 'organization_id': result.position.organization_id if hasattr(result, 'position') else None, - 'can_edit_instructor': not result.get_profile_image_url, - - } - - return render_to_string('publisher/_personLookup.html', context=context) diff --git a/course_discovery/apps/course_metadata/management/commands/add_tag_to_courses.py b/course_discovery/apps/course_metadata/management/commands/add_tag_to_courses.py new file mode 100644 index 0000000000..d363015109 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/add_tag_to_courses.py @@ -0,0 +1,38 @@ +from django.core.management import BaseCommand, CommandError +from django.utils.translation import ugettext as _ + +from course_discovery.apps.course_metadata.models import Course, TagCourseUuidsConfig + + +class Command(BaseCommand): + """ Management command to add a single tag to a list of courses specified by uuid. + Useful for tagging courses to be brought into prospectus, eg + ./manage.py add_tag_to_courses myProspectusTag course0uuid course1uuid ... """ + + help = 'Add single tag to a list of courses specified by uuid' + + def add_arguments(self, parser): + parser.add_argument('tag', nargs='?', help=_("Tag to add to courses")) + parser.add_argument('courses', nargs="*", help=_('UUIDs of courses to tag')) + parser.add_argument('--args-from-database', action='store_true', + help=_('Use arguments from the TagCourseUUIDsConfig model instead of the command line.') + ) + + def handle(self, *args, **options): + if options['args_from_database']: + optionsDict = self.get_args_from_database() + self.add_tag_to_courses(optionsDict['tag'], optionsDict['courses'].split()) + else: + if options['tag'] is None or options['courses'] is None or options['courses'] == []: + raise CommandError(_('Missing required arguments')) + self.add_tag_to_courses(options['tag'], options['courses']) + + def add_tag_to_courses(self, tag, courseUUIDs): + courses = Course.objects.filter(uuid__in=courseUUIDs) + for course in courses: + course.topics.add(tag) + course.save() + + def get_args_from_database(self): + config = TagCourseUuidsConfig.get_solo() + return {"tag": config.tag, "courses": config.course_uuids} diff --git a/course_discovery/apps/course_metadata/management/commands/backfill_course_run_slugs_to_courses.py b/course_discovery/apps/course_metadata/management/commands/backfill_course_run_slugs_to_courses.py new file mode 100644 index 0000000000..477cce982f --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/backfill_course_run_slugs_to_courses.py @@ -0,0 +1,65 @@ +import logging + +from django.core.management import BaseCommand, CommandError + +from course_discovery.apps.course_metadata.models import ( + BackfillCourseRunSlugsConfig, CourseRun, CourseRunStatus, CourseUrlSlug +) + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ Management command to add redirects from course run slugs to courses""" + + help = 'Adds published course run slugs to courses if not already present' + + def add_arguments(self, parser): + parser.add_argument('--all', action='store_true', help='Add redirects from all course run slugs') + parser.add_argument('-uuids', nargs="*", help='Add redirects from all course run slugs for specific courses') + parser.add_argument('--args-from-database', action='store_true', + help=('Use arguments from the BackfillCourseRunSlugsConfig model instead of the ' + 'command line.') + ) + + def handle(self, *args, **options): + # using mutually exclusive argument groups in management commands is only supported in Django 2.2 + # so use XOR to check manually + if not bool(options['args_from_database']) ^ (bool(options['uuids']) ^ bool(options['all'])): + raise CommandError('Invalid arguments') + options_dict = options + if options_dict['args_from_database']: + options_dict = self.get_args_from_database() + self.add_redirects_from_course_runs(**options_dict) + + def add_course_run_redirect_to_parent_course(self, course_run): + if course_run.status != CourseRunStatus.Published or course_run.draft: + return + existing_slug = CourseUrlSlug.objects.filter(url_slug=course_run.slug, + partner=course_run.course.partner).first() + if existing_slug: + if existing_slug.course.uuid != course_run.course.uuid: + logger.warning( + 'Cannot add slug {slug} to course {uuid0}. Slug already belongs to course {uuid1}'.format( + slug=course_run.slug, + uuid0=course_run.course.uuid, + uuid1=existing_slug.course.uuid + ) + ) + return + + course_run.course.url_slug_history.create(url_slug=course_run.slug, course=course_run.course, + partner=course_run.course.partner) + + def add_redirects_from_course_runs(self, **kwargs): + if kwargs['uuids']: + for course_run in CourseRun.objects.filter(status=CourseRunStatus.Published, + course__uuid__in=kwargs['uuids']).all(): + self.add_course_run_redirect_to_parent_course(course_run) + else: + for course_run in CourseRun.objects.filter(status=CourseRunStatus.Published).all(): + self.add_course_run_redirect_to_parent_course(course_run) + + def get_args_from_database(self): + config = BackfillCourseRunSlugsConfig.get_solo() + return {"all": config.all, "uuids": config.uuids.split()} diff --git a/course_discovery/apps/course_metadata/management/commands/backpopulate_course_type.py b/course_discovery/apps/course_metadata/management/commands/backpopulate_course_type.py new file mode 100644 index 0000000000..d2bbe3445f --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/backpopulate_course_type.py @@ -0,0 +1,110 @@ +from django.core.management import BaseCommand, CommandError +from django.db.models import Q +from django.utils.translation import ugettext as _ + +from course_discovery.apps.course_metadata.data_loaders.course_type import calculate_course_type +from course_discovery.apps.course_metadata.models import BackpopulateCourseTypeConfig, Course, CourseRunType, CourseType + + +class Command(BaseCommand): + help = _('Backpopulate new-style CourseType and CourseRunType where possible.') + + def add_arguments(self, parser): + parser.add_argument( + '--partner', + metavar=_('CODE'), + help=_('Short code for a partner.'), + ) + parser.add_argument( + '--course', + metavar=_('UUID'), + action='append', + help=_('Course to backpopulate.'), + default=[], + ) + parser.add_argument( + '--org', + metavar=_('KEY'), + action='append', + help=_('Organization to backpopulate.'), + default=[], + ) + parser.add_argument( + '--allow-for', + action='append', + help=_('CourseType/CourseRunType mismatches to allow. Specify like course-type-slug:run-type-slug.'), + default=[], + ) + parser.add_argument( + '--commit', + action='store_true', + help=_('Actually commit the changes to the database.'), + ) + parser.add_argument( + '--args-from-database', + action='store_true', + help=_('Use arguments from the BackpopulateCourseTypeConfig model instead of the command line.'), + ) + + def get_args_from_database(self): + """ Returns an options dictionary from the current BackpopulateCourseTypeConfig model. """ + config = BackpopulateCourseTypeConfig.get_solo() + + # We don't need fancy shell-style whitespace/quote handling - none of our arguments are complicated + argv = config.arguments.split() + + parser = self.create_parser('manage.py', 'backpopulate_course_type') + return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object + + def backpopulate(self, options): + # Manually check required partner field (doesn't use required=True, because that would require --partner + # even when using --args-from-database) + if options['partner'] is None: + self.print_help('manage.py', 'backpopulate_course_type') + raise CommandError(_('You must specify --partner')) + + mismatches = {} + if options['allow_for']: + for value in options['allow_for']: + course_type_slug, run_type_slug = value.split(':') + if not CourseRunType.objects.filter(slug=run_type_slug).exists(): + raise CommandError(_('Supplied Course Run Type slug [{rt_slug}] does not exist.').format( + rt_slug=run_type_slug + )) + if course_type_slug in mismatches: + mismatches[course_type_slug].append(run_type_slug) + else: + if not CourseType.objects.filter(slug=course_type_slug).exists(): + raise CommandError(_('Supplied Course Type slug [{ct_slug}] does not exist.').format( + ct_slug=course_type_slug + )) + mismatches[course_type_slug] = [run_type_slug] + + # We look at both draft and official rows + courses = Course.everything.filter(partner__short_code=options['partner']).filter( + Q(uuid__in=options['course']) | + Q(authoring_organizations__key__in=options['org']) + ).distinct() + if not courses: + raise CommandError(_('No courses found. Did you specify an argument?')) + + failures = set() + course_types = CourseType.objects.order_by('created') + for course in courses: + if not calculate_course_type(course, course_types=course_types, commit=options['commit'], + mismatches=mismatches): + failures.add(course) + + if failures: + keys = sorted(f'{failure.key} ({failure.id})' for failure in failures) + raise CommandError( + _('Could not backpopulate a course type for the following courses: {course_keys}').format( + course_keys=', '.join(keys) + ) + ) + + def handle(self, *args, **options): + if options['args_from_database']: + options = self.get_args_from_database() + + self.backpopulate(options) diff --git a/course_discovery/apps/course_metadata/management/commands/deduplicate_course_metadata_history.py b/course_discovery/apps/course_metadata/management/commands/deduplicate_course_metadata_history.py new file mode 100644 index 0000000000..8f3e329f51 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/deduplicate_course_metadata_history.py @@ -0,0 +1,81 @@ +""" +Deduplicate course metadata history rows that were unnecessarily created while running +refresh_course_metadata. + +This largely inherits the internals from the clean_duplicate_history management command +provided by django-simple-history, with only two minor tweaks: + +1. Ignore the `modified` field while comparing potentially duplicate records. +2. Use the `everything` model manager while fetching all model instances to process. + +Usage: identical to clean_duplicate_history: + + python manage.py deduplicate_course_metadata_history --dry course_metadata.Course + python manage.py deduplicate_course_metadata_history course_metadata.Course + python manage.py deduplicate_course_metadata_history course_metadata.CourseRun + +https://django-simple-history.readthedocs.io/en/latest/utils.html#clean-duplicate-history +""" +from django.utils import timezone +from simple_history.management.commands import clean_duplicate_history + + +class Command(clean_duplicate_history.Command): + help = ( + "Deduplicate course metadata history rows that were unnecessarily created " + "while running refresh_course_metadata." + ) + + def _process(self, to_process, date_back=None, dry_run=True): + """ + The body of this method is copied VERBATIM from upstream except for the + following change: + + Instead of calling model.objects.all(), we use model.everything.all() + whenever possible. + """ + if date_back: + stop_date = timezone.now() - timezone.timedelta(minutes=date_back) + else: + stop_date = None + + for model, history_model in to_process: + m_qs = history_model.objects + if stop_date: + m_qs = m_qs.filter(history_date__gte=stop_date) + found = m_qs.count() + self.log(f"{model} has {found} historical entries", 2) + if not found: + continue + + # Break apart the query so we can add additional filtering + + # This try block is the only part that differs from upstream + try: + model_query = model.everything.all() # Attempting to use the `everything` manager. + except AttributeError: + model_query = model.objects.all() # upstream's original behavior. + + # If we're provided a stop date take the initial hit of getting the + # filtered records to iterate over + if stop_date: + model_query = model_query.filter( + pk__in=(m_qs.values_list(model._meta.pk.name).distinct()) + ) + + for o in model_query.iterator(): + self._process_instance(o, model, stop_date=stop_date, dry_run=dry_run) + + def _check_and_delete(self, entry1, entry2, dry_run=True): + """ + The body of this method is copied VERBATIM from upstream except for the + following change: + + Ignore changes in the `modified` field. + """ + delta = entry1.diff_against(entry2) + if set(delta.changed_fields).issubset({"modified"}): # This is the only line that differs from upstream. + if not dry_run: + entry1.delete() + return 1 + return 0 diff --git a/course_discovery/apps/course_metadata/management/commands/delete_person_dups.py b/course_discovery/apps/course_metadata/management/commands/delete_person_dups.py index 0b5237b63c..34265ae9f6 100644 --- a/course_discovery/apps/course_metadata/management/commands/delete_person_dups.py +++ b/course_discovery/apps/course_metadata/management/commands/delete_person_dups.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -class PersonInfo(object): +class PersonInfo: def __init__(self, partner, uuid, target_uuid): self.person = Person.objects.get(partner=partner, uuid=uuid) self.target = Person.objects.get(partner=partner, uuid=target_uuid) @@ -81,13 +81,12 @@ def delete_person(self, pinfo, commit=False): # - CourseRun staff (sortedm2m) # - Publisher CourseRun staff (sortedm2m) - logger.info( + logger.info( # pylint: disable=logging-not-lazy '{} {}:\n'.format(_('Deleting') if commit else _('Would delete'), pinfo.person.uuid) + ' {}: {}\n'.format(_('Name'), pinfo.person.full_name) + ' {}: {}\n'.format(_('Endorsements'), pinfo.person.endorsement_set.count()) + ' {}: {}\n'.format(_('Programs'), pinfo.person.program_set.count()) + ' {}: {}\n'.format(_('Course Runs'), pinfo.person.courses_staffed.count()) + - ' {}: {}\n'.format(_('Publisher Course Runs'), pinfo.person.publisher_course_runs_staffed.count()) + ' {}: {} ({})\n'.format(_('Target'), pinfo.target.full_name, pinfo.target.uuid) ) if not commit: @@ -112,24 +111,14 @@ def filter_person(person): if pinfo.target in program.instructor_ordering.all(): continue new_instructors = [filter_person(instructor) for instructor in program.instructor_ordering.all()] - program.instructor_ordering = new_instructors - program.save() + program.instructor_ordering.set(new_instructors) # Update metadata course runs for course_run in pinfo.person.courses_staffed.all(): if pinfo.target in course_run.staff.all(): continue new_staff = [filter_person(staff) for staff in course_run.staff.all()] - course_run.staff = new_staff - course_run.save() - - # Update publisher course runs - for publisher_course_run in pinfo.person.publisher_course_runs_staffed.all(): - if pinfo.target in publisher_course_run.staff.all(): - continue - new_staff = [filter_person(staff) for staff in publisher_course_run.staff.all()] - publisher_course_run.staff = new_staff - publisher_course_run.save() + course_run.staff.set(new_staff) # And finally, actually delete the person pinfo.person.delete() @@ -138,7 +127,7 @@ def delete_person_dups(self, options): if options['partner_code'] is None: self.print_help('manage.py', 'delete_person_dups') raise CommandError(_('You must specify --partner-code')) - if len(options['people']) == 0: + if not options['people']: self.print_help('manage.py', 'delete_person_dups') raise CommandError(_('You must specify at least one person')) diff --git a/course_discovery/apps/course_metadata/management/commands/load_program_fixture.py b/course_discovery/apps/course_metadata/management/commands/load_program_fixture.py new file mode 100644 index 0000000000..d2c74841ad --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/load_program_fixture.py @@ -0,0 +1,235 @@ +""" +Populate catalog programs for masters sandbox environment +""" +import logging +from contextlib import contextmanager +from posixpath import join as urljoin + +from django import db +from django.core import serializers +from django.core.exceptions import FieldDoesNotExist +from django.core.management import BaseCommand, CommandError +from edx_rest_api_client import client as rest_client + +from course_discovery.apps.core.models import Partner +from course_discovery.apps.course_metadata.models import ( + CourseRun, Curriculum, CurriculumCourseMembership, CurriculumProgramMembership, Program, ProgramType, SeatType +) +from course_discovery.apps.course_metadata.signals import ( + check_curriculum_for_cycles, check_curriculum_program_membership_for_cycles, + ensure_external_key_uniqueness__course_run, ensure_external_key_uniqueness__curriculum, + ensure_external_key_uniqueness__curriculum_course_membership +) + +logger = logging.getLogger(__name__) + + +@contextmanager +def disconnect_program_signals(): + """ + Context manager to be used for temporarily disconnecting the following + pre_save signals for verifying external course keys: + - check_curriculum_for_cycles + - check_curriculum_program_membership_for_cycles + - ensure_external_key_uniqueness__course_run + - ensure_external_key_uniqueness__curriculum + - ensure_external_key_uniqueness__curriculum_course_membership + """ + pre_save = db.models.signals.pre_save + + signals_list = [ + { + 'action': pre_save, + 'signal': check_curriculum_for_cycles, + 'sender': Curriculum, + }, + { + 'action': pre_save, + 'signal': check_curriculum_program_membership_for_cycles, + 'sender': CurriculumProgramMembership, + }, + { + 'action': pre_save, + 'signal': ensure_external_key_uniqueness__course_run, + 'sender': CourseRun, + }, + { + 'action': pre_save, + 'signal': ensure_external_key_uniqueness__curriculum, + 'sender': Curriculum, + }, + { + 'action': pre_save, + 'signal': ensure_external_key_uniqueness__curriculum_course_membership, + 'sender': CurriculumCourseMembership, + }, + ] + + for signal in signals_list: + signal['action'].disconnect(signal['signal'], sender=signal['sender']) + + try: + yield + finally: + for signal in signals_list: + signal['action'].connect(signal['signal'], sender=signal['sender']) + + +class Command(BaseCommand): + """ + Command to populate catalog database with programs from another environment + using the /program-fixtures endpoint + + Usage: + ./manage.py load_program_fixture 707acbed-0dae-4e69-a629-1fa20b87ccf1:external_key + --catalog-host http://edx.devstack.discovery:18381 + --oauth-host http://edx.devstack.lms:18000 + --client-id xxxxxxx --client-secret xxxxx + """ + DEFAULT_PARTNER_CODE = 'edx' + + def add_arguments(self, parser): + parser.add_argument('programs', help='comma separated list of program uuids or uuid:external_key mappings') + parser.add_argument('--catalog-host', required=True) + parser.add_argument('--oauth-host', required=True) + parser.add_argument('--client-id', required=True) + parser.add_argument('--client-secret', required=True) + parser.add_argument('--partner-code', default=self.DEFAULT_PARTNER_CODE) + + def get_fixture(self, programs, catalog_host, auth_host, client_id, client_secret): + client = rest_client.OAuthAPIClient( + auth_host, + client_id, + client_secret, + ) + + url = urljoin( + catalog_host, + 'extensions/api/v1/program-fixture/?programs={query}'.format( + query=','.join(programs) + ) + ) + + response = client.request('GET', url) + + if response.status_code != 200: + raise CommandError('Unexpected response loading programs from discovery service: {code} {msg}'.format( + code=response.status_code, + msg=response.text, + )) + + return response.text + + def save_fixture_object(self, obj): + try: + obj.save() + logger.info('Saved {object_label}(pk={pk})'.format( + object_label=obj.object._meta.label, + pk=obj.object.pk, + )) + except (db.DatabaseError, db.IntegrityError) as e: + e.args = ('Failed to save {object_label}(pk={pk}): {error_msg}'.format( + object_label=obj.object._meta.label, + pk=obj.object.pk, + error_msg=e, + ),) + raise + + def handle(self, *args, **options): + programs = [program.split(':')[0] for program in options['programs'].split(',')] + fixture = self.get_fixture( + programs, + options['catalog_host'], + options['oauth_host'], + options['client_id'], + options['client_secret'], + ) + + partner = Partner.objects.get(short_code=options['partner_code']) + + connection = db.connections[db.DEFAULT_DB_ALIAS] + + with connection.constraint_checks_disabled(): + with disconnect_program_signals(): + self.load_fixture(fixture, partner) + try: + connection.check_constraints() + except Exception as e: + e.args = ( + "Checking database constraints failed trying to load fixtures. Unable to save program(s): %s" % e, + ) + raise + + @db.transaction.atomic + def load_fixture(self, fixture_text, partner): + + deserialized_items = serializers.deserialize('json', fixture_text) + seat_type_map = {} + program_type_map = {} + objects_to_be_loaded = [] + for item in deserialized_items: + # maps the pk of incoming SeatType/ProgramType references to a new + # or existing model to avoid duplicate values. + if isinstance(item.object, SeatType): + stored_seat_type, created = SeatType.objects.get_or_create(name=item.object.name) + seat_type_map[item.object.id] = (stored_seat_type, item) + elif isinstance(item.object, ProgramType): + # translated fields work differently in 'get' vs 'create', so need to explicitly call relation + # in get and then set field in defaults + stored_program_type, created = ProgramType.objects.get_or_create(translations__name_t=item.object.name, + defaults={'name_t': item.object.name}) + program_type_map[item.object.id] = (stored_program_type, item) + else: + # partner models are not included in the fixture + # replace partner with valid reference in this environment + try: + item.object._meta.get_field('partner') + item.object.partner = partner + except FieldDoesNotExist: + pass + + objects_to_be_loaded.append(item) + + # set applicable_seat_types on each incoming program type to valid values + # for this environment. Remove any seat types not in the fixture data + for stored_program_type, fixture_program_type in program_type_map.values(): + stored_program_type.applicable_seat_types.clear() + for applicable_seat_type_id in fixture_program_type.m2m_data['applicable_seat_types']: + try: + stored_program_type.applicable_seat_types.add(seat_type_map[applicable_seat_type_id][0]) + except KeyError: + msg = ('Failed to assign applicable_seat_type(pk={seat_type}) to ProgramType(pk={program_type}):' + 'No matching SeatType in fixture') + logger.warning(msg.format( + seat_type=applicable_seat_type_id, + program_type=fixture_program_type.object.id, + )) + raise + + for obj in objects_to_be_loaded: + # apply newly created/updated program_types to all programs + # we're loading in + if isinstance(obj.object, Program): + try: + obj.object.type = program_type_map[obj.object.type_id][0] + except KeyError: + msg = ('Failed to assign type(pk={program_type}) to Program(pk={program}):' + ' No matching ProgramType in fixture') + logger.warning(msg.format( + program_type=obj.object.type_id, + program=obj.object.id, + )) + raise + elif isinstance(obj.object, CourseRun) and obj.object.expected_program_type_id: + try: + obj.object.expected_program_type = program_type_map[obj.object.expected_program_type_id][0] + except KeyError: + msg = ('Failed to assign program type (pk={program_type}) to CourseRun (pk={course_id}):' + ' No matching ProgramType in fixture') + logger.warning(msg.format( + program_type=obj.object.expected_program_type, + course_id=obj.object.key, + )) + raise + + self.save_fixture_object(obj) diff --git a/course_discovery/apps/course_metadata/management/commands/modify_program_hooks.py b/course_discovery/apps/course_metadata/management/commands/modify_program_hooks.py new file mode 100644 index 0000000000..23c2e66a09 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/modify_program_hooks.py @@ -0,0 +1,37 @@ +import logging +import uuid + +from django.core.management import BaseCommand + +from course_discovery.apps.course_metadata.models import BulkModifyProgramHookConfig, Program + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ Management command to bulk modify program hooks. Uses config BulkModifyProgramHooksConfig + to be filled with the correct mapping of uuid to program text, entered as a new line separated list of + : + ./manage.py modify_program_hooks """ + + help = 'Modify program hooks in bulk with arguments from database' + + def handle(self, *args, **options): + config = BulkModifyProgramHookConfig.get_solo() + lines = config.program_hooks.split('\n') + for line in lines: + tokenized = line.split(':', 1) + if len(tokenized) != 2: + logger.warning(f'Incorrectly formatted line {line}') + continue + try: + program_uuid = uuid.UUID(tokenized[0].strip()) + program = Program.objects.filter(uuid=program_uuid).first() + if not program: + logger.warning('Cannot find program with uuid {uuid}'.format(uuid=tokenized[0])) + continue + program.marketing_hook = tokenized[1] + program.save(suppress_publication=True) + except ValueError: + logger.warning('Incorrectly formatted uuid "{uuid}"'.format(uuid=tokenized[0])) + continue diff --git a/course_discovery/apps/course_metadata/management/commands/publish_live_course_runs.py b/course_discovery/apps/course_metadata/management/commands/publish_live_course_runs.py new file mode 100644 index 0000000000..5701167198 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/publish_live_course_runs.py @@ -0,0 +1,34 @@ +import datetime +import logging + +import pytz +from django.core.management import BaseCommand, CommandError +from django.utils.translation import ugettext as _ + +from course_discovery.apps.course_metadata.choices import CourseRunStatus +from course_discovery.apps.course_metadata.models import CourseRun + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Based on course run go_live dates, publish any runs that are due' + + def handle(self, *args, **options): + failed = False + now = datetime.datetime.now(pytz.UTC) + course_runs = CourseRun.objects.filter(status=CourseRunStatus.Reviewed, go_live_date__lte=now) + + for course_run in course_runs: + logger.info(_('Publishing course run {key}').format(key=course_run.key)) + + try: + course_run.publish() + except Exception: # pylint: disable=broad-except + logger.exception(_('Failed to publish {key}').format(key=course_run.key)) + failed = True + else: + logger.info(_('Successfully published {key}').format(key=course_run.key)) + + if failed: + raise CommandError(_('One or more course runs failed to publish.')) diff --git a/course_discovery/apps/course_metadata/management/commands/publish_uuids_to_drupal.py b/course_discovery/apps/course_metadata/management/commands/publish_uuids_to_drupal.py index 2ed9d7bf16..6369096db8 100644 --- a/course_discovery/apps/course_metadata/management/commands/publish_uuids_to_drupal.py +++ b/course_discovery/apps/course_metadata/management/commands/publish_uuids_to_drupal.py @@ -1,6 +1,7 @@ import logging from django.core.management import BaseCommand + from course_discovery.apps.course_metadata.exceptions import MarketingSiteAPIClientException, PersonToMarketingException from course_discovery.apps.course_metadata.models import CourseRun, DrupalPublishUuidConfig, Person from course_discovery.apps.course_metadata.people import MarketingSitePeople diff --git a/course_discovery/apps/course_metadata/management/commands/refresh_course_metadata.py b/course_discovery/apps/course_metadata/management/commands/refresh_course_metadata.py index 09792156aa..a6fb58f85d 100644 --- a/course_discovery/apps/course_metadata/management/commands/refresh_course_metadata.py +++ b/course_discovery/apps/course_metadata/management/commands/refresh_course_metadata.py @@ -1,39 +1,50 @@ import concurrent.futures import itertools import logging -import time -import jwt +import backoff import waffle from django.apps import apps from django.core.management import BaseCommand, CommandError from django.db import connection from django.db.models.signals import post_delete, post_save -from edx_rest_api_client.client import EdxRestApiClient from course_discovery.apps.api.cache import api_change_receiver, set_api_timestamp from course_discovery.apps.core.models import Partner +from course_discovery.apps.core.utils import delete_orphans from course_discovery.apps.course_metadata.data_loaders.analytics_api import AnalyticsAPIDataLoader from course_discovery.apps.course_metadata.data_loaders.api import ( - CoursesApiDataLoader, EcommerceApiDataLoader, OrganizationsApiDataLoader, ProgramsApiDataLoader + CoursesApiDataLoader, + CourseRatingApiDataLoader, + EcommerceApiDataLoader, + ProgramsApiDataLoader, + WordPressApiDataLoader, ) -from course_discovery.apps.course_metadata.data_loaders.marketing_site import ( - CourseMarketingSiteDataLoader, SchoolMarketingSiteDataLoader, SponsorMarketingSiteDataLoader, - SubjectMarketingSiteDataLoader -) -from course_discovery.apps.course_metadata.models import Course, DataLoaderConfig +from course_discovery.apps.course_metadata.models import Course, DataLoaderConfig, Image, Video logger = logging.getLogger(__name__) -def execute_loader(loader_class, *loader_args, **loader_kwargs): +def execute_loader(loader_class, *loader_args): + @backoff.on_exception( + backoff.expo, + Exception, + max_tries=loader_class.LOADER_MAX_RETRY, + logger=logger, + base=60, + ) + def run_loader(): + return loader_class(*loader_args).ingest() + try: - loader_class(*loader_args, **loader_kwargs).ingest() + run_loader() + return True except Exception: # pylint: disable=broad-except logger.exception('%s failed!', loader_class.__name__) + return False -def execute_parallel_loader(loader_class, *loader_args, **loader_kwargs): +def execute_parallel_loader(loader_class, *loader_args): """ ProcessPoolExecutor uses the multiprocessing module. Multiprocessing forks processes, causing connection objects to be copied across processes. The key goal when running @@ -49,7 +60,7 @@ def execute_parallel_loader(loader_class, *loader_args, **loader_kwargs): """ connection.close() - execute_loader(loader_class, *loader_args, **loader_kwargs) + return execute_loader(loader_class, *loader_args) class Command(BaseCommand): @@ -58,9 +69,6 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( '--partner_code', - action='store', - dest='partner_code', - default=None, help='The short code for a specific partner to refresh.' ) @@ -74,7 +82,7 @@ def handle(self, *args, **options): signal.disconnect(receiver=api_change_receiver, sender=model) # For each partner defined... - partners = Partner.objects.all() + partners = Partner.objects.filter(is_disabled=False) # If a specific partner was indicated, filter down the set partner_code = options.get('partner_code') @@ -84,22 +92,8 @@ def handle(self, *args, **options): if not partners: raise CommandError('No partners available!') - token_type = 'JWT' + success = True for partner in partners: - logger.info('Retrieving access token for partner [{}]'.format(partner.short_code)) - - try: - access_token, __ = EdxRestApiClient.get_oauth_access_token( - '{root}/access_token'.format(root=partner.oidc_url_root.strip('/')), - partner.oidc_key, - partner.oidc_secret, - token_type=token_type - ) - except Exception: - logger.exception('No access token acquired through client_credential flow.') - raise - username = jwt.decode(access_token, verify=False)['preferred_username'] - kwargs = {'username': username} if username else {} # The Linux kernel implements copy-on-write when fork() is called to create a new # process. Pages that the parent and child processes share, such as the database @@ -139,16 +133,13 @@ def handle(self, *args, **options): pipeline = ( ( - (SubjectMarketingSiteDataLoader, partner.marketing_site_url_root, max_workers), - (SchoolMarketingSiteDataLoader, partner.marketing_site_url_root, max_workers), - (SponsorMarketingSiteDataLoader, partner.marketing_site_url_root, max_workers), + (CoursesApiDataLoader, partner.courses_api_url, max_workers), ), ( - (CourseMarketingSiteDataLoader, partner.marketing_site_url_root, max_workers), - (OrganizationsApiDataLoader, partner.organizations_api_url, max_workers), + (WordPressApiDataLoader, partner.marketing_site_api_url, max_workers), ), ( - (CoursesApiDataLoader, partner.courses_api_url, max_workers), + (CourseRatingApiDataLoader, partner.lms_url, max_workers), ), ( (EcommerceApiDataLoader, partner.ecommerce_api_url, 1), @@ -160,43 +151,44 @@ def handle(self, *args, **options): ) if waffle.switch_is_active('parallel_refresh_pipeline'): + futures = [] for stage in pipeline: with concurrent.futures.ProcessPoolExecutor() as executor: for loader_class, api_url, max_workers in stage: if api_url: - logger.info('Executing Loader [{}]'.format(api_url)) - executor.submit( + logger.info(f'Executing Loader [{api_url}]') + futures.append(executor.submit( execute_parallel_loader, loader_class, partner, api_url, - access_token, - token_type, max_workers, is_threadsafe, - **kwargs, - ) + )) + + success = success and all(f.result() for f in futures) else: # Flatten pipeline and run serially. for loader_class, api_url, max_workers in itertools.chain(*(stage for stage in pipeline)): if api_url: - logger.info('Executing Loader [{}]'.format(api_url)) - execute_loader( + logger.info(f'Executing Loader [{api_url}]') + success = execute_loader( loader_class, partner, api_url, - access_token, - token_type, max_workers, is_threadsafe, - **kwargs, - ) + ) and success # TODO Cleanup CourseRun overrides equivalent to the Course values. - timestamp = time.time() - logger.info( - 'Data loading complete. Updating API timestamp to {timestamp}.'.format(timestamp=timestamp) - ) + connection.connect() # reconnect to django outside of loop (see connect comment above) + + # Clean up any media orphans that we might have created + delete_orphans(Image) + delete_orphans(Video) + + set_api_timestamp() - set_api_timestamp(timestamp) + if not success: + raise CommandError('One or more of the data loaders above failed.') diff --git a/course_discovery/apps/course_metadata/management/commands/remove_redirects_from_courses.py b/course_discovery/apps/course_metadata/management/commands/remove_redirects_from_courses.py new file mode 100644 index 0000000000..cff8bd8c41 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/remove_redirects_from_courses.py @@ -0,0 +1,69 @@ +import logging +import re + +from django.core.exceptions import ObjectDoesNotExist +from django.core.management import BaseCommand, CommandError +from django.utils.translation import ugettext as _ + +from course_discovery.apps.course_metadata.models import CourseUrlRedirect, CourseUrlSlug, RemoveRedirectsConfig + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ Management command to remove redirects (non-canonical urls) from courses, effectively removing those urls + from the site for the next prospectus build + ./manage.py remove_redirects_from_courses --remove_all OR + ./manage.py remove_redirects_from_courses -url_paths /course/slug-0 /some/other/path ...""" + + help = 'Remove redirects from courses' + + def add_arguments(self, parser): + parser.add_argument('--remove_all', action='store_true', help=_('Remove all redirects to all courses')) + parser.add_argument('-url_paths', nargs="*", help=_('Redirects to remove')) + parser.add_argument('--args-from-database', action='store_true', + help=_('Use arguments from the RemoveRedirectsConfig model instead of the command line.') + ) + + def handle(self, *args, **options): + # using mutually exclusive argument groups in management commands is only supported in Django 2.2 + # so use XOR to check manually + if not bool(options['args_from_database']) ^ (bool(options['url_paths']) ^ bool(options['remove_all'])): + raise CommandError(_('Invalid arguments')) + options_dict = options + if options_dict['args_from_database']: + options_dict = self.get_args_from_database() + if options_dict['url_paths']: + self.remove_redirects(options_dict['url_paths']) + return + if options_dict['remove_all']: + self.remove_all_redirects() + + def remove_all_redirects(self): + CourseUrlRedirect.objects.all().delete() + # keep active url slug + CourseUrlSlug.objects.filter(is_active=False, is_active_on_draft=False).delete() + + def remove_redirects(self, url_paths): + standard_course_url_regex = re.compile('^/?course/([^/]*)$') + for url_path in url_paths: + matched = standard_course_url_regex.match(url_path) + if matched: + url_slug = matched.group(1) + try: + url_slug_object = CourseUrlSlug.objects.get(url_slug=url_slug) + if url_slug_object.is_active or url_slug_object.is_active_on_draft: + logger.warning(_('Cannot remove active url_slug {url_slug}').format(url_slug=url_slug)) + continue + url_slug_object.delete() + except ObjectDoesNotExist: + logger.info(_('Path /course/{url_slug} not in use, nothing to delete').format(url_slug=url_slug)) + continue + # if not of the form /course/, the path would be stored in CourseUrlRedirects + deleted = CourseUrlRedirect.objects.filter(value=url_path).delete() + if deleted[0] == 0: + logger.info(_('Path {url_path} not in use, nothing to delete').format(url_path=url_path)) + + def get_args_from_database(self): + config = RemoveRedirectsConfig.get_solo() + return {"remove_all": config.remove_all, "url_paths": config.url_paths.split()} diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_add_tag_to_courses.py b/course_discovery/apps/course_metadata/management/commands/tests/test_add_tag_to_courses.py new file mode 100644 index 0000000000..77c755386d --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_add_tag_to_courses.py @@ -0,0 +1,41 @@ +from django.core.management import CommandError, call_command +from django.test import TestCase + +from course_discovery.apps.core.tests.factories import PartnerFactory +from course_discovery.apps.course_metadata.models import Course, TagCourseUuidsConfig +from course_discovery.apps.course_metadata.tests.factories import CourseFactory + + +class AddTagToCoursesCommandTests(TestCase): + def setUp(self): + super().setUp() + self.partner = PartnerFactory(marketing_site_api_password=None) + self.course1 = CourseFactory(partner=self.partner) + self.course2 = CourseFactory(partner=self.partner) + self.course3 = CourseFactory(partner=self.partner) + + def testNormalRun(self): + call_command('add_tag_to_courses', "tag0", self.course1.uuid, self.course2.uuid) + self.assertTrue(Course.objects.filter(topics__name="tag0", uuid=self.course1.uuid).exists()) + self.assertTrue(Course.objects.filter(topics__name="tag0", uuid=self.course2.uuid).exists()) + self.assertTrue(Course.objects.filter(uuid=self.course3.uuid).exists()) + self.assertFalse(Course.objects.filter(topics__name="tag0", uuid=self.course3.uuid).exists()) + + def testMissingArgument(self): + with self.assertRaises(CommandError): + call_command('add_tag_to_courses', "tag0") + + def testArgsFromDatabase(self): + config = TagCourseUuidsConfig.get_solo() + config.tag = 'tag0' + config.course_uuids = str(self.course1.uuid) + " " + str(self.course2.uuid) + config.save() + call_command('add_tag_to_courses', "--args-from-database") + self.assertTrue(Course.objects.filter(topics__name="tag0", uuid=self.course1.uuid).exists()) + self.assertTrue(Course.objects.filter(topics__name="tag0", uuid=self.course2.uuid).exists()) + self.assertTrue(Course.objects.filter(uuid=self.course3.uuid).exists()) + self.assertFalse(Course.objects.filter(topics__name="tag0", uuid=self.course3.uuid).exists()) + + # test command line args ignored if --args-from-database is set + call_command('add_tag_to_courses', "tag1", self.course1.uuid, self.course2.uuid, "--args-from-database") + self.assertFalse(Course.objects.filter(topics__name="tag1").exists()) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_backfill_course_run_slugs_to_courses.py b/course_discovery/apps/course_metadata/management/commands/tests/test_backfill_course_run_slugs_to_courses.py new file mode 100644 index 0000000000..139dac4d13 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_backfill_course_run_slugs_to_courses.py @@ -0,0 +1,112 @@ +from unittest import mock + +import ddt +from django.core.management import CommandError, call_command +from django.test import TestCase + +from course_discovery.apps.core.tests.factories import PartnerFactory +from course_discovery.apps.course_metadata.models import BackfillCourseRunSlugsConfig, CourseRunStatus +from course_discovery.apps.course_metadata.tests.factories import CourseFactory, CourseRunFactory + + +@ddt.ddt +class BackfillCourseRunSlugsToCoursesCommandTests(TestCase): + LOGGER = 'course_discovery.apps.course_metadata.management.commands.backfill_course_run_slugs_to_courses.logger' + + def setUp(self): + super().setUp() + self.partner = PartnerFactory(marketing_site_api_password=None) + self.course1 = CourseFactory(partner=self.partner, draft=False) + self.course2 = CourseFactory(partner=self.partner, draft=False) + self.course1UnpublishedRun = CourseRunFactory(course=self.course1, status=CourseRunStatus.Unpublished) + self.course1PublishedRun = CourseRunFactory(course=self.course1, status=CourseRunStatus.Published) + self.course2PublishedRun = CourseRunFactory(course=self.course2, status=CourseRunStatus.Published) + + def test_missing_arguments(self): + with self.assertRaises(CommandError): + call_command('backfill_course_run_slugs_to_courses') + + def test_conflicting_arguments(self): + with self.assertRaises(CommandError): + call_command('backfill_course_run_slugs_to_courses', '--args-from-database', '--all') + with self.assertRaises(CommandError): + call_command('backfill_course_run_slugs_to_courses', '--all', '-uuids', self.course1.uuid) + with self.assertRaises(CommandError): + call_command('backfill_course_run_slugs_to_courses', '-uuids', self.course1.uuid, '--args-from-database') + + @ddt.data( + ('command_line', '--all'), + ('database', '--args-from-database'), + ) + @ddt.unpack + def test_backfill_all(self, argument_source, argument): + course1_active_url_slug = self.course1.active_url_slug + course2_active_url_slug = self.course2.active_url_slug + + if argument_source == 'database': + config = BackfillCourseRunSlugsConfig.get_solo() + config.all = True + config.save() + call_command('backfill_course_run_slugs_to_courses', argument) + + course1_url_slugs = [slug_obj.url_slug for slug_obj in self.course1.url_slug_history.all()] + course2_url_slugs = [slug_obj.url_slug for slug_obj in self.course2.url_slug_history.all()] + + # make sure active url_slugs remain unchanged + self.assertEqual(self.course1.active_url_slug, course1_active_url_slug) + self.assertIn(self.course1PublishedRun.slug, course1_url_slugs) + self.assertNotIn(self.course1UnpublishedRun.slug, course1_url_slugs) + self.assertEqual(self.course2.active_url_slug, course2_active_url_slug) + self.assertIn(self.course2PublishedRun.slug, course2_url_slugs) + + @ddt.data('command_line', 'database',) + def test_backfill_specific_course(self, argument_source): + course1_active_url_slug = self.course1.active_url_slug + course2_active_url_slug = self.course2.active_url_slug + if argument_source == 'database': + config = BackfillCourseRunSlugsConfig.get_solo() + config.uuids = self.course1.uuid + config.save() + call_command('backfill_course_run_slugs_to_courses', '--args-from-database') + else: + call_command('backfill_course_run_slugs_to_courses', '-uuids', self.course1.uuid) + + course1_url_slugs = [slug_obj.url_slug for slug_obj in self.course1.url_slug_history.all()] + self.assertEqual(self.course1.active_url_slug, course1_active_url_slug) + self.assertIn(self.course1PublishedRun.slug, course1_url_slugs) + + # check we didn't change anything for course2 + self.assertEqual(self.course2.active_url_slug, course2_active_url_slug) + self.assertEqual(self.course2.url_slug_history.count(), 1) + + def test_specific_uuids_take_priority_in_database_config(self): + course1_active_url_slug = self.course1.active_url_slug + course2_active_url_slug = self.course2.active_url_slug + + config = BackfillCourseRunSlugsConfig.get_solo() + config.uuids = self.course1.uuid + config.all = True + config.save() + call_command('backfill_course_run_slugs_to_courses', '--args-from-database') + + course1_url_slugs = [slug_obj.url_slug for slug_obj in self.course1.url_slug_history.all()] + self.assertEqual(self.course1.active_url_slug, course1_active_url_slug) + self.assertIn(self.course1PublishedRun.slug, course1_url_slugs) + + # check we didn't change anything for course2 + self.assertEqual(self.course2.active_url_slug, course2_active_url_slug) + self.assertEqual(self.course2.url_slug_history.count(), 1) + + @mock.patch(LOGGER) + def test_unable_to_add_duplicate_slugs(self, mock_logger): + # add a slug from a course1 run to course2 + self.course2.url_slug_history.create(course=self.course2, partner=self.course2.partner, + url_slug=self.course1PublishedRun.slug) + # try to backfill course1 slugs + call_command('backfill_course_run_slugs_to_courses', '-uuids', self.course1.uuid) + desired_warning = 'Cannot add slug {slug} to course {uuid0}. Slug already belongs to course {uuid1}'.format( + slug=self.course1PublishedRun.slug, + uuid0=self.course1.uuid, + uuid1=self.course2.uuid + ) + mock_logger.warning.assert_called_with(desired_warning) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_backpopulate_course_type.py b/course_discovery/apps/course_metadata/management/commands/tests/test_backpopulate_course_type.py new file mode 100644 index 0000000000..f272f18c82 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_backpopulate_course_type.py @@ -0,0 +1,297 @@ +from unittest import mock + +import ddt +from django.core.management import CommandError, call_command +from django.test import TestCase +from testfixtures import LogCapture, StringComparison + +from course_discovery.apps.course_metadata.data_loaders.course_type import logger +from course_discovery.apps.course_metadata.models import ( + BackpopulateCourseTypeConfig, Course, CourseRunType, CourseType, Mode, Seat, Track +) +from course_discovery.apps.course_metadata.tests import factories +from course_discovery.apps.course_metadata.utils import ensure_draft_world + + +@ddt.ddt +class BackpopulateCourseTypeCommandTests(TestCase): + def setUp(self): + super().setUp() + + # Disable marketing site password just to save us from having to mock the responses + self.partner = factories.PartnerFactory(marketing_site_api_password=None) + + # Fill out a bunch of types and modes. Exact mode parameters don't matter, just the resulting seat types. + self.audit_seat_type = factories.SeatTypeFactory.audit() + self.verified_seat_type = factories.SeatTypeFactory.verified() + self.audit_mode = Mode.objects.get(slug=Seat.AUDIT) + self.verified_mode = Mode.objects.get(slug=Seat.VERIFIED) + self.audit_track = Track.objects.get(seat_type=self.audit_seat_type, mode=self.audit_mode) + self.verified_track = Track.objects.get(seat_type=self.verified_seat_type, mode=self.verified_mode) + self.empty_run_type = CourseRunType.objects.get(slug=CourseRunType.EMPTY) + self.audit_run_type = CourseRunType.objects.get(slug=CourseRunType.AUDIT) + self.va_run_type = CourseRunType.objects.get(slug=CourseRunType.VERIFIED_AUDIT) + self.empty_course_type = CourseType.objects.get(slug=CourseType.EMPTY) + self.va_course_type = CourseType.objects.get(slug=CourseType.VERIFIED_AUDIT) + self.audit_course_type = CourseType.objects.get(slug=CourseType.AUDIT) + + # Now create some courses and orgs that will be found to match the above, in the simple happy path case. + self.org = factories.OrganizationFactory(partner=self.partner, key='Org1') + self.course = factories.CourseFactory(partner=self.partner, authoring_organizations=[self.org], + type=self.empty_course_type, + key=f'{self.org.key}+Course1') + self.entitlement = factories.CourseEntitlementFactory(partner=self.partner, course=self.course, + mode=self.verified_seat_type) + self.audit_run = factories.CourseRunFactory(course=self.course, type=self.empty_run_type, + key='course-v1:Org1+Course1+A') + self.audit_seat = factories.SeatFactory(course_run=self.audit_run, type=self.audit_seat_type) + self.verified_run = factories.CourseRunFactory(course=self.course, type=self.empty_run_type, + key='course-v1:Org1+Course1+V') + self.verified_seat = factories.SeatFactory(course_run=self.verified_run, type=self.verified_seat_type) + self.verified_audit_seat = factories.SeatFactory(course_run=self.verified_run, type=self.audit_seat_type) + + # Create parallel obj / course for argument testing + self.org2 = factories.OrganizationFactory(partner=self.partner, key='Org2') + self.org3 = factories.OrganizationFactory(partner=self.partner, key='Org3') + self.course2 = factories.CourseFactory(partner=self.partner, authoring_organizations=[self.org2, self.org3], + type=self.empty_course_type, + key=f'{self.org2.key}+Course1') + self.c2_audit_run = factories.CourseRunFactory(course=self.course2, type=self.empty_run_type) + self.c2_audit_seat = factories.SeatFactory(course_run=self.c2_audit_run, type=self.audit_seat_type) + + def run_command(self, courses=None, orgs=None, allow_for=None, commit=True, fails=None, log=None): + command_args = ['--partner=' + self.partner.short_code] + if commit: + command_args.append('--commit') + if courses is None and orgs is None: + courses = [self.course] + if courses: + command_args += ['--course=' + str(c.uuid) for c in courses] + if orgs: + command_args += ['--org=' + str(o.key) for o in orgs] + if allow_for: + command_args += ['--allow-for=' + type_slug_run_slug_tuple for type_slug_run_slug_tuple in allow_for] + + with LogCapture(logger.name) as log_capture: + if fails: + fails = fails if isinstance(fails, list) else [fails] + keys = sorted(f'{fail.key} ({fail.id})' for fail in fails) + msg = 'Could not backpopulate a course type for the following courses: {course_keys}'.format( + course_keys=', '.join(keys) + ) + with self.assertRaisesMessage(CommandError, msg): + self.call_command(*command_args) + else: + self.call_command(*command_args) + + if log: + log_capture.check_present((logger.name, 'INFO', StringComparison(log))) + + # As a convenience, refresh our built in courses and runs + for obj in (self.course, self.audit_run, self.verified_run, self.course2, self.c2_audit_run): + if obj.id: + obj.refresh_from_db() + + def call_command(self, *args): + call_command('backpopulate_course_type', *args) + + def test_invalid_args(self): + partner_code = f'--partner={self.partner.short_code}' + course_arg = f'--course={self.course.uuid}' + + with self.assertRaises(CommandError) as cm: + self.call_command(partner_code) # no courses listed + self.assertEqual(cm.exception.args[0], 'No courses found. Did you specify an argument?') + + with self.assertRaises(CommandError) as cm: + self.call_command(course_arg) # no partner + self.assertEqual(cm.exception.args[0], 'You must specify --partner') + + with self.assertRaises(CommandError) as cm: + self.call_command('--partner=NotAPartner', course_arg) + self.assertEqual(cm.exception.args[0], 'No courses found. Did you specify an argument?') + + with self.assertRaises(CommandError) as cm: + self.call_command('--allow-for=NotACourseType:audit', partner_code, course_arg) + self.assertEqual(cm.exception.args[0], 'Supplied Course Type slug [NotACourseType] does not exist.') + + with self.assertRaises(CommandError) as cm: + self.call_command('--allow-for=verified-audit:NotACourseRunType', partner_code, course_arg) + self.assertEqual(cm.exception.args[0], 'Supplied Course Run Type slug [NotACourseRunType] does not exist.') + + def test_args_from_database(self): + config = BackpopulateCourseTypeConfig.get_solo() + config.arguments = '--partner=a --course=b --course=c --org=d --org=e --commit' + config.save() + + module = 'course_discovery.apps.course_metadata.management.commands.backpopulate_course_type' + + # First ensure we do correctly grab arguments from the db + with mock.patch(module + '.Command.backpopulate') as cm: + self.call_command('--args-from-database', '--course=f') + self.assertEqual(cm.call_count, 1) + args = cm.call_args[0][0] + self.assertEqual(args['partner'], 'a') + self.assertEqual(args['commit'], True) + self.assertEqual(args['course'], ['b', 'c']) + self.assertEqual(args['org'], ['d', 'e']) + + # Then confirm that we don't when not asked to + with mock.patch(module + '.Command.backpopulate') as cm: + self.call_command('--partner=b', '--course=f') + self.assertEqual(cm.call_count, 1) + args = cm.call_args[0][0] + self.assertEqual(args['partner'], 'b') + self.assertEqual(args['commit'], False) + self.assertEqual(args['course'], ['f']) + self.assertEqual(args['org'], []) + + def test_non_committal_run(self): + self.run_command(commit=False) + self.assertTrue(self.course.type.empty) + + # Sanity check that it would set the type with commit=True + self.run_command() + self.assertFalse(self.course.type.empty) + + def test_normal_run(self): + self.run_command(log='Course .* matched type Verified and Audit') + self.assertEqual(self.course.type, self.va_course_type) + self.assertEqual(self.audit_run.type, self.audit_run_type) + self.assertEqual(self.verified_run.type, self.va_run_type) + + def test_existing_type(self): + # First, confirm we try and fail to find a valid match for runs when course has type but no runs can match it + prof_course_type = CourseType.objects.get(slug=CourseType.PROFESSIONAL) + self.course.type = prof_course_type + self.course.save() + self.run_command(fails=self.course, + log="Existing course type Professional Only for .* doesn't match its own entitlements") + + # Now set up the runs with types too -- but make sure we don't consider the course type a valid match yet + self.entitlement.delete() + audit_run_type = CourseRunType.objects.get(slug=CourseRunType.AUDIT) + self.audit_run.type = audit_run_type + self.audit_run.save() + self.verified_run.type = audit_run_type + self.verified_run.save() + self.run_command(fails=self.course, + log="Existing run type Audit Only for .* doesn't match course type Professional Only") + + # Once the run type is valid for the course type, it should still require matching seats. + prof_course_type.course_run_types.add(audit_run_type) + self.run_command(fails=self.course, log="Existing run type Audit Only for .* doesn't match its own seats") + + # Now make the runs match the audit only run types and everything should pass + self.verified_seat.delete() + pre_course_modified = self.course.modified + pre_audit_modified = self.audit_run.modified + pre_verified_modified = self.verified_run.modified + self.run_command() + self.assertEqual(self.course.type, prof_course_type) + self.assertEqual(self.course.modified, pre_course_modified) + self.assertEqual(self.audit_run.type, audit_run_type) + self.assertEqual(self.audit_run.modified, pre_audit_modified) + self.assertEqual(self.verified_run.type, audit_run_type) + self.assertEqual(self.verified_run.modified, pre_verified_modified) + + def test_affects_drafts_too(self): + draft_course = ensure_draft_world(Course.objects.get(pk=self.course.pk)) + + self.run_command() + draft_course.refresh_from_db() + self.assertEqual(self.course.type, self.va_course_type) + self.assertEqual(self.audit_run.type, self.audit_run_type) + self.assertEqual(self.verified_run.type, self.va_run_type) + self.assertEqual(draft_course.type, self.va_course_type) + self.assertEqual(set(draft_course.course_runs.values_list('type', flat=True)), + {self.audit_run_type.id, self.va_run_type.id}) + + def test_matches_earliest_course_type(self): + # This messes up this test due to Verified and Audit being a subset of Credit + # and Credit being created prior to 'Second' + CourseType.objects.get(slug=CourseType.CREDIT_VERIFIED_AUDIT).delete() + second_type = factories.CourseTypeFactory( + name='Second', + entitlement_types=self.va_course_type.entitlement_types.all(), + course_run_types=self.va_course_type.course_run_types.all(), + ) + + self.run_command() + self.assertEqual(self.course.type, self.va_course_type) + + # Now sanity check that we *would* have matched + self.course.type = self.empty_course_type + self.course.save() + self.va_course_type.delete() + self.run_command() + self.assertEqual(self.course.type, second_type) + + def test_one_mismatched_run_ruins_whole_thing(self): + """ Test that a single run that doesn't match its parent course type will prevent a course match. """ + self.audit_seat.delete() + self.run_command(fails=self.course) + self.assertTrue(self.course.type.empty) + + # Now sanity check that we *would* have matched without seatless run + self.audit_run.delete() + self.run_command() + self.assertEqual(self.course.type, self.va_course_type) + + def test_by_org(self): + self.run_command(orgs=[self.org2]) + self.assertTrue(self.course.type.empty) + self.assertEqual(self.course2.type, self.audit_course_type) + + def test_by_multiple_orgs(self): + self.run_command(orgs=[self.org, self.org3]) + self.assertEqual(self.course.type, self.va_course_type) + self.assertEqual(self.course2.type, self.audit_course_type) + + def test_by_multiple_courses(self): + self.run_command(courses=[self.course, self.course2]) + self.assertEqual(self.course.type, self.va_course_type) + self.assertEqual(self.course2.type, self.audit_course_type) + + def test_by_course_and_org(self): + self.run_command(courses=[self.course], orgs=[self.org2]) + self.assertEqual(self.course.type, self.va_course_type) + self.assertEqual(self.course2.type, self.audit_course_type) + + def test_courses_with_honor_seats(self): + honor_seat_type = factories.SeatTypeFactory.honor() + honor_run_type = CourseRunType.objects.get(slug=CourseRunType.HONOR) + # Tests that Honor Only will not match with Verified and Honor despite being a subset of that CourseRunType + honor_run = factories.CourseRunFactory(course=self.course, type=self.empty_run_type, + key='course-v1:Org1+Course1+H') + factories.SeatFactory(course_run=honor_run, type=honor_seat_type) + + vh_run_type = CourseRunType.objects.get(slug=CourseRunType.VERIFIED_HONOR) + vh_run = factories.CourseRunFactory(course=self.course, type=self.empty_run_type, + key='course-v1:Org1+Course1+VH') + factories.SeatFactory(course_run=vh_run, type=honor_seat_type) + factories.SeatFactory(course_run=vh_run, type=self.verified_seat_type) + + allow_for = [ + CourseType.VERIFIED_AUDIT + ':' + CourseRunType.HONOR, + CourseType.VERIFIED_AUDIT + ':' + CourseRunType.VERIFIED_HONOR + ] + self.run_command(allow_for=allow_for) + self.assertEqual(self.course.type, self.va_course_type) + honor_run.refresh_from_db() + self.assertEqual(honor_run.type, honor_run_type) + vh_run.refresh_from_db() + self.assertEqual(vh_run.type, vh_run_type) + + def test_course_runs_with_no_seats(self): + print(CourseRunType.objects.all()) + empty_run_type = CourseRunType.objects.get(slug=CourseRunType.EMPTY) + empty_run = factories.CourseRunFactory(course=self.course, type=self.empty_run_type, + key='course-v1:Org1+Course1+H') + self.assertEqual(empty_run.seats.count(), 0) + + allow_for = [CourseType.VERIFIED_AUDIT + ':' + CourseRunType.EMPTY] + self.run_command(allow_for=allow_for) + self.assertEqual(self.course.type, self.va_course_type) + empty_run.refresh_from_db() + self.assertEqual(empty_run.type, empty_run_type) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_create_test_program.py b/course_discovery/apps/course_metadata/management/commands/tests/test_create_test_program.py index d0c3659998..6c808a3064 100644 --- a/course_discovery/apps/course_metadata/management/commands/tests/test_create_test_program.py +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_create_test_program.py @@ -7,9 +7,9 @@ class CreateTestProgramCommandTests(TestCase): def setUp(self): - super(CreateTestProgramCommandTests, self).setUp() + super().setUp() self.partner = PartnerFactory() - self.command_args = ['--partner_code={}'.format(self.partner.short_code)] + self.command_args = [f'--partner_code={self.partner.short_code}'] def test_create_command(self): call_command('create_test_program', *self.command_args) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_deduplicate_course_metadata_history.py b/course_discovery/apps/course_metadata/management/commands/tests/test_deduplicate_course_metadata_history.py new file mode 100644 index 0000000000..5f41996762 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_deduplicate_course_metadata_history.py @@ -0,0 +1,65 @@ +from django.core.management import call_command +from django.test import TestCase + +from course_discovery.apps.course_metadata.models import CourseRun +from course_discovery.apps.course_metadata.tests import factories + + +class DeduplicateCourseMetadataHistoryCommandTests(TestCase): + """ + Test the deduplicate_course_metadata_history management command for *basic + functionality*. This is not inteded as an exhaustive test of deduplication logic, + which is already tested upstream (django-simple-history). The goal is to test for + possible regressions caused by changes in upstream's API, and to make sure the + management command launches correctly. + """ + def setUp(self): + super().setUp() + self.courserun1 = factories.CourseRunFactory(draft=False) + self.courserun2 = factories.CourseRunFactory(draft=False) + self.courserun3 = factories.CourseRunFactory(draft=True) + + # At this point, there are 6 total history records: three creates and + # three updates. The CourseRunFactory is apparently responsible for an + # update in addition to a create. + + def run_command(self, model_identifier): + call_command('deduplicate_course_metadata_history', model_identifier) + + def test_normal_case(self): + """ + Test the case where we have a random mix of creates and updates to several + different CourseRun records. + """ + # Induce a few history records: + # - 2 updates for courserun1 + # - 0 updates for courserun2 + # - 3 updates for courserun3 + self.courserun1.save() + self.courserun3.save() + self.courserun1.save() + self.courserun3.save() + factories.CourseRunFactory() # Toss in a fourth create to mix things up. + self.courserun3.save() + + courserun1_count_initial = len(CourseRun.history.filter(id=self.courserun1.id).all()) # pylint: disable=no-member + courserun2_count_initial = len(CourseRun.history.filter(id=self.courserun2.id).all()) # pylint: disable=no-member + courserun3_count_initial = len(CourseRun.history.filter(id=self.courserun3.id).all()) # pylint: disable=no-member + + # Ensure that there are multiple history records for each course run. For each + # course run, there should be 2 (baseline) + the amount we added at the + # beginning of this test. + self.assertEqual(courserun1_count_initial, 2 + 2) + self.assertEqual(courserun2_count_initial, 2 + 0) + self.assertEqual(courserun3_count_initial, 2 + 3) + + self.run_command('course_metadata.CourseRun') + + courserun1_count_final = len(CourseRun.history.filter(id=self.courserun1.id).all()) # pylint: disable=no-member + courserun2_count_final = len(CourseRun.history.filter(id=self.courserun2.id).all()) # pylint: disable=no-member + courserun3_count_final = len(CourseRun.history.filter(id=self.courserun3.id).all()) # pylint: disable=no-member + + # Ensure that the only history records left are the 3 original creates. + self.assertEqual(courserun1_count_final, 1) + self.assertEqual(courserun2_count_final, 1) + self.assertEqual(courserun3_count_final, 1) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_delete_person_dups.py b/course_discovery/apps/course_metadata/management/commands/tests/test_delete_person_dups.py index bee18d3e04..27010289db 100644 --- a/course_discovery/apps/course_metadata/management/commands/tests/test_delete_person_dups.py +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_delete_person_dups.py @@ -1,15 +1,14 @@ -import mock +from unittest import mock from django.core.exceptions import ValidationError from django.core.management import CommandError, call_command from django.test import TestCase from course_discovery.apps.core.models import Partner -from course_discovery.apps.course_metadata.models import (CourseRun, DeletePersonDupsConfig, Endorsement, Person, - PersonSocialNetwork, Position, Program) +from course_discovery.apps.course_metadata.models import ( + CourseRun, DeletePersonDupsConfig, Endorsement, Person, PersonSocialNetwork, Position, Program +) from course_discovery.apps.course_metadata.tests import factories -from course_discovery.apps.publisher.models import CourseRun as PublisherCourseRun -from course_discovery.apps.publisher.tests import factories as publisher_factories class DeletePersonDupsCommandTests(TestCase): @@ -36,13 +35,6 @@ def setUp(self): self.program = factories.ProgramFactory(courses=[self.course], instructor_ordering=[ self.person, self.instructor1, self.instructor2, self.instructor3, ]) - self.publisher_course = publisher_factories.CourseFactory() - self.publisher_courserun1 = publisher_factories.CourseRunFactory(course=self.publisher_course, staff=[ - self.person, self.instructor1, self.instructor2, self.instructor3, - ]) - self.publisher_courserun2 = publisher_factories.CourseRunFactory(course=self.publisher_course, staff=[ - self.instructor1, self.instructor2, self.instructor3, self.person, - ]) def run_command(self, people=None, commit=True): command_args = ['--partner-code=' + self.partner.short_code] @@ -57,8 +49,8 @@ def call_command(self, *args): call_command('delete_person_dups', *args) def test_invalid_args(self): - partner_code = '--partner-code={}'.format(self.partner.short_code) - uuid_arg = '{}:{}'.format(self.person.uuid, self.target.uuid) + partner_code = f'--partner-code={self.partner.short_code}' + uuid_arg = f'{self.person.uuid}:{self.target.uuid}' with self.assertRaises(CommandError) as cm: self.call_command(partner_code) # no uuid @@ -124,39 +116,21 @@ def test_normal_run(self): self.assertListEqual(list(Program.objects.get(id=self.program.id).instructor_ordering.all()), [ self.target, self.instructor1, self.instructor2, self.instructor3, ]) - self.assertListEqual(list(PublisherCourseRun.objects.get(id=self.publisher_courserun1.id).staff.all()), [ - self.target, self.instructor1, self.instructor2, self.instructor3, - ]) - self.assertListEqual(list(PublisherCourseRun.objects.get(id=self.publisher_courserun2.id).staff.all()), [ - self.instructor1, self.instructor2, self.instructor3, self.target, - ]) def test_target_already_present(self): + # pylint: disable=no-member # Change everything to include target. We expect that target's place isn't altered. - self.courserun1.staff = list(self.courserun1.staff.all()) + [self.target] - self.courserun1.save() - self.courserun2.staff = list(self.courserun2.staff.all()) + [self.target] - self.courserun2.save() - self.publisher_courserun1.staff = list(self.publisher_courserun1.staff.all()) + [self.target] - self.publisher_courserun1.save() - # This one is reversed, because target would normally be on end anyway. So put it first instead. - self.publisher_courserun2.staff = [self.target] + list(self.publisher_courserun2.staff.all()) - self.publisher_courserun2.save() - self.program.instructor_ordering = list(self.program.instructor_ordering.all()) + [self.target] - self.program.save() + self.courserun1.staff.set(list(self.courserun1.staff.all()) + [self.target]) + self.courserun2.staff.set(list(self.courserun2.staff.all()) + [self.target]) + self.program.instructor_ordering.set(list(self.program.instructor_ordering.all()) + [self.target]) expected = [self.instructor1, self.instructor2, self.instructor3, self.target] - expected_first = [self.target, self.instructor1, self.instructor2, self.instructor3] self.run_command() self.assertListEqual(list(CourseRun.objects.get(id=self.courserun1.id).staff.all()), expected) self.assertListEqual(list(CourseRun.objects.get(id=self.courserun2.id).staff.all()), expected) self.assertListEqual(list(Program.objects.get(id=self.program.id).instructor_ordering.all()), expected) - self.assertListEqual(list(PublisherCourseRun.objects.get(id=self.publisher_courserun1.id).staff.all()), - expected) - self.assertListEqual(list(PublisherCourseRun.objects.get(id=self.publisher_courserun2.id).staff.all()), - expected_first) def test_multiple_people(self): self.run_command(people=[(self.person, self.target), (self.instructor1, self.instructor2)]) @@ -179,12 +153,6 @@ def test_multiple_people(self): self.assertListEqual(list(Program.objects.get(id=self.program.id).instructor_ordering.all()), [ self.target, self.instructor2, self.instructor3, ]) - self.assertListEqual(list(PublisherCourseRun.objects.get(id=self.publisher_courserun1.id).staff.all()), [ - self.target, self.instructor2, self.instructor3, - ]) - self.assertListEqual(list(PublisherCourseRun.objects.get(id=self.publisher_courserun2.id).staff.all()), [ - self.instructor2, self.instructor3, self.target, - ]) def test_args_from_database(self): config = DeletePersonDupsConfig.get_solo() diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_download_course_images.py b/course_discovery/apps/course_metadata/management/commands/tests/test_download_course_images.py index 41912c1808..2eaeb6b9e0 100644 --- a/course_discovery/apps/course_metadata/management/commands/tests/test_download_course_images.py +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_download_course_images.py @@ -1,4 +1,5 @@ -import mock +from unittest import mock + import pytest import responses from django.core.management import call_command diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_load_program_fixture.py b/course_discovery/apps/course_metadata/management/commands/tests/test_load_program_fixture.py new file mode 100644 index 0000000000..e55e8850f1 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_load_program_fixture.py @@ -0,0 +1,398 @@ +import itertools +import json +import re + +import pytest +import responses +from django.contrib.sites.models import Site +from django.core.management import call_command +from django.core.serializers import json as json_serializer +from django.db import IntegrityError +from django.test import TestCase + +from course_discovery.apps.core.models import Partner +from course_discovery.apps.course_metadata.models import ( + Course, CourseRun, Curriculum, CurriculumCourseMembership, CurriculumProgramMembership, Organization, Program, + ProgramType, SeatType +) +from course_discovery.apps.course_metadata.tests.factories import ( + CourseFactory, CourseRunFactory, CurriculumCourseMembershipFactory, CurriculumFactory, + CurriculumProgramMembershipFactory, OrganizationFactory, PartnerFactory, ProgramFactory, ProgramTypeFactory, + SeatTypeFactory +) + + +class TestLoadProgramFixture(TestCase): + oauth_host = 'http://example.com' + catalog_host = 'http://discovery-example.com' + + def setUp(self): + super().setUp() + self.pk_generator = itertools.count(1) + + stored_site, created = Site.objects.get_or_create( # pylint: disable=unused-variable + domain='example.com' + ) + self.default_partner = Partner.objects.create( + site=stored_site, + name='edX', + short_code='edx' + ) + + SeatType.objects.all().delete() + ProgramType.objects.all().delete() + + self.partner = PartnerFactory(name='Test') + self.organization = OrganizationFactory(partner=self.partner) + self.seat_type_verified = SeatTypeFactory(name='Verified', slug='verified') + self.program_type_masters = ProgramTypeFactory( + name='Masters', + slug='masters', + applicable_seat_types=[self.seat_type_verified] + ) + + self.program_type_mm = ProgramTypeFactory( + name='MicroMasters', + slug='micromasters', + applicable_seat_types=[self.seat_type_verified] + ) + + self.course = CourseFactory(partner=self.partner, authoring_organizations=[self.organization]) + self.course_run = CourseRunFactory(course=self.course) + self.program = ProgramFactory( + type=self.program_type_masters, + partner=self.partner, + authoring_organizations=[self.organization] + ) + self.course_mm = CourseFactory(partner=self.partner, authoring_organizations=[self.organization]) + self.course_run_mm = CourseRunFactory(course=self.course) + self.program_mm = ProgramFactory( + type=self.program_type_mm, + partner=self.partner, + authoring_organizations=[self.organization], + courses=[self.course_mm] + ) + self.curriculum = CurriculumFactory(program=self.program) + self.curriculum_course_membership = CurriculumCourseMembershipFactory( + course=self.course, curriculum=self.curriculum + ) + self.curriculum_program_membership = CurriculumProgramMembershipFactory( + program=self.program_mm, curriculum=self.curriculum + ) + + self.program_2 = ProgramFactory( + type=self.program_type_masters, + partner=self.partner, + authoring_organizations=[self.organization] + ) + + self._mock_oauth_request() + + def _mock_oauth_request(self): + responses.add( + responses.POST, + f'{self.oauth_host}/oauth2/access_token', + json={'access_token': 'abcd', 'expires_in': 60}, + status=200, + ) + + def _mock_fixture_response(self, fixture): + url = re.compile('{catalog_host}/extensions/api/v1/program-fixture/'.format( + catalog_host=self.catalog_host, + )) + responses.add(responses.GET, url, body=fixture, status=200) + + def _call_load_program_fixture(self, program_uuids): + call_command( + 'load_program_fixture', + ','.join(program_uuids), + '--catalog-host', self.catalog_host, + '--oauth-host', self.oauth_host, + '--client-id', 'foo', + '--client-secret', 'bar', + ) + + def _set_up_masters_program_type(self): + """ + Set DB to have a conflicting program type on load. + """ + seat_type = SeatTypeFactory( + name='Something', + slug='something', + ) + existing_program_type = ProgramTypeFactory( + name='Masters', + name_t='Masters', + slug='masters', + applicable_seat_types=[seat_type] + ) + return existing_program_type + + def reset_db_state(self): + Partner.objects.all().exclude(short_code='edx').delete() + SeatType.objects.all().delete() + Course.objects.all().delete() + CourseRun.objects.all().delete() + Curriculum.objects.all().delete() + CurriculumCourseMembership.objects.all().delete() + CurriculumProgramMembership.objects.all().delete() + ProgramType.objects.all().delete() + Organization.objects.all().delete() + Program.objects.all().delete() + + @responses.activate + def test_load_programs(self): + + fixture = json_serializer.Serializer().serialize([ + self.program_type_masters, + self.program_type_mm, + self.organization, + self.seat_type_verified, + self.program, + self.program_2, + self.program_mm, + self.curriculum_program_membership, + self.curriculum_course_membership, + self.curriculum, + self.course, + self.course_mm, + self.course_run, + self.course_run_mm, + ]) + self._mock_fixture_response(fixture) + + requested_programs = [ + str(self.program.uuid), + str(self.program_2.uuid), + ] + self.reset_db_state() + self._call_load_program_fixture(requested_programs) + + # walk through program structure to validate correct + # objects have been created + stored_program = Program.objects.get(uuid=self.program.uuid) + stored_program_2 = Program.objects.get(uuid=self.program_2.uuid) + self.assertEqual(stored_program.title, self.program.title) + self.assertEqual(stored_program_2.title, self.program_2.title) + + stored_organization = stored_program.authoring_organizations.first() + self.assertEqual(stored_organization.name, self.organization.name) + + # partner should use existing edx value + self.assertEqual(stored_program.partner, self.default_partner) + self.assertEqual(stored_organization.partner, self.default_partner) + + stored_program_type = stored_program.type + self.assertEqual(stored_program_type.name_t, self.program_type_masters.name) + + stored_seat_type = stored_program_type.applicable_seat_types.first() + self.assertEqual(stored_seat_type.name, self.seat_type_verified.name) + + stored_curriculum = stored_program.curricula.first() + self.assertEqual(stored_curriculum.uuid, self.curriculum.uuid) + + stored_course = stored_curriculum.course_curriculum.first() + self.assertEqual(stored_course.key, self.course.key) + + stored_mm = stored_curriculum.program_curriculum.first() + self.assertEqual(stored_mm.uuid, self.program_mm.uuid) + + stored_course_run = stored_course.course_runs.first() + self.assertEqual(stored_course_run.key, self.course_run.key) + + @responses.activate + def test_update_existing_program_type(self): + + fixture = json_serializer.Serializer().serialize([ + self.organization, + self.seat_type_verified, + self.program_type_masters, + self.program, + ]) + self._mock_fixture_response(fixture) + self.reset_db_state() + + existing_program_type = self._set_up_masters_program_type() + + self._call_load_program_fixture([str(self.program.uuid)]) + + stored_program = Program.objects.get(uuid=self.program.uuid) + + # assert existing DB value is used + stored_program_type = stored_program.type + self.assertEqual(stored_program_type, existing_program_type) + + # assert existing DB value is updated to match fixture + stored_seat_types = list(stored_program_type.applicable_seat_types.all()) + self.assertEqual(len(stored_seat_types), 1) + self.assertEqual(stored_seat_types[0].name, self.seat_type_verified.name) + + @responses.activate + def test_remapping_courserun_programtype(self): + """ + Tests whether the remapping of program types works for the course run field that points to them + """ + self.course_run.expected_program_type = self.program_type_masters + self.course_run.save() + fixture = json_serializer.Serializer().serialize([ + self.program_type_masters, + self.program_type_mm, + self.organization, + self.seat_type_verified, + self.program, + self.program_mm, + self.curriculum_program_membership, + self.curriculum_course_membership, + self.curriculum, + self.course, + self.course_mm, + self.course_run, + ]) + self._mock_fixture_response(fixture) + self.reset_db_state() + + existing_program_type = self._set_up_masters_program_type() + + self._call_load_program_fixture([str(self.program.uuid)]) + + stored_courserun = CourseRun.objects.get(key=self.course_run.key) + stored_program_type = stored_courserun.expected_program_type + + self.assertEqual(existing_program_type, stored_program_type) + + @responses.activate + def test_existing_seat_types(self): + + fixture = json_serializer.Serializer().serialize([ + self.organization, + self.seat_type_verified, + self.program_type_masters, + self.program, + ]) + self._mock_fixture_response(fixture) + self.reset_db_state() + + # create existing verified seat with different pk than fixture and + # a second seat type with the same pk but different values + new_pk = self.seat_type_verified.id + 1 + SeatType.objects.create(id=new_pk, name='Verified', slug='verified') + SeatType.objects.create(id=self.seat_type_verified.id, name='Test', slug='test') + self._call_load_program_fixture([str(self.program.uuid)]) + + stored_program = Program.objects.get(uuid=self.program.uuid) + stored_seat_type = stored_program.type.applicable_seat_types.first() + + self.assertEqual(stored_seat_type.id, new_pk) + self.assertEqual(stored_seat_type.name, self.seat_type_verified.name) + + @responses.activate + def test_fail_on_save_error(self): + + fixture = json_serializer.Serializer().serialize([ + self.organization, + ]) + + # Should not be able to save an organization without uuid + fixture_json = json.loads(fixture) + fixture_json[0]['fields']['uuid'] = None + fixture = json.dumps(fixture_json) + + self._mock_fixture_response(fixture) + self.reset_db_state() + + with pytest.raises(IntegrityError) as err: + self._call_load_program_fixture([str(self.program.uuid)]) + expected_msg = fr'Failed to save course_metadata.Organization\(pk={self.organization.id}\):' + assert re.match(expected_msg, str(err.value)) + + @responses.activate + def test_fail_on_constraint_error(self): + + # duplicate programs should successfully save but fail final constraint check + fixture = json_serializer.Serializer().serialize([ + self.program, + self.program, + self.seat_type_verified, + self.program_type_masters, + ]) + self._mock_fixture_response(fixture) + self.reset_db_state() + + with pytest.raises(IntegrityError) as err: + self._call_load_program_fixture([str(self.program.uuid)]) + expected_msg = ( + r'Checking database constraints failed trying to load fixtures. Unable to save program\(s\):' + ).format(pk=self.organization.id) + assert re.match(expected_msg, str(err.value)) + + @responses.activate + def test_ignore_program_external_key(self): + fixture = json_serializer.Serializer().serialize([ + self.organization, + self.seat_type_verified, + self.program_type_masters, + self.program, + ]) + self._mock_fixture_response(fixture) + self.reset_db_state() + + self._call_load_program_fixture([ + '{uuid}:{external_key}'.format( + uuid=str(self.program.uuid), + external_key='CS-104-FALL-2019' + ) + ]) + + Program.objects.get(uuid=self.program.uuid) + + @responses.activate + def test_update_existing_data(self): + + fixture = json_serializer.Serializer().serialize([ + self.organization, + self.seat_type_verified, + self.program_type_masters, + self.program, + self.curriculum, + self.course, + self.course_run, + self.curriculum_course_membership, + ]) + self._mock_fixture_response(fixture) + self._call_load_program_fixture([str(self.program.uuid)]) + + self.program.title = 'program-title-modified' + self.course.title = 'course-title-modified' + new_course = CourseFactory(partner=self.partner, authoring_organizations=[self.organization]) + new_course_run = CourseRunFactory(course=new_course) + new_course_membership = CurriculumCourseMembershipFactory(course=new_course, curriculum=self.curriculum) + + fixture = json_serializer.Serializer().serialize([ + self.organization, + self.seat_type_verified, + self.program_type_masters, + self.program, + self.curriculum, + self.course, + self.course_run, + self.curriculum_course_membership, + new_course_membership, + new_course, + new_course_run, + ]) + responses.reset() + self._mock_oauth_request() + self._mock_fixture_response(fixture) + self.reset_db_state() + self._call_load_program_fixture([str(self.program.uuid)]) + + stored_program = Program.objects.get(uuid=self.program.uuid) + self.assertEqual(stored_program.title, 'program-title-modified') + + stored_program_courses = stored_program.curricula.first().course_curriculum.all() + modified_existing_course = stored_program_courses.get(uuid=self.course.uuid) + stored_new_course = stored_program_courses.get(uuid=new_course.uuid) + + self.assertEqual(len(stored_program_courses), 2) + self.assertEqual(modified_existing_course.title, 'course-title-modified') + self.assertEqual(stored_new_course.key, new_course.key) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_modify_program_hooks.py b/course_discovery/apps/course_metadata/management/commands/tests/test_modify_program_hooks.py new file mode 100644 index 0000000000..0bb1c10ad8 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_modify_program_hooks.py @@ -0,0 +1,59 @@ +from unittest import mock + +from django.core.management import call_command +from django.test import TestCase + +from course_discovery.apps.course_metadata.models import BulkModifyProgramHookConfig +from course_discovery.apps.course_metadata.tests.factories import ProgramFactory + + +class ModifyProgramHooksCommandTest(TestCase): + LOGGER_PATH = 'course_discovery.apps.course_metadata.management.commands.modify_program_hooks.logger' + + def setUp(self): + super().setUp() + self.config = BulkModifyProgramHookConfig.get_solo() + + def testNormalRun(self): + program = ProgramFactory() + program1 = ProgramFactory() + self.config.program_hooks = '''{uuid}:Bananas in pajamas + {uuid1}:Are coming down the stairs'''.format(uuid=program.uuid, uuid1=program1.uuid) + self.config.save() + call_command('modify_program_hooks') + program.refresh_from_db() + program1.refresh_from_db() + self.assertEqual(program.marketing_hook, 'Bananas in pajamas') + self.assertEqual(program1.marketing_hook, 'Are coming down the stairs') + + def testWeirdCharactersInHookText(self): + program = ProgramFactory() + self.config.program_hooks = '%s:+:[{])(%%' % program.uuid + self.config.save() + call_command('modify_program_hooks') + program.refresh_from_db() + self.assertEqual(program.marketing_hook, '+:[{])(%') + + @mock.patch(LOGGER_PATH) + def testBadUUID(self, mock_logger): + self.config.program_hooks = 'not-a-UUID:bananas' + self.config.save() + call_command('modify_program_hooks') + mock_logger.warning.assert_called_with('Incorrectly formatted uuid "not-a-UUID"') + + @mock.patch(LOGGER_PATH) + def testProgramDoesntExist(self, mock_logger): + program = ProgramFactory() + uuid = program.uuid + self.config.program_hooks = '%s:bananas' % uuid + program.delete() + self.config.save() + call_command('modify_program_hooks') + mock_logger.warning.assert_called_with(f'Cannot find program with uuid {uuid}') + + @mock.patch(LOGGER_PATH) + def testUnreadableLine(self, mock_logger): + self.config.program_hooks = 'NopeNopeNope' + self.config.save() + call_command('modify_program_hooks') + mock_logger.warning.assert_called_with('Incorrectly formatted line NopeNopeNope') diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_publish_live_course_runs.py b/course_discovery/apps/course_metadata/management/commands/tests/test_publish_live_course_runs.py new file mode 100644 index 0000000000..e0eaa88f1f --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_publish_live_course_runs.py @@ -0,0 +1,61 @@ +import datetime +from unittest import mock + +import ddt +import pytz +from django.core.management import CommandError +from django.test import TestCase + +from course_discovery.apps.course_metadata.choices import CourseRunStatus +from course_discovery.apps.course_metadata.management.commands.publish_live_course_runs import Command +from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory + + +@ddt.ddt +@mock.patch('course_discovery.apps.course_metadata.models.CourseRun.publish') +class PublishLiveCourseRunsTests(TestCase): + def setUp(self): + super().setUp() + self.now = datetime.datetime.now(pytz.UTC) + self.past = self.now - datetime.timedelta(days=1) + + def handle(self): + Command().handle() + + @ddt.data( + (CourseRunStatus.Reviewed, -1, True), + (CourseRunStatus.Reviewed, None, False), + (CourseRunStatus.Reviewed, +1, False), + (CourseRunStatus.Published, -1, False), + (CourseRunStatus.Unpublished, -1, False), + ) + @ddt.unpack + def test_publish_conditions(self, status, delta, published, mock_publish): + time = delta and (self.now + datetime.timedelta(days=delta)) + CourseRunFactory(status=status, go_live_date=time) + + self.handle() + + self.assertEqual(mock_publish.call_count, 1 if published else 0) + + def test_ignores_drafts(self, mock_publish): + # Draft run doesn't get published + run = CourseRunFactory(draft=True, status=CourseRunStatus.Reviewed, go_live_date=self.past) + self.handle() + self.assertEqual(mock_publish.call_count, 0) + + # But sanity check by confirming that if it *is* an official version, it does. + run.draft = False + run.save() + self.handle() + self.assertEqual(mock_publish.call_count, 1) + + def test_exception_does_not_stop_publishing(self, mock_publish): + CourseRunFactory(status=CourseRunStatus.Reviewed, go_live_date=self.past) + CourseRunFactory(status=CourseRunStatus.Reviewed, go_live_date=self.past) + + mock_publish.side_effect = [Exception, None] + with self.assertRaises(CommandError): + self.handle() + + self.assertEqual(mock_publish.call_count, 2) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_publish_uuids_to_drupal.py b/course_discovery/apps/course_metadata/management/commands/tests/test_publish_uuids_to_drupal.py index 8fc9bfabed..2d0a1e5f63 100644 --- a/course_discovery/apps/course_metadata/management/commands/tests/test_publish_uuids_to_drupal.py +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_publish_uuids_to_drupal.py @@ -1,4 +1,5 @@ -import mock +from unittest import mock + from django.db.utils import IntegrityError from django.test import TestCase @@ -14,7 +15,7 @@ class TestPublishUuidsToDrupal(TestCase): def setUp(self): - super(TestPublishUuidsToDrupal, self).setUp() + super().setUp() self.partner = PartnerFactory() self.course_run = CourseRunFactory(course__partner=self.partner) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_refresh_course_metadata.py b/course_discovery/apps/course_metadata/management/commands/tests/test_refresh_course_metadata.py index e62398a4a8..a8f24d7aef 100644 --- a/course_discovery/apps/course_metadata/management/commands/tests/test_refresh_course_metadata.py +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_refresh_course_metadata.py @@ -1,55 +1,45 @@ import json +from unittest import mock import ddt -import jwt -import mock import responses from django.core.management import CommandError, call_command from django.test import TransactionTestCase +from waffle.testutils import override_switch +from course_discovery.apps.api.v1.tests.test_views.mixins import OAuth2Mixin from course_discovery.apps.core.tests.factories import PartnerFactory from course_discovery.apps.core.tests.utils import mock_api_callback from course_discovery.apps.course_metadata.data_loaders.analytics_api import AnalyticsAPIDataLoader from course_discovery.apps.course_metadata.data_loaders.api import ( - CoursesApiDataLoader, EcommerceApiDataLoader, OrganizationsApiDataLoader, ProgramsApiDataLoader -) -from course_discovery.apps.course_metadata.data_loaders.marketing_site import ( - CourseMarketingSiteDataLoader, SchoolMarketingSiteDataLoader, SponsorMarketingSiteDataLoader, - SubjectMarketingSiteDataLoader + CoursesApiDataLoader, EcommerceApiDataLoader, ProgramsApiDataLoader ) from course_discovery.apps.course_metadata.data_loaders.tests import mock_data from course_discovery.apps.course_metadata.management.commands.refresh_course_metadata import execute_parallel_loader -from course_discovery.apps.course_metadata.tests import toggle_switch +from course_discovery.apps.course_metadata.models import Image, Video from course_discovery.apps.course_metadata.tests.factories import CourseFactory JSON = 'application/json' -ACCESS_TOKEN = str(jwt.encode({'preferred_username': 'bob'}, 'secret'), 'utf-8') @ddt.ddt -class RefreshCourseMetadataCommandTests(TransactionTestCase): +class RefreshCourseMetadataCommandTests(OAuth2Mixin, TransactionTestCase): def setUp(self): - super(RefreshCourseMetadataCommandTests, self).setUp() + super().setUp() self.partner = PartnerFactory() partner = self.partner self.pipeline = [ - (SubjectMarketingSiteDataLoader, partner.marketing_site_url_root, None), - (SchoolMarketingSiteDataLoader, partner.marketing_site_url_root, None), - (SponsorMarketingSiteDataLoader, partner.marketing_site_url_root, None), - (CourseMarketingSiteDataLoader, partner.marketing_site_url_root, None), - (OrganizationsApiDataLoader, partner.organizations_api_url, None), (CoursesApiDataLoader, partner.courses_api_url, None), (EcommerceApiDataLoader, partner.ecommerce_api_url, 1), (ProgramsApiDataLoader, partner.programs_api_url, None), (AnalyticsAPIDataLoader, partner.analytics_url, 1), ] - self.kwargs = {'username': 'bob'} # Courses must exist for the refresh_course_metadata command to use multiple threads. If there are no # courses, the command won't risk race conditions between threads trying to create the same course. CourseFactory(partner=self.partner) - self.mock_access_token_api() + self.mock_access_token() def mock_apis(self): self.mock_organizations_api() @@ -58,23 +48,6 @@ def mock_apis(self): self.mock_marketing_courses_api() self.mock_programs_api() - def mock_access_token_api(self, requests_mock=None): - body = { - 'access_token': ACCESS_TOKEN, - 'expires_in': 30 - } - requests_mock = requests_mock or responses - - url = self.partner.oidc_url_root.strip('/') + '/access_token' - requests_mock.add_callback( - responses.POST, - url, - callback=mock_api_callback(url, body, results_key=False), - content_type=JSON - ) - - return body - def mock_organizations_api(self): bodies = mock_data.ORGANIZATIONS_API_BODIES url = self.partner.organizations_api_url + 'organizations/' @@ -134,19 +107,16 @@ def mock_programs_api(self): @mock.patch('course_discovery.apps.api.cache.set_api_timestamp') @mock.patch('course_discovery.apps.course_metadata.management.commands.refresh_course_metadata.set_api_timestamp') def test_refresh_course_metadata_serial(self, mock_set_api_timestamp, mock_receiver): - with responses.RequestsMock() as rsps: - self.mock_access_token_api(rsps) - self.mock_apis() + self.mock_apis() - with mock.patch('course_discovery.apps.course_metadata.management.commands.' - 'refresh_course_metadata.execute_loader') as mock_executor: - call_command('refresh_course_metadata') + with mock.patch('course_discovery.apps.course_metadata.management.commands.' + 'refresh_course_metadata.execute_loader', return_value=True) as mock_executor: + call_command('refresh_course_metadata') - # Set up expected calls - expected_calls = [mock.call(loader_class, self.partner, api_url, - ACCESS_TOKEN, 'JWT', max_workers or 7, False, **self.kwargs) - for loader_class, api_url, max_workers in self.pipeline] - mock_executor.assert_has_calls(expected_calls) + # Set up expected calls + expected_calls = [mock.call(loader_class, self.partner, api_url, max_workers or 7, False) + for loader_class, api_url, max_workers in self.pipeline] + mock_executor.assert_has_calls(expected_calls) # Verify that the API cache is invalidated once, and that it isn't # being done by the signal receiver. @@ -155,23 +125,19 @@ def test_refresh_course_metadata_serial(self, mock_set_api_timestamp, mock_recei @mock.patch('course_discovery.apps.api.cache.set_api_timestamp') @mock.patch('course_discovery.apps.course_metadata.management.commands.refresh_course_metadata.set_api_timestamp') + @override_switch('threaded_metadata_write', True) + @override_switch('parallel_refresh_pipeline', True) def test_refresh_course_metadata_parallel(self, mock_set_api_timestamp, mock_receiver): - for name in ['threaded_metadata_write', 'parallel_refresh_pipeline']: - toggle_switch(name) + self.mock_apis() - with responses.RequestsMock() as rsps: - self.mock_access_token_api(rsps) - self.mock_apis() + with mock.patch('concurrent.futures.ProcessPoolExecutor.submit') as mock_executor: + call_command('refresh_course_metadata') - with mock.patch('concurrent.futures.ProcessPoolExecutor.submit') as mock_executor: - call_command('refresh_course_metadata') - - # Set up expected calls - expected_calls = [mock.call(execute_parallel_loader, loader_class, - self.partner, api_url, ACCESS_TOKEN, - 'JWT', max_workers or 7, True, **self.kwargs) - for loader_class, api_url, max_workers in self.pipeline] - mock_executor.assert_has_calls(expected_calls, any_order=True) + # Set up expected calls + expected_calls = [mock.call(execute_parallel_loader, loader_class, + self.partner, api_url, max_workers or 7, True) + for loader_class, api_url, max_workers in self.pipeline] + mock_executor.assert_has_calls(expected_calls, any_order=True) # Verify that the API cache is invalidated once, and that it isn't # being done by the signal receiver. @@ -184,35 +150,28 @@ def test_refresh_course_metadata_with_invalid_partner_code(self): command_args = ['--partner_code=invalid'] call_command('refresh_course_metadata', *command_args) - def test_refresh_course_metadata_errors_with_no_token(self): - """ Verify an exception is raised and an error is logged if an access token is not acquired. """ - with mock.patch('edx_rest_api_client.client.EdxRestApiClient.get_oauth_access_token', side_effect=Exception): - logger = 'course_discovery.apps.course_metadata.management.commands.refresh_course_metadata.logger' - with mock.patch(logger) as mock_logger: - with self.assertRaises(Exception): - call_command('refresh_course_metadata') - expected_calls = [mock.call('No access token acquired through client_credential flow.')] - mock_logger.exception.assert_has_calls(expected_calls) - def test_refresh_course_metadata_with_loader_exception(self): """ Verify execution continues if an individual data loader fails. """ - with responses.RequestsMock() as rsps: - self.mock_access_token_api(rsps) - - logger_target = 'course_discovery.apps.course_metadata.management.commands.refresh_course_metadata.logger' - with mock.patch(logger_target) as mock_logger: + logger_target = 'course_discovery.apps.course_metadata.management.commands.refresh_course_metadata.logger' + with mock.patch(logger_target) as mock_logger: + with self.assertRaisesMessage(CommandError, 'One or more of the data loaders above failed.'): call_command('refresh_course_metadata') - loader_classes = ( - SubjectMarketingSiteDataLoader, - SchoolMarketingSiteDataLoader, - SponsorMarketingSiteDataLoader, - CourseMarketingSiteDataLoader, - OrganizationsApiDataLoader, - CoursesApiDataLoader, - EcommerceApiDataLoader, - ProgramsApiDataLoader, - AnalyticsAPIDataLoader, - ) - expected_calls = [mock.call('%s failed!', loader_class.__name__) for loader_class in loader_classes] - mock_logger.exception.assert_has_calls(expected_calls) + loader_classes = ( + CoursesApiDataLoader, + EcommerceApiDataLoader, + ProgramsApiDataLoader, + AnalyticsAPIDataLoader, + ) + expected_calls = [mock.call('%s failed!', loader_class.__name__) for loader_class in loader_classes] + mock_logger.exception.assert_has_calls(expected_calls) + + @mock.patch('course_discovery.apps.course_metadata.management.commands.refresh_course_metadata.delete_orphans') + def test_deletes_orphans(self, mock_delete_orphans): + """ Verify execution culls any orphans left behind. """ + # Don't bother setting anything up - we expect to delete orphans on success or failure + with self.assertRaisesMessage(CommandError, 'One or more of the data loaders above failed.'): + call_command('refresh_course_metadata') + + self.assertEqual(mock_delete_orphans.call_count, 2) + self.assertEqual({x[0][0] for x in mock_delete_orphans.call_args_list}, {Image, Video}) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_remove_redirects_from_courses.py b/course_discovery/apps/course_metadata/management/commands/tests/test_remove_redirects_from_courses.py new file mode 100644 index 0000000000..e612b6b78a --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_remove_redirects_from_courses.py @@ -0,0 +1,147 @@ +import ddt +from django.core.management import CommandError, call_command +from django.test import TestCase + +from course_discovery.apps.core.tests.factories import PartnerFactory +from course_discovery.apps.course_metadata.models import RemoveRedirectsConfig +from course_discovery.apps.course_metadata.tests.factories import CourseFactory + + +@ddt.ddt +class RemoveRedirectsFromCoursesCommandTests(TestCase): + def setUp(self): + super().setUp() + self.partner = PartnerFactory(marketing_site_api_password=None) + self.course1 = CourseFactory(partner=self.partner) + self.course2 = CourseFactory(partner=self.partner) + self.course1.url_slug_history.create(course=self.course1, url_slug='older_course1_slug', partner=self.partner) + self.course1.url_redirects.create(course=self.course1, partner=self.partner, value='/courses/course1') + self.course2.url_redirects.create(course=self.course2, partner=self.partner, value='/courses/course2') + + def test_missing_arguments(self): + with self.assertRaises(CommandError): + call_command('remove_redirects_from_courses') + + def test_conflicting_arguments(self): + with self.assertRaises(CommandError): + call_command('remove_redirects_from_courses', '--args-from-database', '--remove_all') + with self.assertRaises(CommandError): + call_command('remove_redirects_from_courses', '--remove_all', '-url_paths', '/a/path') + with self.assertRaises(CommandError): + call_command('remove_redirects_from_courses', '-url_paths', '/a/path', '--args-from-database') + + @ddt.data( + ('command_line', '--remove_all'), + ('database', '--args-from-database'), + ) + @ddt.unpack + def test_remove_all(self, argument_source, argument): + course1_active_url_slug = self.course1.active_url_slug + course2_active_url_slug = self.course2.active_url_slug + + if argument_source == 'database': + config = RemoveRedirectsConfig.get_solo() + config.remove_all = True + config.save() + call_command('remove_redirects_from_courses', argument) + + # make sure active url_slugs remain + self.assertEqual(self.course1.active_url_slug, course1_active_url_slug) + self.assertEqual(self.course1.url_slug_history.count(), 1) + self.assertEqual(self.course1.url_redirects.count(), 0) + + self.assertEqual(self.course2.active_url_slug, course2_active_url_slug) + self.assertEqual(self.course2.url_redirects.count(), 0) + + @ddt.data( + # test both argument sources, with and without backslash (should be backslash-insensitive) + ('command_line', '/course/ancient_course1_slug'), + ('database', '/course/ancient_course1_slug'), + ('command_line', 'course/ancient_course1_slug'), + ('database', 'course/ancient_course1_slug'), + ) + @ddt.unpack + def test_remove_specific_slug(self, argument_source, path): + course1_active_url_slug = self.course1.active_url_slug + self.course1.url_slug_history.create(course=self.course1, url_slug='ancient_course1_slug', partner=self.partner) + if argument_source == 'database': + config = RemoveRedirectsConfig.get_solo() + config.url_paths = path + config.save() + call_command('remove_redirects_from_courses', '--args-from-database') + else: + call_command('remove_redirects_from_courses', '-url_paths', path) + + course1_url_slugs = list(map(lambda x: x.url_slug, self.course1.url_slug_history.all())) + + # check we removed the relevant slug + self.assertNotIn('ancient_course1_slug', course1_url_slugs) + + # check we didn't remove anything else + self.assertIn(course1_active_url_slug, course1_url_slugs) + self.assertIn('older_course1_slug', course1_url_slugs) + + @ddt.data( + # test both argument sources, with and without backslash (should be backslash-sensitive) + ('command_line', '/courses/course1', True), + ('database', '/courses/course1', True), + ('command_line', 'courses/course1', False), + ('database', 'courses/course1', False), + ) + @ddt.unpack + def test_remove_specific_path(self, argument_source, path, is_removed): + self.course1.url_redirects.create(course=self.course1, partner=self.partner, value='/courses/course1/better') + if argument_source == 'database': + config = RemoveRedirectsConfig.get_solo() + config.url_paths = path + config.save() + call_command('remove_redirects_from_courses', '--args-from-database') + else: + call_command('remove_redirects_from_courses', '-url_paths', path) + + course1_url_paths = list(map(lambda x: x.value, self.course1.url_redirects.all())) + + # check we removed the relevant path + self.assertEqual('/courses/course1' not in course1_url_paths, is_removed) + + # check we didn't remove anything else + self.assertIn('/courses/course1/better', course1_url_paths) + + def test_cannot_remove_active_url_slug(self): + active_url_slug = self.course1.active_url_slug + call_command('remove_redirects_from_courses', '-url_paths', + f'/course/{active_url_slug}') + self.assertEqual(self.course1.active_url_slug, active_url_slug) + + def test_remove_multiple_specific_same_course(self): + active_url_slug = self.course1.active_url_slug + self.course1.url_redirects.create(course=self.course1, partner=self.partner, value='/courses/course1/better') + config = RemoveRedirectsConfig.get_solo() + config.url_paths = '/courses/course1/better /courses/course1 course/older_course1_slug' + config.save() + call_command('remove_redirects_from_courses', '--args-from-database') + + self.assertEqual(self.course1.active_url_slug, active_url_slug) + self.assertEqual(self.course1.url_slug_history.count(), 1) + self.assertEqual(self.course1.url_redirects.count(), 0) + + def test_remove_multiple_specific_different_courses(self): + active_url_slug = self.course1.active_url_slug + config = RemoveRedirectsConfig.get_solo() + config.url_paths = '/courses/course2 /courses/course1 course/older_course1_slug' + config.save() + call_command('remove_redirects_from_courses', '--args-from-database') + + self.assertEqual(self.course1.active_url_slug, active_url_slug) + self.assertEqual(self.course1.url_slug_history.count(), 1) + self.assertEqual(self.course1.url_redirects.count(), 0) + self.assertEqual(self.course2.url_redirects.count(), 0) + + def test_specific_paths_take_priority_in_database_config(self): + self.course1.url_redirects.create(course=self.course1, partner=self.partner, value='/courses/course1/better') + config = RemoveRedirectsConfig.get_solo() + config.url_paths = '/courses/course1' + config.remove_all = True + config.save() + call_command('remove_redirects_from_courses', '--args-from-database') + self.assertEqual(self.course1.url_redirects.count(), 1) diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_unpublish_inactive_runs.py b/course_discovery/apps/course_metadata/management/commands/tests/test_unpublish_inactive_runs.py new file mode 100644 index 0000000000..c80e5e696b --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_unpublish_inactive_runs.py @@ -0,0 +1,42 @@ +from unittest import mock + +from django.core.management import CommandError +from django.test import TestCase + +from course_discovery.apps.course_metadata.choices import CourseRunStatus +from course_discovery.apps.course_metadata.exceptions import UnpublishError +from course_discovery.apps.course_metadata.management.commands.unpublish_inactive_runs import Command +from course_discovery.apps.course_metadata.tests.factories import CourseFactory, CourseRunFactory + + +@mock.patch('course_discovery.apps.course_metadata.models.Course.unpublish_inactive_runs') +class PublishLiveCourseRunsTests(TestCase): + def handle(self): + Command().handle() + + def test_filtering_and_grouping(self, mock_unpublish): + course1 = CourseFactory() + course2 = CourseFactory() + course3 = CourseFactory() + run1 = CourseRunFactory(status=CourseRunStatus.Published, course=course2) # all intentionally out of order + _run2 = CourseRunFactory(status=CourseRunStatus.Unpublished, course=course2) + _run3 = CourseRunFactory(status=CourseRunStatus.Unpublished, course=course3) + run4 = CourseRunFactory(status=CourseRunStatus.Published, course=course1) + run5 = CourseRunFactory(status=CourseRunStatus.Published, course=course2) + + self.handle() + + self.assertNumQueries(3) + self.assertEqual(mock_unpublish.call_count, 2) + self.assertEqual(mock_unpublish.call_args_list[0], mock.call(published_runs={run4})) + self.assertEqual(mock_unpublish.call_args_list[1], mock.call(published_runs={run1, run5})) + + def test_exception_does_not_stop_command(self, mock_unpublish): + CourseRunFactory(status=CourseRunStatus.Published) + CourseRunFactory(status=CourseRunStatus.Published) + + mock_unpublish.side_effect = [UnpublishError, None] + with self.assertRaises(CommandError): + self.handle() + + self.assertEqual(mock_unpublish.call_count, 2) diff --git a/course_discovery/apps/course_metadata/management/commands/unpublish_inactive_runs.py b/course_discovery/apps/course_metadata/management/commands/unpublish_inactive_runs.py new file mode 100644 index 0000000000..dc7f69a3b9 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/unpublish_inactive_runs.py @@ -0,0 +1,52 @@ +import logging + +from django.core.management import BaseCommand, CommandError +from django.utils.translation import ugettext as _ + +from course_discovery.apps.course_metadata.choices import CourseRunStatus +from course_discovery.apps.course_metadata.exceptions import UnpublishError +from course_discovery.apps.course_metadata.models import CourseRun + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Unpublishes marketing site URLs from any old inactive course runs to newer active runs' + + def handle(self, *args, **options): + success = True + + # Since we know we will call unpublish_inactive_runs for nearly every single course in our catalog, let's + # try to optimize a little bit by only making one database query. We ask for all course runs, sort by course, + # then hand the set of published course runs into unpublish_inactive_runs. + published_runs = CourseRun.objects.filter(status=CourseRunStatus.Published).order_by('course').iterator() + + current_course = None + current_runs = set() + + # Iterate through all published runs, gather up all the runs for a given course, group them, and + # send them to unpublish_inactive_runs. + for run in published_runs: + if current_course and current_course != run.course: + success = self.update_course(current_course, current_runs) and success + current_runs = set() + + current_course = run.course + current_runs.add(run) + + # and handle the last group of runs too + if current_runs: + success = self.update_course(current_course, current_runs) and success + + if not success: + raise CommandError(_('One or more courses failed to unpublish.')) + + @staticmethod + def update_course(course, runs): + try: + if course.unpublish_inactive_runs(published_runs=runs): + logger.info(_('Successfully unpublished runs in course {key}').format(key=course.key)) + return True + except UnpublishError: + logger.exception(_('Failed to unpublish runs in course {key}').format(key=course.key)) + return False diff --git a/course_discovery/apps/course_metadata/managers.py b/course_discovery/apps/course_metadata/managers.py new file mode 100644 index 0000000000..7133bee7a4 --- /dev/null +++ b/course_discovery/apps/course_metadata/managers.py @@ -0,0 +1,26 @@ +from django.db import models +from django.db.models import Q + + +class DraftManager(models.Manager): + """ Model manager that hides draft rows unless you ask for them. """ + + def get_queryset(self): + return super().get_queryset().filter(draft=False) + + def _with_drafts(self): + return super().get_queryset() + + def filter_drafts(self, **kwargs): + """ + Acts like filter(), but prefers draft versions. + If a draft is not available, we give back the non-draft version. + """ + return self._with_drafts().filter(Q(draft=True) | Q(draft_version=None)).filter(**kwargs) + + def get_draft(self, **kwargs): + """ + Acts like get(), but prefers draft versions. (including raising exceptions like get does) + If a draft is not available, we give back the non-draft version. + """ + return self.filter_drafts(**kwargs).get() diff --git a/course_discovery/apps/course_metadata/migrations/0001_initial.py b/course_discovery/apps/course_metadata/migrations/0001_initial.py index 5ca237bf36..4e0fe77610 100644 --- a/course_discovery/apps/course_metadata/migrations/0001_initial.py +++ b/course_discovery/apps/course_metadata/migrations/0001_initial.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import django.db.models.deletion import django_extensions.db.fields import sortedm2m.fields @@ -41,7 +38,7 @@ class Migration(migrations.Migration): ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), ('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)), ('relation_type', models.CharField(max_length=63, choices=[('owner', 'Owner'), ('sponsor', 'Sponsor')])), - ('course', models.ForeignKey(to='course_metadata.Course')), + ('course', models.ForeignKey(to='course_metadata.Course', on_delete=django.db.models.deletion.CASCADE)), ], ), migrations.CreateModel( @@ -62,7 +59,7 @@ class Migration(migrations.Migration): ('min_effort', models.PositiveSmallIntegerField(help_text='Estimated minimum number of hours per week needed to complete a course run.', blank=True, null=True)), ('max_effort', models.PositiveSmallIntegerField(help_text='Estimated maximum number of hours per week needed to complete a course run.', blank=True, null=True)), ('pacing_type', models.CharField(db_index=True, blank=True, max_length=255, null=True, choices=[('self_paced', 'Self-paced'), ('instructor_paced', 'Instructor-paced')])), - ('course', models.ForeignKey(related_name='course_runs', to='course_metadata.Course')), + ('course', models.ForeignKey(related_name='course_runs', to='course_metadata.Course', on_delete=django.db.models.deletion.CASCADE)), ], options={ 'ordering': ('-modified', '-created'), @@ -236,7 +233,7 @@ class Migration(migrations.Migration): ('name', models.CharField(blank=True, max_length=255, null=True)), ('description', models.TextField(blank=True, null=True)), ('homepage_url', models.URLField(blank=True, max_length=255, null=True)), - ('logo_image', models.ForeignKey(to='course_metadata.Image', blank=True, null=True)), + ('logo_image', models.ForeignKey(to='course_metadata.Image', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'ordering': ('-modified', '-created'), @@ -255,7 +252,7 @@ class Migration(migrations.Migration): ('title', models.CharField(blank=True, max_length=255, null=True)), ('bio', models.TextField(blank=True, null=True)), ('organizations', models.ManyToManyField(blank=True, to='course_metadata.Organization')), - ('profile_image', models.ForeignKey(to='course_metadata.Image', blank=True, null=True)), + ('profile_image', models.ForeignKey(to='course_metadata.Image', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'verbose_name_plural': 'People', @@ -284,8 +281,8 @@ class Migration(migrations.Migration): ('upgrade_deadline', models.DateTimeField(blank=True, null=True)), ('credit_provider', models.CharField(blank=True, max_length=255, null=True)), ('credit_hours', models.IntegerField(blank=True, null=True)), - ('course_run', models.ForeignKey(related_name='seats', to='course_metadata.CourseRun')), - ('currency', models.ForeignKey(to='core.Currency')), + ('course_run', models.ForeignKey(related_name='seats', to='course_metadata.CourseRun', on_delete=django.db.models.deletion.CASCADE)), + ('currency', models.ForeignKey(to='core.Currency', on_delete=django.db.models.deletion.CASCADE)), ], ), migrations.CreateModel( @@ -307,7 +304,7 @@ class Migration(migrations.Migration): ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), ('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)), ('value', models.CharField(max_length=255)), - ('parent', models.ForeignKey(to='course_metadata.SyllabusItem', blank=True, related_name='children', null=True)), + ('parent', models.ForeignKey(to='course_metadata.SyllabusItem', blank=True, related_name='children', null=True, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'abstract': False, @@ -321,7 +318,7 @@ class Migration(migrations.Migration): ('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)), ('src', models.URLField(max_length=255, unique=True)), ('description', models.CharField(blank=True, max_length=255, null=True)), - ('image', models.ForeignKey(to='course_metadata.Image', blank=True, null=True)), + ('image', models.ForeignKey(to='course_metadata.Image', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'abstract': False, @@ -375,7 +372,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='courserun', name='image', - field=models.ForeignKey(default=None, to='course_metadata.Image', blank=True, null=True), + field=models.ForeignKey(default=None, to='course_metadata.Image', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='courserun', @@ -385,7 +382,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='courserun', name='language', - field=models.ForeignKey(to='ietf_language_tags.LanguageTag', blank=True, null=True), + field=models.ForeignKey(to='ietf_language_tags.LanguageTag', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='courserun', @@ -395,7 +392,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='courserun', name='syllabus', - field=models.ForeignKey(default=None, to='course_metadata.SyllabusItem', blank=True, null=True), + field=models.ForeignKey(default=None, to='course_metadata.SyllabusItem', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='courserun', @@ -405,12 +402,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='courserun', name='video', - field=models.ForeignKey(default=None, to='course_metadata.Video', blank=True, null=True), + field=models.ForeignKey(default=None, to='course_metadata.Video', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='courseorganization', name='organization', - field=models.ForeignKey(to='course_metadata.Organization'), + field=models.ForeignKey(to='course_metadata.Organization', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='course', @@ -420,12 +417,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='course', name='image', - field=models.ForeignKey(default=None, to='course_metadata.Image', blank=True, null=True), + field=models.ForeignKey(default=None, to='course_metadata.Image', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='course', name='level_type', - field=models.ForeignKey(default=None, to='course_metadata.LevelType', blank=True, null=True), + field=models.ForeignKey(default=None, to='course_metadata.LevelType', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='course', @@ -445,18 +442,18 @@ class Migration(migrations.Migration): migrations.AddField( model_name='course', name='video', - field=models.ForeignKey(default=None, to='course_metadata.Video', blank=True, null=True), + field=models.ForeignKey(default=None, to='course_metadata.Video', blank=True, null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AlterUniqueTogether( name='seat', - unique_together=set([('course_run', 'type', 'currency', 'credit_provider')]), + unique_together={('course_run', 'type', 'currency', 'credit_provider')}, ), migrations.AlterUniqueTogether( name='courseorganization', - unique_together=set([('course', 'organization', 'relation_type')]), + unique_together={('course', 'organization', 'relation_type')}, ), migrations.AlterIndexTogether( name='courseorganization', - index_together=set([('course', 'relation_type')]), + index_together={('course', 'relation_type')}, ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0001_squashed_0033_courserun_mobile_available.py b/course_discovery/apps/course_metadata/migrations/0001_squashed_0033_courserun_mobile_available.py index 129a458aa7..1b4c14b58d 100644 --- a/course_discovery/apps/course_metadata/migrations/0001_squashed_0033_courserun_mobile_available.py +++ b/course_discovery/apps/course_metadata/migrations/0001_squashed_0033_courserun_mobile_available.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.10 on 2016-10-25 17:11 -from __future__ import unicode_literals + import uuid @@ -441,31 +440,31 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='subject', - unique_together=set([('partner', 'name'), ('partner', 'slug'), ('partner', 'uuid')]), + unique_together={('partner', 'name'), ('partner', 'slug'), ('partner', 'uuid')}, ), migrations.AlterUniqueTogether( name='seat', - unique_together=set([('course_run', 'type', 'currency', 'credit_provider')]), + unique_together={('course_run', 'type', 'currency', 'credit_provider')}, ), migrations.AlterUniqueTogether( name='personsocialnetwork', - unique_together=set([('person', 'type')]), + unique_together={('person', 'type')}, ), migrations.AlterUniqueTogether( name='person', - unique_together=set([('partner', 'uuid')]), + unique_together={('partner', 'uuid')}, ), migrations.AlterUniqueTogether( name='organization', - unique_together=set([('partner', 'key'), ('partner', 'uuid')]), + unique_together={('partner', 'key'), ('partner', 'uuid')}, ), migrations.AlterUniqueTogether( name='courserunsocialnetwork', - unique_together=set([('course_run', 'type')]), + unique_together={('course_run', 'type')}, ), migrations.AlterUniqueTogether( name='course', - unique_together=set([('partner', 'key'), ('partner', 'uuid')]), + unique_together={('partner', 'key'), ('partner', 'uuid')}, ), # Data Migrations migrations.RunPython( diff --git a/course_discovery/apps/course_metadata/migrations/0002_auto_20160406_1644.py b/course_discovery/apps/course_metadata/migrations/0002_auto_20160406_1644.py index 0e8d9ec14d..c57586c7bb 100644 --- a/course_discovery/apps/course_metadata/migrations/0002_auto_20160406_1644.py +++ b/course_discovery/apps/course_metadata/migrations/0002_auto_20160406_1644.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0003_auto_20160523_1422.py b/course_discovery/apps/course_metadata/migrations/0003_auto_20160523_1422.py index b360dac57c..4763447c97 100644 --- a/course_discovery/apps/course_metadata/migrations/0003_auto_20160523_1422.py +++ b/course_discovery/apps/course_metadata/migrations/0003_auto_20160523_1422.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0004_program.py b/course_discovery/apps/course_metadata/migrations/0004_program.py index 6951efd29d..f9bfe8df88 100644 --- a/course_discovery/apps/course_metadata/migrations/0004_program.py +++ b/course_discovery/apps/course_metadata/migrations/0004_program.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import uuid import django_extensions.db.fields diff --git a/course_discovery/apps/course_metadata/migrations/0005_auto_20160713_0113.py b/course_discovery/apps/course_metadata/migrations/0005_auto_20160713_0113.py index 3bf2127360..e35638ca36 100644 --- a/course_discovery/apps/course_metadata/migrations/0005_auto_20160713_0113.py +++ b/course_discovery/apps/course_metadata/migrations/0005_auto_20160713_0113.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import uuid from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0006_auto_20160719_2052.py b/course_discovery/apps/course_metadata/migrations/0006_auto_20160719_2052.py index bf56a0c971..cc780d420a 100644 --- a/course_discovery/apps/course_metadata/migrations/0006_auto_20160719_2052.py +++ b/course_discovery/apps/course_metadata/migrations/0006_auto_20160719_2052.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0007_auto_20160720_1749.py b/course_discovery/apps/course_metadata/migrations/0007_auto_20160720_1749.py index a4a232dd1b..07cdd26b95 100644 --- a/course_discovery/apps/course_metadata/migrations/0007_auto_20160720_1749.py +++ b/course_discovery/apps/course_metadata/migrations/0007_auto_20160720_1749.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import django.db.models.deletion import django_extensions.db.fields import sortedm2m.fields from django.db import migrations, models @@ -21,7 +19,7 @@ class Migration(migrations.Migration): ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), ('type', models.CharField(choices=[('facebook', 'Facebook'), ('twitter', 'Twitter'), ('blog', 'Blog'), ('others', 'Others')], db_index=True, max_length=15)), ('value', models.CharField(max_length=500)), - ('course_run', models.ForeignKey(related_name='course_run_networks', to='course_metadata.CourseRun')), + ('course_run', models.ForeignKey(related_name='course_run_networks', to='course_metadata.CourseRun', on_delete=django.db.models.deletion.CASCADE)), ], options={ 'verbose_name_plural': 'CourseRun SocialNetwork', @@ -107,7 +105,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='personsocialnetwork', name='person', - field=models.ForeignKey(related_name='person_networks', to='course_metadata.Person'), + field=models.ForeignKey(related_name='person_networks', to='course_metadata.Person', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='person', @@ -121,10 +119,10 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='personsocialnetwork', - unique_together=set([('person', 'type')]), + unique_together={('person', 'type')}, ), migrations.AlterUniqueTogether( name='courserunsocialnetwork', - unique_together=set([('course_run', 'type')]), + unique_together={('course_run', 'type')}, ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0008_program_image.py b/course_discovery/apps/course_metadata/migrations/0008_program_image.py index f4f7336243..3300b05bc4 100644 --- a/course_discovery/apps/course_metadata/migrations/0008_program_image.py +++ b/course_discovery/apps/course_metadata/migrations/0008_program_image.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import django.db.models.deletion from django.db import migrations, models @@ -14,6 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='program', name='image', - field=models.ForeignKey(to='course_metadata.Image', blank=True, null=True, default=None), + field=models.ForeignKey(to='course_metadata.Image', blank=True, null=True, default=None, on_delete=django.db.models.deletion.CASCADE), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0009_auto_20160725_1751.py b/course_discovery/apps/course_metadata/migrations/0009_auto_20160725_1751.py index 4e6725e91a..d7b51737b3 100644 --- a/course_discovery/apps/course_metadata/migrations/0009_auto_20160725_1751.py +++ b/course_discovery/apps/course_metadata/migrations/0009_auto_20160725_1751.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models @@ -16,7 +13,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='course', name='partner', - field=models.ForeignKey(null=True, to='core.Partner'), + field=models.ForeignKey(null=True, to='core.Partner', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='historicalcourse', @@ -31,11 +28,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='organization', name='partner', - field=models.ForeignKey(null=True, to='core.Partner'), + field=models.ForeignKey(null=True, to='core.Partner', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='program', name='partner', - field=models.ForeignKey(null=True, to='core.Partner'), + field=models.ForeignKey(null=True, to='core.Partner', on_delete=django.db.models.deletion.CASCADE), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0010_auto_20160731_0226.py b/course_discovery/apps/course_metadata/migrations/0010_auto_20160731_0226.py index 942e0bd3e1..8724318729 100644 --- a/course_discovery/apps/course_metadata/migrations/0010_auto_20160731_0226.py +++ b/course_discovery/apps/course_metadata/migrations/0010_auto_20160731_0226.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0011_auto_20160805_1949.py b/course_discovery/apps/course_metadata/migrations/0011_auto_20160805_1949.py index ad693a14e7..7bee230d77 100644 --- a/course_discovery/apps/course_metadata/migrations/0011_auto_20160805_1949.py +++ b/course_discovery/apps/course_metadata/migrations/0011_auto_20160805_1949.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import django.db.models.deletion import django_extensions.db.fields import djchoices.choices @@ -23,7 +20,7 @@ class Migration(migrations.Migration): ('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)), ('corporation_name', models.CharField(max_length=128)), ('statement', models.TextField()), - ('image', models.ForeignKey(blank=True, to='course_metadata.Image', null=True)), + ('image', models.ForeignKey(blank=True, to='course_metadata.Image', null=True, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'ordering': ('-modified', '-created'), @@ -38,7 +35,7 @@ class Migration(migrations.Migration): ('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)), ('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)), ('quote', models.TextField()), - ('endorser', models.ForeignKey(to='course_metadata.Person')), + ('endorser', models.ForeignKey(to='course_metadata.Person', on_delete=django.db.models.deletion.CASCADE)), ], options={ 'ordering': ('-modified', '-created'), @@ -117,7 +114,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='organization', name='banner_image', - field=models.ForeignKey(blank=True, related_name='bannered_organizations', to='course_metadata.Image', null=True), + field=models.ForeignKey(blank=True, related_name='bannered_organizations', to='course_metadata.Image', null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='program', @@ -172,7 +169,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='program', name='video', - field=models.ForeignKey(default=None, blank=True, to='course_metadata.Video', null=True), + field=models.ForeignKey(default=None, blank=True, to='course_metadata.Video', null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='program', @@ -182,7 +179,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='organization', name='logo_image', - field=models.ForeignKey(blank=True, related_name='logoed_organizations', to='course_metadata.Image', null=True), + field=models.ForeignKey(blank=True, related_name='logoed_organizations', to='course_metadata.Image', null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AlterField( model_name='program', @@ -222,6 +219,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='program', name='type', - field=models.ForeignKey(blank=True, to='course_metadata.ProgramType', null=True), + field=models.ForeignKey(blank=True, to='course_metadata.ProgramType', null=True, on_delete=django.db.models.deletion.CASCADE), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0012_create_seat_types.py b/course_discovery/apps/course_metadata/migrations/0012_create_seat_types.py index b20e157ee0..0c399a5c6b 100644 --- a/course_discovery/apps/course_metadata/migrations/0012_create_seat_types.py +++ b/course_discovery/apps/course_metadata/migrations/0012_create_seat_types.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations SEAT_TYPES = ('Audit', 'Credit', 'Professional', 'Verified',) diff --git a/course_discovery/apps/course_metadata/migrations/0013_auto_20160809_1259.py b/course_discovery/apps/course_metadata/migrations/0013_auto_20160809_1259.py index 21842d14c9..3f804447b5 100644 --- a/course_discovery/apps/course_metadata/migrations/0013_auto_20160809_1259.py +++ b/course_discovery/apps/course_metadata/migrations/0013_auto_20160809_1259.py @@ -1,8 +1,6 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import uuid +import django.db.models.deletion import django_extensions.db.fields from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -60,7 +58,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='subject', name='partner', - field=models.ForeignKey(to='core.Partner', null=True), + field=models.ForeignKey(to='core.Partner', null=True, on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='subject', @@ -80,7 +78,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='subject', - unique_together=set([('partner', 'name'), ('partner', 'slug'), ('partner', 'uuid')]), + unique_together={('partner', 'name'), ('partner', 'slug'), ('partner', 'uuid')}, ), migrations.RunPython(update_subjects, reverse_code=migrations.RunPython.noop), migrations.AlterField( @@ -93,6 +91,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='subject', name='partner', - field=models.ForeignKey(to='core.Partner'), + field=models.ForeignKey(to='core.Partner', on_delete=django.db.models.deletion.CASCADE), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0014_auto_20160811_0436.py b/course_discovery/apps/course_metadata/migrations/0014_auto_20160811_0436.py index 1d5fa94a6f..2d0d5272e6 100644 --- a/course_discovery/apps/course_metadata/migrations/0014_auto_20160811_0436.py +++ b/course_discovery/apps/course_metadata/migrations/0014_auto_20160811_0436.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import uuid from django.db import migrations, models @@ -88,7 +85,7 @@ class Migration(migrations.Migration): migrations.RunPython(update_organizations, reverse_code=migrations.RunPython.noop), migrations.AlterUniqueTogether( name='organization', - unique_together=set([('partner', 'uuid'), ('partner', 'key')]), + unique_together={('partner', 'uuid'), ('partner', 'key')}, ), migrations.AlterField( model_name='historicalorganization', diff --git a/course_discovery/apps/course_metadata/migrations/0015_auto_20160813_2142.py b/course_discovery/apps/course_metadata/migrations/0015_auto_20160813_2142.py index 995b4188fe..26cca9da11 100644 --- a/course_discovery/apps/course_metadata/migrations/0015_auto_20160813_2142.py +++ b/course_discovery/apps/course_metadata/migrations/0015_auto_20160813_2142.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0016_auto_20160815_1438.py b/course_discovery/apps/course_metadata/migrations/0016_auto_20160815_1438.py index ca0e1ca4d1..c797415bff 100644 --- a/course_discovery/apps/course_metadata/migrations/0016_auto_20160815_1438.py +++ b/course_discovery/apps/course_metadata/migrations/0016_auto_20160815_1438.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0017_auto_20160815_2135.py b/course_discovery/apps/course_metadata/migrations/0017_auto_20160815_2135.py index dbf6bc1021..8f118cc083 100644 --- a/course_discovery/apps/course_metadata/migrations/0017_auto_20160815_2135.py +++ b/course_discovery/apps/course_metadata/migrations/0017_auto_20160815_2135.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import taggit.managers from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0018_auto_20160815_2252.py b/course_discovery/apps/course_metadata/migrations/0018_auto_20160815_2252.py index 6c72f77ffd..3d971246e6 100644 --- a/course_discovery/apps/course_metadata/migrations/0018_auto_20160815_2252.py +++ b/course_discovery/apps/course_metadata/migrations/0018_auto_20160815_2252.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import uuid import django.db.models.deletion @@ -30,7 +27,7 @@ class Migration(migrations.Migration): ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), ('title', models.CharField(max_length=255)), ('organization_override', models.CharField(max_length=255, blank=True, null=True)), - ('organization', models.ForeignKey(null=True, to='course_metadata.Organization', blank=True)), + ('organization', models.ForeignKey(null=True, to='course_metadata.Organization', blank=True, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'ordering': ('-modified', '-created'), @@ -107,7 +104,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='person', name='partner', - field=models.ForeignKey(null=True, to='core.Partner'), + field=models.ForeignKey(null=True, to='core.Partner', on_delete=django.db.models.deletion.CASCADE), ), migrations.AddField( model_name='person', @@ -126,12 +123,12 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='person', - unique_together=set([('partner', 'uuid')]), + unique_together={('partner', 'uuid')}, ), migrations.AddField( model_name='position', name='person', - field=models.OneToOneField(to='course_metadata.Person'), + field=models.OneToOneField(to='course_metadata.Person', on_delete=django.db.models.deletion.CASCADE), ), migrations.RemoveField( model_name='person', diff --git a/course_discovery/apps/course_metadata/migrations/0019_program_banner_image.py b/course_discovery/apps/course_metadata/migrations/0019_program_banner_image.py index 65beadcaf0..b13a25fb95 100644 --- a/course_discovery/apps/course_metadata/migrations/0019_program_banner_image.py +++ b/course_discovery/apps/course_metadata/migrations/0019_program_banner_image.py @@ -1,8 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import stdimage.models -import stdimage.utils +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath from django.db import migrations @@ -16,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='program', name='banner_image', - field=stdimage.models.StdImageField(upload_to=stdimage.utils.UploadToAutoSlugClassNameDir('uuid', path='/media/programs/banner_images'), null=True, blank=True), + field=stdimage.models.StdImageField(upload_to=UploadToFieldNamePath('uuid', path='/media/programs/banner_images'), null=True, blank=True), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0020_auto_20160819_1942.py b/course_discovery/apps/course_metadata/migrations/0020_auto_20160819_1942.py index 26ad83a33c..2cd6c0fcd0 100644 --- a/course_discovery/apps/course_metadata/migrations/0020_auto_20160819_1942.py +++ b/course_discovery/apps/course_metadata/migrations/0020_auto_20160819_1942.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import stdimage.models from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0021_auto_20160819_2005.py b/course_discovery/apps/course_metadata/migrations/0021_auto_20160819_2005.py index 42afea7e70..8aca08d76b 100644 --- a/course_discovery/apps/course_metadata/migrations/0021_auto_20160819_2005.py +++ b/course_discovery/apps/course_metadata/migrations/0021_auto_20160819_2005.py @@ -1,8 +1,6 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import uuid +import django.db.models.deletion import django_extensions.db.fields import sortedm2m.fields from django.db import migrations, models @@ -32,11 +30,11 @@ class Migration(migrations.Migration): migrations.RunPython(delete_partnerless_courses, reverse_code=migrations.RunPython.noop), migrations.AlterUniqueTogether( name='courseorganization', - unique_together=set([]), + unique_together=set(), ), migrations.AlterIndexTogether( name='courseorganization', - index_together=set([]), + index_together=set(), ), migrations.RemoveField( model_name='courseorganization', @@ -162,7 +160,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='course', name='partner', - field=models.ForeignKey(to='core.Partner'), + field=models.ForeignKey(to='core.Partner', on_delete=django.db.models.deletion.CASCADE), ), migrations.AlterField( model_name='historicalcourse', @@ -192,7 +190,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='course', - unique_together=set([('partner', 'key'), ('partner', 'uuid')]), + unique_together={('partner', 'key'), ('partner', 'uuid')}, ), migrations.RemoveField( model_name='course', diff --git a/course_discovery/apps/course_metadata/migrations/0022_remove_duplicate_courses.py b/course_discovery/apps/course_metadata/migrations/0022_remove_duplicate_courses.py index 9435ae6fc7..7d56ab4894 100644 --- a/course_discovery/apps/course_metadata/migrations/0022_remove_duplicate_courses.py +++ b/course_discovery/apps/course_metadata/migrations/0022_remove_duplicate_courses.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0023_auto_20160826_1912.py b/course_discovery/apps/course_metadata/migrations/0023_auto_20160826_1912.py index 4a9d1ccafd..12e2314c9f 100644 --- a/course_discovery/apps/course_metadata/migrations/0023_auto_20160826_1912.py +++ b/course_discovery/apps/course_metadata/migrations/0023_auto_20160826_1912.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0024_auto_20160901_1426.py b/course_discovery/apps/course_metadata/migrations/0024_auto_20160901_1426.py index e3ea77987f..52ff780e1e 100644 --- a/course_discovery/apps/course_metadata/migrations/0024_auto_20160901_1426.py +++ b/course_discovery/apps/course_metadata/migrations/0024_auto_20160901_1426.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import djchoices.choices from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0025_remove_program_category.py b/course_discovery/apps/course_metadata/migrations/0025_remove_program_category.py index f8d6238ee8..d57a1ec4b4 100644 --- a/course_discovery/apps/course_metadata/migrations/0025_remove_program_category.py +++ b/course_discovery/apps/course_metadata/migrations/0025_remove_program_category.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0026_auto_20160912_2146.py b/course_discovery/apps/course_metadata/migrations/0026_auto_20160912_2146.py index cb665c9482..08970ea33b 100644 --- a/course_discovery/apps/course_metadata/migrations/0026_auto_20160912_2146.py +++ b/course_discovery/apps/course_metadata/migrations/0026_auto_20160912_2146.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0027_auto_20160915_2038.py b/course_discovery/apps/course_metadata/migrations/0027_auto_20160915_2038.py index 4f8221234e..56cdd0ce26 100644 --- a/course_discovery/apps/course_metadata/migrations/0027_auto_20160915_2038.py +++ b/course_discovery/apps/course_metadata/migrations/0027_auto_20160915_2038.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0028_courserun_hidden.py b/course_discovery/apps/course_metadata/migrations/0028_courserun_hidden.py index ba42777d25..f87be91e0f 100644 --- a/course_discovery/apps/course_metadata/migrations/0028_courserun_hidden.py +++ b/course_discovery/apps/course_metadata/migrations/0028_courserun_hidden.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0029_auto_20160923_1306.py b/course_discovery/apps/course_metadata/migrations/0029_auto_20160923_1306.py index eed53ed8f0..395b9d6474 100644 --- a/course_discovery/apps/course_metadata/migrations/0029_auto_20160923_1306.py +++ b/course_discovery/apps/course_metadata/migrations/0029_auto_20160923_1306.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models from sortedm2m import fields, operations diff --git a/course_discovery/apps/course_metadata/migrations/0031_courserun_weeks_to_complete.py b/course_discovery/apps/course_metadata/migrations/0031_courserun_weeks_to_complete.py index faa39601ee..26cf192f2f 100644 --- a/course_discovery/apps/course_metadata/migrations/0031_courserun_weeks_to_complete.py +++ b/course_discovery/apps/course_metadata/migrations/0031_courserun_weeks_to_complete.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0032_auto_20161021_1636.py b/course_discovery/apps/course_metadata/migrations/0032_auto_20161021_1636.py index 91613e2a30..4c3e63400c 100644 --- a/course_discovery/apps/course_metadata/migrations/0032_auto_20161021_1636.py +++ b/course_discovery/apps/course_metadata/migrations/0032_auto_20161021_1636.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0033_courserun_mobile_available.py b/course_discovery/apps/course_metadata/migrations/0033_courserun_mobile_available.py index 15d76001a9..b83354e450 100644 --- a/course_discovery/apps/course_metadata/migrations/0033_courserun_mobile_available.py +++ b/course_discovery/apps/course_metadata/migrations/0033_courserun_mobile_available.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0034_auto_20161103_0855.py b/course_discovery/apps/course_metadata/migrations/0034_auto_20161103_0855.py index 84d47b6258..760a059e95 100644 --- a/course_discovery/apps/course_metadata/migrations/0034_auto_20161103_0855.py +++ b/course_discovery/apps/course_metadata/migrations/0034_auto_20161103_0855.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0035_auto_20161103_2129.py b/course_discovery/apps/course_metadata/migrations/0035_auto_20161103_2129.py index 391bed6589..b523cb3b30 100644 --- a/course_discovery/apps/course_metadata/migrations/0035_auto_20161103_2129.py +++ b/course_discovery/apps/course_metadata/migrations/0035_auto_20161103_2129.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.10 on 2016-11-03 21:29 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0036_course_canonical_course_run.py b/course_discovery/apps/course_metadata/migrations/0036_course_canonical_course_run.py index a1035bf977..bf5f6f1cf1 100644 --- a/course_discovery/apps/course_metadata/migrations/0036_course_canonical_course_run.py +++ b/course_discovery/apps/course_metadata/migrations/0036_course_canonical_course_run.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import django.db.models.deletion from django.db import migrations, models @@ -14,6 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='course', name='canonical_course_run', - field=models.OneToOneField(null=True, default=None, blank=True, to='course_metadata.CourseRun', related_name='canonical_for_course'), + field=models.OneToOneField(null=True, default=None, blank=True, to='course_metadata.CourseRun', related_name='canonical_for_course', on_delete=django.db.models.deletion.CASCADE), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0038_seat_sku.py b/course_discovery/apps/course_metadata/migrations/0038_seat_sku.py index 74e175d61e..24b7338d72 100644 --- a/course_discovery/apps/course_metadata/migrations/0038_seat_sku.py +++ b/course_discovery/apps/course_metadata/migrations/0038_seat_sku.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2016-11-23 09:21 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0039_programtype_logo_image.py b/course_discovery/apps/course_metadata/migrations/0039_programtype_logo_image.py index fd8755a7ae..2a5d857c8c 100644 --- a/course_discovery/apps/course_metadata/migrations/0039_programtype_logo_image.py +++ b/course_discovery/apps/course_metadata/migrations/0039_programtype_logo_image.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2016-12-13 10:57 -from __future__ import unicode_literals + import stdimage.models -import stdimage.utils +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath from django.db import migrations @@ -17,6 +16,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='programtype', name='logo_image', - field=stdimage.models.StdImageField(blank=True, help_text='Please provide an image file with transparent background', null=True, upload_to=stdimage.utils.UploadToAutoSlug('name', path='media/program_types/logo_images')), + field=stdimage.models.StdImageField(blank=True, help_text='Please provide an image file with transparent background', null=True, upload_to=UploadToFieldNamePath('name', path='media/program_types/logo_images')), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0040_auto_20161220_1644.py b/course_discovery/apps/course_metadata/migrations/0040_auto_20161220_1644.py index dcec2347b9..a0049800c7 100644 --- a/course_discovery/apps/course_metadata/migrations/0040_auto_20161220_1644.py +++ b/course_discovery/apps/course_metadata/migrations/0040_auto_20161220_1644.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2016-12-20 16:44 -from __future__ import unicode_literals + import django_extensions.db.fields from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0041_organization_certificate_logo_image_url.py b/course_discovery/apps/course_metadata/migrations/0041_organization_certificate_logo_image_url.py index 460473915d..56e44721ea 100644 --- a/course_discovery/apps/course_metadata/migrations/0041_organization_certificate_logo_image_url.py +++ b/course_discovery/apps/course_metadata/migrations/0041_organization_certificate_logo_image_url.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-17 21:14 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0042_auto_20170119_0918.py b/course_discovery/apps/course_metadata/migrations/0042_auto_20170119_0918.py index e823a7b027..9add47a65e 100644 --- a/course_discovery/apps/course_metadata/migrations/0042_auto_20170119_0918.py +++ b/course_discovery/apps/course_metadata/migrations/0042_auto_20170119_0918.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-19 09:18 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0043_courserun_course_overridden.py b/course_discovery/apps/course_metadata/migrations/0043_courserun_course_overridden.py index de618950ac..9f3deae3b0 100644 --- a/course_discovery/apps/course_metadata/migrations/0043_courserun_course_overridden.py +++ b/course_discovery/apps/course_metadata/migrations/0043_courserun_course_overridden.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-24 04:41 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0044_auto_20170131_1749.py b/course_discovery/apps/course_metadata/migrations/0044_auto_20170131_1749.py index 754d69898d..d1b34f0f8f 100644 --- a/course_discovery/apps/course_metadata/migrations/0044_auto_20170131_1749.py +++ b/course_discovery/apps/course_metadata/migrations/0044_auto_20170131_1749.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-31 17:49 -from __future__ import unicode_literals + import taggit_autosuggest.managers from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0045_person_profile_image.py b/course_discovery/apps/course_metadata/migrations/0045_person_profile_image.py index a7d0127ed4..3a908187e0 100644 --- a/course_discovery/apps/course_metadata/migrations/0045_person_profile_image.py +++ b/course_discovery/apps/course_metadata/migrations/0045_person_profile_image.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-23 19:51 -from __future__ import unicode_literals + import stdimage.models -import stdimage.utils +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath from django.db import migrations @@ -17,6 +16,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='person', name='profile_image', - field=stdimage.models.StdImageField(blank=True, null=True, upload_to=stdimage.utils.UploadToAutoSlug('uuid', path='media/people/profile_images')), + field=stdimage.models.StdImageField(blank=True, null=True, upload_to=UploadToFieldNamePath('uuid', path='media/people/profile_images')), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0046_courserun_reporting_type.py b/course_discovery/apps/course_metadata/migrations/0046_courserun_reporting_type.py index c7051be660..17bf249fab 100644 --- a/course_discovery/apps/course_metadata/migrations/0046_courserun_reporting_type.py +++ b/course_discovery/apps/course_metadata/migrations/0046_courserun_reporting_type.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-02-06 20:45 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0047_personwork.py b/course_discovery/apps/course_metadata/migrations/0047_personwork.py index 9d8766c1d8..a7da8eaf1b 100644 --- a/course_discovery/apps/course_metadata/migrations/0047_personwork.py +++ b/course_discovery/apps/course_metadata/migrations/0047_personwork.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-01-31 19:21 -from __future__ import unicode_literals + import django.db.models.deletion import django_extensions.db.fields diff --git a/course_discovery/apps/course_metadata/migrations/0048_dataloaderconfig.py b/course_discovery/apps/course_metadata/migrations/0048_dataloaderconfig.py index e81d9e76df..4af485090e 100644 --- a/course_discovery/apps/course_metadata/migrations/0048_dataloaderconfig.py +++ b/course_discovery/apps/course_metadata/migrations/0048_dataloaderconfig.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-02-21 20:11 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0049_courserun_eligible_for_financial_aid.py b/course_discovery/apps/course_metadata/migrations/0049_courserun_eligible_for_financial_aid.py index 888952be8a..4c9e34ecb9 100644 --- a/course_discovery/apps/course_metadata/migrations/0049_courserun_eligible_for_financial_aid.py +++ b/course_discovery/apps/course_metadata/migrations/0049_courserun_eligible_for_financial_aid.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.12 on 2017-03-20 11:30 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0050_person_profile_url.py b/course_discovery/apps/course_metadata/migrations/0050_person_profile_url.py index 76aafd49a3..c04f72eaa1 100644 --- a/course_discovery/apps/course_metadata/migrations/0050_person_profile_url.py +++ b/course_discovery/apps/course_metadata/migrations/0050_person_profile_url.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.11 on 2017-04-04 11:00 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0051_program_one_click_purchase_enabled.py b/course_discovery/apps/course_metadata/migrations/0051_program_one_click_purchase_enabled.py index a37f577106..5a862a8385 100644 --- a/course_discovery/apps/course_metadata/migrations/0051_program_one_click_purchase_enabled.py +++ b/course_discovery/apps/course_metadata/migrations/0051_program_one_click_purchase_enabled.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.12 on 2017-04-05 12:06 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0053_person_email.py b/course_discovery/apps/course_metadata/migrations/0053_person_email.py index 1bd91c290e..d8979c2ee0 100644 --- a/course_discovery/apps/course_metadata/migrations/0053_person_email.py +++ b/course_discovery/apps/course_metadata/migrations/0053_person_email.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.13 on 2017-04-27 11:49 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0054_update_program_type_slug_field.py b/course_discovery/apps/course_metadata/migrations/0054_update_program_type_slug_field.py index 514e8c0d3b..144dbace53 100644 --- a/course_discovery/apps/course_metadata/migrations/0054_update_program_type_slug_field.py +++ b/course_discovery/apps/course_metadata/migrations/0054_update_program_type_slug_field.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.13 on 2017-05-05 02:32 -from __future__ import unicode_literals + import django_extensions.db.fields from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0055_program_hidden.py b/course_discovery/apps/course_metadata/migrations/0055_program_hidden.py index ffdf41df9f..46318e892e 100644 --- a/course_discovery/apps/course_metadata/migrations/0055_program_hidden.py +++ b/course_discovery/apps/course_metadata/migrations/0055_program_hidden.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.7 on 2017-06-01 16:13 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0056_auto_20170620_1351.py b/course_discovery/apps/course_metadata/migrations/0056_auto_20170620_1351.py index 620c780a83..01cf6f83c5 100644 --- a/course_discovery/apps/course_metadata/migrations/0056_auto_20170620_1351.py +++ b/course_discovery/apps/course_metadata/migrations/0056_auto_20170620_1351.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.7 on 2017-06-20 13:51 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0057_auto_20170915_1528.py b/course_discovery/apps/course_metadata/migrations/0057_auto_20170915_1528.py index 4996a9ddf5..41f7c0120e 100644 --- a/course_discovery/apps/course_metadata/migrations/0057_auto_20170915_1528.py +++ b/course_discovery/apps/course_metadata/migrations/0057_auto_20170915_1528.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-09-15 15:28 -from __future__ import unicode_literals + import stdimage.models -import stdimage.utils +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath from django.db import migrations, models @@ -18,7 +17,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='course', name='image', - field=stdimage.models.StdImageField(blank=True, help_text='Please provide a course preview image', null=True, upload_to=stdimage.utils.UploadToAutoSlug('uuid', path='media/course/image')), + field=stdimage.models.StdImageField(blank=True, help_text='Please provide a course preview image', null=True, upload_to=UploadToFieldNamePath('uuid', path='media/course/image')), ), migrations.AddField( model_name='course', diff --git a/course_discovery/apps/course_metadata/migrations/0059_auto_20171002_1705.py b/course_discovery/apps/course_metadata/migrations/0059_auto_20171002_1705.py index bb38e69640..9103130678 100644 --- a/course_discovery/apps/course_metadata/migrations/0059_auto_20171002_1705.py +++ b/course_discovery/apps/course_metadata/migrations/0059_auto_20171002_1705.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-10-02 17:05 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0060_create_subjecttranslations_models.py b/course_discovery/apps/course_metadata/migrations/0060_create_subjecttranslations_models.py index be952cd79f..5c7a2364b4 100644 --- a/course_discovery/apps/course_metadata/migrations/0060_create_subjecttranslations_models.py +++ b/course_discovery/apps/course_metadata/migrations/0060_create_subjecttranslations_models.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-09-19 19:27 -from __future__ import unicode_literals + import django.db.models.deletion import django_extensions @@ -38,10 +37,10 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='subject', - unique_together=set([('partner', 'slug'), ('partner', 'uuid')]), + unique_together={('partner', 'slug'), ('partner', 'uuid')}, ), migrations.AlterUniqueTogether( name='subjecttranslation', - unique_together=set([('language_code', 'master')]), + unique_together={('language_code', 'master')}, ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0061_migrate_subjects_data.py b/course_discovery/apps/course_metadata/migrations/0061_migrate_subjects_data.py index 396ca2a282..99632acbf9 100644 --- a/course_discovery/apps/course_metadata/migrations/0061_migrate_subjects_data.py +++ b/course_discovery/apps/course_metadata/migrations/0061_migrate_subjects_data.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-09-11 17:06 -from __future__ import unicode_literals + import logging @@ -38,7 +37,7 @@ def backwards_func(apps, schema_editor): subject.save() # Note this only calls Model.save() except ObjectDoesNotExist: # nothing to migrate - logger.exception('Migrating data from SubjectTranslation for master_id={} DoesNotExist'.format(subject.pk)) + logger.exception(f'Migrating data from SubjectTranslation for master_id={subject.pk} DoesNotExist') class Migration(migrations.Migration): dependencies = [ diff --git a/course_discovery/apps/course_metadata/migrations/0062_courserun_license.py b/course_discovery/apps/course_metadata/migrations/0062_courserun_license.py index 33c4df4bc4..e7d5726691 100644 --- a/course_discovery/apps/course_metadata/migrations/0062_courserun_license.py +++ b/course_discovery/apps/course_metadata/migrations/0062_courserun_license.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-10-06 20:48 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0063_auto_20171005_1931.py b/course_discovery/apps/course_metadata/migrations/0063_auto_20171005_1931.py index bcdebda206..ec04b7f779 100644 --- a/course_discovery/apps/course_metadata/migrations/0063_auto_20171005_1931.py +++ b/course_discovery/apps/course_metadata/migrations/0063_auto_20171005_1931.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-10-05 19:31 -from __future__ import unicode_literals + import django_extensions.db.fields from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0064_auto_20171018_1528.py b/course_discovery/apps/course_metadata/migrations/0064_auto_20171018_1528.py index 557afb87c6..161ad778b0 100644 --- a/course_discovery/apps/course_metadata/migrations/0064_auto_20171018_1528.py +++ b/course_discovery/apps/course_metadata/migrations/0064_auto_20171018_1528.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-10-18 15:28 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0065_program_total_hours_of_effort.py b/course_discovery/apps/course_metadata/migrations/0065_program_total_hours_of_effort.py index a368873e33..88ad4c48d9 100644 --- a/course_discovery/apps/course_metadata/migrations/0065_program_total_hours_of_effort.py +++ b/course_discovery/apps/course_metadata/migrations/0065_program_total_hours_of_effort.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-10-20 13:40 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0066_auto_20171107_1707.py b/course_discovery/apps/course_metadata/migrations/0066_auto_20171107_1707.py index 642a5117d7..c2d836de2c 100644 --- a/course_discovery/apps/course_metadata/migrations/0066_auto_20171107_1707.py +++ b/course_discovery/apps/course_metadata/migrations/0066_auto_20171107_1707.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-07 17:07 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0067_auto_20171108_1432.py b/course_discovery/apps/course_metadata/migrations/0067_auto_20171108_1432.py index e901ebc00e..cdaf2ca93c 100644 --- a/course_discovery/apps/course_metadata/migrations/0067_auto_20171108_1432.py +++ b/course_discovery/apps/course_metadata/migrations/0067_auto_20171108_1432.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-08 14:32 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0068_auto_20171108_1614.py b/course_discovery/apps/course_metadata/migrations/0068_auto_20171108_1614.py index 152c7f04c4..cd13d29461 100644 --- a/course_discovery/apps/course_metadata/migrations/0068_auto_20171108_1614.py +++ b/course_discovery/apps/course_metadata/migrations/0068_auto_20171108_1614.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-08 16:14 -from __future__ import unicode_literals + import django.db.models.deletion import django_extensions.db.fields @@ -30,6 +29,6 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='courseentitlement', - unique_together=set([('course', 'mode')]), + unique_together={('course', 'mode')}, ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0069_courseentitlement_expires.py b/course_discovery/apps/course_metadata/migrations/0069_courseentitlement_expires.py index 6156d3bf33..ae8ff04ff5 100644 --- a/course_discovery/apps/course_metadata/migrations/0069_courseentitlement_expires.py +++ b/course_discovery/apps/course_metadata/migrations/0069_courseentitlement_expires.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-16 17:08 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0070_auto_20171127_1057.py b/course_discovery/apps/course_metadata/migrations/0070_auto_20171127_1057.py index 9bea390804..0552a4d7b8 100644 --- a/course_discovery/apps/course_metadata/migrations/0070_auto_20171127_1057.py +++ b/course_discovery/apps/course_metadata/migrations/0070_auto_20171127_1057.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-27 10:57 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0071_auto_20171128_1945.py b/course_discovery/apps/course_metadata/migrations/0071_auto_20171128_1945.py index 97906fc9c1..1565568723 100644 --- a/course_discovery/apps/course_metadata/migrations/0071_auto_20171128_1945.py +++ b/course_discovery/apps/course_metadata/migrations/0071_auto_20171128_1945.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-28 19:45 -from __future__ import unicode_literals + import uuid @@ -48,10 +47,10 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='topictranslation', - unique_together=set([('language_code', 'master')]), + unique_together={('language_code', 'master')}, ), migrations.AlterUniqueTogether( name='topic', - unique_together=set([('partner', 'uuid'), ('partner', 'slug')]), + unique_together={('partner', 'uuid'), ('partner', 'slug')}, ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0072_courseentitlement_partner.py b/course_discovery/apps/course_metadata/migrations/0072_courseentitlement_partner.py index a4b4403d99..e7f0593582 100644 --- a/course_discovery/apps/course_metadata/migrations/0072_courseentitlement_partner.py +++ b/course_discovery/apps/course_metadata/migrations/0072_courseentitlement_partner.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-12-07 19:07 -from __future__ import unicode_literals + import django.db.models.deletion from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0073_program_instructors.py b/course_discovery/apps/course_metadata/migrations/0073_program_instructors.py index c6ac6c8418..cf971a4c77 100644 --- a/course_discovery/apps/course_metadata/migrations/0073_program_instructors.py +++ b/course_discovery/apps/course_metadata/migrations/0073_program_instructors.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-11-29 15:20 -from __future__ import unicode_literals + import sortedm2m.fields from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0074_auto_20171212_2016.py b/course_discovery/apps/course_metadata/migrations/0074_auto_20171212_2016.py index 818598d83b..06c817411f 100644 --- a/course_discovery/apps/course_metadata/migrations/0074_auto_20171212_2016.py +++ b/course_discovery/apps/course_metadata/migrations/0074_auto_20171212_2016.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-12-12 20:16 -from __future__ import unicode_literals + import django.db.models.deletion import stdimage.models diff --git a/course_discovery/apps/course_metadata/migrations/0075_auto_20171211_1922.py b/course_discovery/apps/course_metadata/migrations/0075_auto_20171211_1922.py index 1db323a18a..ce67e399ba 100644 --- a/course_discovery/apps/course_metadata/migrations/0075_auto_20171211_1922.py +++ b/course_discovery/apps/course_metadata/migrations/0075_auto_20171211_1922.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-12-11 19:22 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0076_auto_20171219_1841.py b/course_discovery/apps/course_metadata/migrations/0076_auto_20171219_1841.py index 5d3e0d72d2..5b433f4cce 100644 --- a/course_discovery/apps/course_metadata/migrations/0076_auto_20171219_1841.py +++ b/course_discovery/apps/course_metadata/migrations/0076_auto_20171219_1841.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-12-19 18:41 -from __future__ import unicode_literals + import sortedm2m.fields import stdimage.models diff --git a/course_discovery/apps/course_metadata/migrations/0077_auto_20180126_1204.py b/course_discovery/apps/course_metadata/migrations/0077_auto_20180126_1204.py index 6f9f7e13ad..4e3173912d 100644 --- a/course_discovery/apps/course_metadata/migrations/0077_auto_20180126_1204.py +++ b/course_discovery/apps/course_metadata/migrations/0077_auto_20180126_1204.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-01-26 12:04 -from __future__ import unicode_literals + import sortedm2m.fields from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0077_auto_20180131_1956.py b/course_discovery/apps/course_metadata/migrations/0077_auto_20180131_1956.py index ddac724a29..04dbdd2a44 100644 --- a/course_discovery/apps/course_metadata/migrations/0077_auto_20180131_1956.py +++ b/course_discovery/apps/course_metadata/migrations/0077_auto_20180131_1956.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-01-31 19:56 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0078_merge_20180209_1044.py b/course_discovery/apps/course_metadata/migrations/0078_merge_20180209_1044.py index 84b6f72782..c4b7e033d8 100644 --- a/course_discovery/apps/course_metadata/migrations/0078_merge_20180209_1044.py +++ b/course_discovery/apps/course_metadata/migrations/0078_merge_20180209_1044.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-02-09 10:44 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0079_enable_program_default_true.py b/course_discovery/apps/course_metadata/migrations/0079_enable_program_default_true.py index fae03f8225..995d025603 100644 --- a/course_discovery/apps/course_metadata/migrations/0079_enable_program_default_true.py +++ b/course_discovery/apps/course_metadata/migrations/0079_enable_program_default_true.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-03-19 17:18 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0080_seat_bulk_sku.py b/course_discovery/apps/course_metadata/migrations/0080_seat_bulk_sku.py index 117db0bcff..0e7ab17e89 100644 --- a/course_discovery/apps/course_metadata/migrations/0080_seat_bulk_sku.py +++ b/course_discovery/apps/course_metadata/migrations/0080_seat_bulk_sku.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-03-09 19:38 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0081_auto_20180329_0718.py b/course_discovery/apps/course_metadata/migrations/0081_auto_20180329_0718.py index c9d75c5c13..e92958d67c 100644 --- a/course_discovery/apps/course_metadata/migrations/0081_auto_20180329_0718.py +++ b/course_discovery/apps/course_metadata/migrations/0081_auto_20180329_0718.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-03-29 07:18 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0082_person_salutation.py b/course_discovery/apps/course_metadata/migrations/0082_person_salutation.py index 67853deb52..45259fbd39 100644 --- a/course_discovery/apps/course_metadata/migrations/0082_person_salutation.py +++ b/course_discovery/apps/course_metadata/migrations/0082_person_salutation.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2018-03-30 12:18 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0083_auto_20180511_1406.py b/course_discovery/apps/course_metadata/migrations/0083_auto_20180511_1406.py index 2c4b91b931..a382dff78b 100644 --- a/course_discovery/apps/course_metadata/migrations/0083_auto_20180511_1406.py +++ b/course_discovery/apps/course_metadata/migrations/0083_auto_20180511_1406.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-05-11 14:06 -from __future__ import unicode_literals + import djchoices.choices from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0084_auto_20180522_1339.py b/course_discovery/apps/course_metadata/migrations/0084_auto_20180522_1339.py index 7993beb9da..cc1cf351b4 100644 --- a/course_discovery/apps/course_metadata/migrations/0084_auto_20180522_1339.py +++ b/course_discovery/apps/course_metadata/migrations/0084_auto_20180522_1339.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-05-22 13:39 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0085_creditpathway.py b/course_discovery/apps/course_metadata/migrations/0085_creditpathway.py index b88b42ffbe..e42638e581 100644 --- a/course_discovery/apps/course_metadata/migrations/0085_creditpathway.py +++ b/course_discovery/apps/course_metadata/migrations/0085_creditpathway.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-06 14:21 -from __future__ import unicode_literals + import django_extensions.db.fields import sortedm2m.fields diff --git a/course_discovery/apps/course_metadata/migrations/0086_auto_20180712_1854.py b/course_discovery/apps/course_metadata/migrations/0086_auto_20180712_1854.py index 199d2faf2b..3a086b6666 100644 --- a/course_discovery/apps/course_metadata/migrations/0086_auto_20180712_1854.py +++ b/course_discovery/apps/course_metadata/migrations/0086_auto_20180712_1854.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-12 18:54 -from __future__ import unicode_literals + import uuid diff --git a/course_discovery/apps/course_metadata/migrations/0087_auto_20180718_2016.py b/course_discovery/apps/course_metadata/migrations/0087_auto_20180718_2016.py index e2e7f64618..30cdb9f61e 100644 --- a/course_discovery/apps/course_metadata/migrations/0087_auto_20180718_2016.py +++ b/course_discovery/apps/course_metadata/migrations/0087_auto_20180718_2016.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-18 20:16 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0088_degreeprogramrelationship.py b/course_discovery/apps/course_metadata/migrations/0088_degreeprogramrelationship.py index c7e8ba4c0a..275f323599 100644 --- a/course_discovery/apps/course_metadata/migrations/0088_degreeprogramrelationship.py +++ b/course_discovery/apps/course_metadata/migrations/0088_degreeprogramrelationship.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-23 13:56 -from __future__ import unicode_literals + import uuid diff --git a/course_discovery/apps/course_metadata/migrations/0089_auto_20180725_1602.py b/course_discovery/apps/course_metadata/migrations/0089_auto_20180725_1602.py index f90167bb39..cae7f02d12 100644 --- a/course_discovery/apps/course_metadata/migrations/0089_auto_20180725_1602.py +++ b/course_discovery/apps/course_metadata/migrations/0089_auto_20180725_1602.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-25 16:02 -from __future__ import unicode_literals + import django.db.models.deletion from django.db import migrations, models @@ -30,6 +29,6 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='creditpathway', - unique_together=set([('partner', 'name')]), + unique_together={('partner', 'name')}, ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0090_degree_curriculum_reset.py b/course_discovery/apps/course_metadata/migrations/0090_degree_curriculum_reset.py index 0d7fb5742f..681150755b 100644 --- a/course_discovery/apps/course_metadata/migrations/0090_degree_curriculum_reset.py +++ b/course_discovery/apps/course_metadata/migrations/0090_degree_curriculum_reset.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-26 18:14 -from __future__ import unicode_literals + import uuid diff --git a/course_discovery/apps/course_metadata/migrations/0091_auto_20180727_1844.py b/course_discovery/apps/course_metadata/migrations/0091_auto_20180727_1844.py index 75f2ca0ef3..28f61b06aa 100644 --- a/course_discovery/apps/course_metadata/migrations/0091_auto_20180727_1844.py +++ b/course_discovery/apps/course_metadata/migrations/0091_auto_20180727_1844.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-27 18:44 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0092_auto_20180730_1756.py b/course_discovery/apps/course_metadata/migrations/0092_auto_20180730_1756.py index c3da3f3468..ee855f0871 100644 --- a/course_discovery/apps/course_metadata/migrations/0092_auto_20180730_1756.py +++ b/course_discovery/apps/course_metadata/migrations/0092_auto_20180730_1756.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-07-30 17:56 -from __future__ import unicode_literals + import django_extensions.db.fields import sortedm2m.fields diff --git a/course_discovery/apps/course_metadata/migrations/0093_auto_20180802_1652.py b/course_discovery/apps/course_metadata/migrations/0093_auto_20180802_1652.py index e38ea88fbf..20d6072924 100644 --- a/course_discovery/apps/course_metadata/migrations/0093_auto_20180802_1652.py +++ b/course_discovery/apps/course_metadata/migrations/0093_auto_20180802_1652.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-08-02 16:52 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0094_auto_20180803_1946.py b/course_discovery/apps/course_metadata/migrations/0094_auto_20180803_1946.py index 3289659963..25a3f9027d 100644 --- a/course_discovery/apps/course_metadata/migrations/0094_auto_20180803_1946.py +++ b/course_discovery/apps/course_metadata/migrations/0094_auto_20180803_1946.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-08-03 19:46 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0095_icontextpairing.py b/course_discovery/apps/course_metadata/migrations/0095_icontextpairing.py index ff9e2f9802..56b989895e 100644 --- a/course_discovery/apps/course_metadata/migrations/0095_icontextpairing.py +++ b/course_discovery/apps/course_metadata/migrations/0095_icontextpairing.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-08-06 15:14 -from __future__ import unicode_literals + import django.db.models.deletion import django_extensions.db.fields diff --git a/course_discovery/apps/course_metadata/migrations/0096_degree_lead_capture_list_name.py b/course_discovery/apps/course_metadata/migrations/0096_degree_lead_capture_list_name.py index 23cd181bea..cab3f6feaf 100644 --- a/course_discovery/apps/course_metadata/migrations/0096_degree_lead_capture_list_name.py +++ b/course_discovery/apps/course_metadata/migrations/0096_degree_lead_capture_list_name.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-08-08 14:20 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0097_degree_lead_capture_image.py b/course_discovery/apps/course_metadata/migrations/0097_degree_lead_capture_image.py index 93e276fc5a..a07435e8ca 100644 --- a/course_discovery/apps/course_metadata/migrations/0097_degree_lead_capture_image.py +++ b/course_discovery/apps/course_metadata/migrations/0097_degree_lead_capture_image.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-08 18:28 -from __future__ import unicode_literals + import django.db.models.deletion import django_extensions.db.fields import stdimage.models -import stdimage.utils +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath from django.db import migrations, models @@ -19,6 +18,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='degree', name='lead_capture_image', - field=stdimage.models.StdImageField(blank=True, help_text='Please provide an image file for the lead capture banner.', null=True, upload_to=stdimage.utils.UploadToAutoSlug('uuid', path='media/degree_marketing/lead_capture_images/')), + field=stdimage.models.StdImageField(blank=True, help_text='Please provide an image file for the lead capture banner.', null=True, upload_to=UploadToFieldNamePath('uuid', path='media/degree_marketing/lead_capture_images/')), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0098_degree_cost_and_deadline.py b/course_discovery/apps/course_metadata/migrations/0098_degree_cost_and_deadline.py index 94b32ac639..a4f6de71f8 100644 --- a/course_discovery/apps/course_metadata/migrations/0098_degree_cost_and_deadline.py +++ b/course_discovery/apps/course_metadata/migrations/0098_degree_cost_and_deadline.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import django.db.models.deletion import django_extensions.db.fields from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0099_micromasters_details.py b/course_discovery/apps/course_metadata/migrations/0099_micromasters_details.py index 365d4e48d3..fe6778312f 100644 --- a/course_discovery/apps/course_metadata/migrations/0099_micromasters_details.py +++ b/course_discovery/apps/course_metadata/migrations/0099_micromasters_details.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-09 15:33 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0100_details_fine_print.py b/course_discovery/apps/course_metadata/migrations/0100_details_fine_print.py index 50ff7aec70..787c766852 100644 --- a/course_discovery/apps/course_metadata/migrations/0100_details_fine_print.py +++ b/course_discovery/apps/course_metadata/migrations/0100_details_fine_print.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-15 16:54 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0101_auto_20180815_2017.py b/course_discovery/apps/course_metadata/migrations/0101_auto_20180815_2017.py index 64768f6892..5f1bc81ad0 100644 --- a/course_discovery/apps/course_metadata/migrations/0101_auto_20180815_2017.py +++ b/course_discovery/apps/course_metadata/migrations/0101_auto_20180815_2017.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-15 20:17 -from __future__ import unicode_literals + import uuid from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0102_auto_20180815_2017.py b/course_discovery/apps/course_metadata/migrations/0102_auto_20180815_2017.py index bbad3e6dbd..fd22012dc9 100644 --- a/course_discovery/apps/course_metadata/migrations/0102_auto_20180815_2017.py +++ b/course_discovery/apps/course_metadata/migrations/0102_auto_20180815_2017.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-15 20:17 -from __future__ import unicode_literals + import uuid from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0103_auto_20180815_2017.py b/course_discovery/apps/course_metadata/migrations/0103_auto_20180815_2017.py index b772fda313..dde4e0d8f7 100644 --- a/course_discovery/apps/course_metadata/migrations/0103_auto_20180815_2017.py +++ b/course_discovery/apps/course_metadata/migrations/0103_auto_20180815_2017.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-15 20:17 -from __future__ import unicode_literals + import uuid from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0104_auto_20180815_2017.py b/course_discovery/apps/course_metadata/migrations/0104_auto_20180815_2017.py index 95f439424b..2834517ad1 100644 --- a/course_discovery/apps/course_metadata/migrations/0104_auto_20180815_2017.py +++ b/course_discovery/apps/course_metadata/migrations/0104_auto_20180815_2017.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-15 20:17 -from __future__ import unicode_literals + from django.db import migrations @@ -18,6 +17,6 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='creditpathway', - unique_together=set([]), + unique_together=set(), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0105_auto_20180817_1754.py b/course_discovery/apps/course_metadata/migrations/0105_auto_20180817_1754.py index 414d971212..ac53ff803f 100644 --- a/course_discovery/apps/course_metadata/migrations/0105_auto_20180817_1754.py +++ b/course_discovery/apps/course_metadata/migrations/0105_auto_20180817_1754.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-17 17:54 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0106_pathway.py b/course_discovery/apps/course_metadata/migrations/0106_pathway.py index a477efe430..f4a9f70e6c 100644 --- a/course_discovery/apps/course_metadata/migrations/0106_pathway.py +++ b/course_discovery/apps/course_metadata/migrations/0106_pathway.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-08-20 17:57 -from __future__ import unicode_literals + from django.db import migrations, models import django.db.models.deletion diff --git a/course_discovery/apps/course_metadata/migrations/0107_auto_20180821_1340.py b/course_discovery/apps/course_metadata/migrations/0107_auto_20180821_1340.py index 19e3be4621..e0cc07cb1a 100644 --- a/course_discovery/apps/course_metadata/migrations/0107_auto_20180821_1340.py +++ b/course_discovery/apps/course_metadata/migrations/0107_auto_20180821_1340.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-21 13:40 -from __future__ import unicode_literals + from django.db import migrations, models @@ -23,4 +22,3 @@ class Migration(migrations.Migration): field=models.ImageField(blank=True, help_text='Provide a background image for the title section of the degree', null=True, upload_to='media/degree_marketing/campus_images/'), ), ] - \ No newline at end of file diff --git a/course_discovery/apps/course_metadata/migrations/0108_auto_20180822_1416.py b/course_discovery/apps/course_metadata/migrations/0108_auto_20180822_1416.py index fa8ef0ced4..ffc588c092 100644 --- a/course_discovery/apps/course_metadata/migrations/0108_auto_20180822_1416.py +++ b/course_discovery/apps/course_metadata/migrations/0108_auto_20180822_1416.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.11 on 2018-08-22 14:16 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0109_auto_20180822_1624.py b/course_discovery/apps/course_metadata/migrations/0109_auto_20180822_1624.py index ee359b16da..97ed906c51 100644 --- a/course_discovery/apps/course_metadata/migrations/0109_auto_20180822_1624.py +++ b/course_discovery/apps/course_metadata/migrations/0109_auto_20180822_1624.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-22 16:24 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0110_auto_20180824_1727.py b/course_discovery/apps/course_metadata/migrations/0110_auto_20180824_1727.py index e7af354a07..d12723ccc1 100644 --- a/course_discovery/apps/course_metadata/migrations/0110_auto_20180824_1727.py +++ b/course_discovery/apps/course_metadata/migrations/0110_auto_20180824_1727.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-24 17:27 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0111_pathway_pathway_type.py b/course_discovery/apps/course_metadata/migrations/0111_pathway_pathway_type.py index 2d8f223af3..ebfca1fc41 100644 --- a/course_discovery/apps/course_metadata/migrations/0111_pathway_pathway_type.py +++ b/course_discovery/apps/course_metadata/migrations/0111_pathway_pathway_type.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-28 13:25 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0112_degree_banner_border_color.py b/course_discovery/apps/course_metadata/migrations/0112_degree_banner_border_color.py index 6c63e9a3cd..18d26774cb 100644 --- a/course_discovery/apps/course_metadata/migrations/0112_degree_banner_border_color.py +++ b/course_discovery/apps/course_metadata/migrations/0112_degree_banner_border_color.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-08-29 01:41 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0113_brief_text_curriculum.py b/course_discovery/apps/course_metadata/migrations/0113_brief_text_curriculum.py index 89b85ecbe9..684fef3c60 100644 --- a/course_discovery/apps/course_metadata/migrations/0113_brief_text_curriculum.py +++ b/course_discovery/apps/course_metadata/migrations/0113_brief_text_curriculum.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-05 12:30 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0114_auto_20180905_1547.py b/course_discovery/apps/course_metadata/migrations/0114_auto_20180905_1547.py index 8026ec8797..375ed390c2 100644 --- a/course_discovery/apps/course_metadata/migrations/0114_auto_20180905_1547.py +++ b/course_discovery/apps/course_metadata/migrations/0114_auto_20180905_1547.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-05 15:47 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0115_increase_read_more_cutoff.py b/course_discovery/apps/course_metadata/migrations/0115_increase_read_more_cutoff.py index 6924268f0c..1da75c1997 100644 --- a/course_discovery/apps/course_metadata/migrations/0115_increase_read_more_cutoff.py +++ b/course_discovery/apps/course_metadata/migrations/0115_increase_read_more_cutoff.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-07 15:51 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0116_auto_20180912_0857.py b/course_discovery/apps/course_metadata/migrations/0116_auto_20180912_0857.py index f70cebd02a..fb7f5a3bed 100644 --- a/course_discovery/apps/course_metadata/migrations/0116_auto_20180912_0857.py +++ b/course_discovery/apps/course_metadata/migrations/0116_auto_20180912_0857.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-12 08:57 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0117_degree_mm_bg_image.py b/course_discovery/apps/course_metadata/migrations/0117_degree_mm_bg_image.py index f870c86bcd..4ba833c3fd 100644 --- a/course_discovery/apps/course_metadata/migrations/0117_degree_mm_bg_image.py +++ b/course_discovery/apps/course_metadata/migrations/0117_degree_mm_bg_image.py @@ -1,10 +1,9 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-20 16:16 -from __future__ import unicode_literals + from django.db import migrations import stdimage.models -import stdimage.utils +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath class Migration(migrations.Migration): @@ -17,6 +16,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='degree', name='micromasters_background_image', - field=stdimage.models.StdImageField(blank=True, help_text='Customized background image for the MicroMasters section.', null=True, upload_to=stdimage.utils.UploadToAutoSlug('uuid', path='media/degree_marketing/mm_images/')), + field=stdimage.models.StdImageField(blank=True, help_text='Customized background image for the MicroMasters section.', null=True, upload_to=UploadToFieldNamePath('uuid', path='media/degree_marketing/mm_images/')), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0118_auto_20180921_1534.py b/course_discovery/apps/course_metadata/migrations/0118_auto_20180921_1534.py index 2c5ab9513c..34a810ab9a 100644 --- a/course_discovery/apps/course_metadata/migrations/0118_auto_20180921_1534.py +++ b/course_discovery/apps/course_metadata/migrations/0118_auto_20180921_1534.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-21 15:34 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0119_auto_20180925_1542.py b/course_discovery/apps/course_metadata/migrations/0119_auto_20180925_1542.py index a4d26f3a7f..e091a045a4 100644 --- a/course_discovery/apps/course_metadata/migrations/0119_auto_20180925_1542.py +++ b/course_discovery/apps/course_metadata/migrations/0119_auto_20180925_1542.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-25 15:42 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0120_auto_20180926_1442.py b/course_discovery/apps/course_metadata/migrations/0120_auto_20180926_1442.py index f80ff36f03..6230209d19 100644 --- a/course_discovery/apps/course_metadata/migrations/0120_auto_20180926_1442.py +++ b/course_discovery/apps/course_metadata/migrations/0120_auto_20180926_1442.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-26 14:42 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0121_degree_image_naming.py b/course_discovery/apps/course_metadata/migrations/0121_degree_image_naming.py index 9778b9b09e..f0d0f2a50b 100644 --- a/course_discovery/apps/course_metadata/migrations/0121_degree_image_naming.py +++ b/course_discovery/apps/course_metadata/migrations/0121_degree_image_naming.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-26 16:04 -from __future__ import unicode_literals + import course_discovery.apps.course_metadata.utils from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0122_person_bio_language.py b/course_discovery/apps/course_metadata/migrations/0122_person_bio_language.py index 0e9e6cfb0c..311956e135 100644 --- a/course_discovery/apps/course_metadata/migrations/0122_person_bio_language.py +++ b/course_discovery/apps/course_metadata/migrations/0122_person_bio_language.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-09-24 15:33 -from __future__ import unicode_literals + from django.db import migrations, models import django.db.models.deletion diff --git a/course_discovery/apps/course_metadata/migrations/0123_auto_20181003_1836.py b/course_discovery/apps/course_metadata/migrations/0123_auto_20181003_1836.py index 841d77ebc8..6008b5a05b 100644 --- a/course_discovery/apps/course_metadata/migrations/0123_auto_20181003_1836.py +++ b/course_discovery/apps/course_metadata/migrations/0123_auto_20181003_1836.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-10-03 18:36 -from __future__ import unicode_literals + from django.db import migrations, models import django.db.models.deletion diff --git a/course_discovery/apps/course_metadata/migrations/0124_course_faq_and_learner_testimonials.py b/course_discovery/apps/course_metadata/migrations/0124_course_faq_and_learner_testimonials.py index 95f76cc9bb..b54d3786f0 100644 --- a/course_discovery/apps/course_metadata/migrations/0124_course_faq_and_learner_testimonials.py +++ b/course_discovery/apps/course_metadata/migrations/0124_course_faq_and_learner_testimonials.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-10-26 14:09 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0125_course_additional_information.py b/course_discovery/apps/course_metadata/migrations/0125_course_additional_information.py index 1fa6f30062..959ca2f7f9 100644 --- a/course_discovery/apps/course_metadata/migrations/0125_course_additional_information.py +++ b/course_discovery/apps/course_metadata/migrations/0125_course_additional_information.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-10-30 14:26 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0126_course_has_ofac_restrictions.py b/course_discovery/apps/course_metadata/migrations/0126_course_has_ofac_restrictions.py index b56c243a37..f135acc143 100644 --- a/course_discovery/apps/course_metadata/migrations/0126_course_has_ofac_restrictions.py +++ b/course_discovery/apps/course_metadata/migrations/0126_course_has_ofac_restrictions.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-06 18:04 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0127_remove_courserun_learner_testimonials.py b/course_discovery/apps/course_metadata/migrations/0127_remove_courserun_learner_testimonials.py index cc328beacf..770efd2e28 100644 --- a/course_discovery/apps/course_metadata/migrations/0127_remove_courserun_learner_testimonials.py +++ b/course_discovery/apps/course_metadata/migrations/0127_remove_courserun_learner_testimonials.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-07 17:16 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0128_auto_20181126_1929.py b/course_discovery/apps/course_metadata/migrations/0128_auto_20181126_1929.py index 63d0218e76..ffd1a06b4b 100644 --- a/course_discovery/apps/course_metadata/migrations/0128_auto_20181126_1929.py +++ b/course_discovery/apps/course_metadata/migrations/0128_auto_20181126_1929.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-26 19:29 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0129_auto_20181113_1415.py b/course_discovery/apps/course_metadata/migrations/0129_auto_20181113_1415.py index 2e00c869c0..553f22c468 100644 --- a/course_discovery/apps/course_metadata/migrations/0129_auto_20181113_1415.py +++ b/course_discovery/apps/course_metadata/migrations/0129_auto_20181113_1415.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-13 14:15 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0130_courserun_has_ofac_restrictions.py b/course_discovery/apps/course_metadata/migrations/0130_courserun_has_ofac_restrictions.py index f6c01ffbd6..a9ab596413 100644 --- a/course_discovery/apps/course_metadata/migrations/0130_courserun_has_ofac_restrictions.py +++ b/course_discovery/apps/course_metadata/migrations/0130_courserun_has_ofac_restrictions.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-20 15:18 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0131_person_major_works.py b/course_discovery/apps/course_metadata/migrations/0131_person_major_works.py index fcecaebb38..eb49f4d167 100644 --- a/course_discovery/apps/course_metadata/migrations/0131_person_major_works.py +++ b/course_discovery/apps/course_metadata/migrations/0131_person_major_works.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-21 14:32 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0132_person_works_to_major_works.py b/course_discovery/apps/course_metadata/migrations/0132_person_works_to_major_works.py index 09bfdcf3bd..ed8461cfbc 100644 --- a/course_discovery/apps/course_metadata/migrations/0132_person_works_to_major_works.py +++ b/course_discovery/apps/course_metadata/migrations/0132_person_works_to_major_works.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-21 14:39 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0133_remove_courserun_social_network_and_add_title_to_person_socialnetwork.py b/course_discovery/apps/course_metadata/migrations/0133_remove_courserun_social_network_and_add_title_to_person_socialnetwork.py index ab9c3bfd49..232a8c8117 100644 --- a/course_discovery/apps/course_metadata/migrations/0133_remove_courserun_social_network_and_add_title_to_person_socialnetwork.py +++ b/course_discovery/apps/course_metadata/migrations/0133_remove_courserun_social_network_and_add_title_to_person_socialnetwork.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-20 20:29 -from __future__ import unicode_literals + from django.db import migrations, models @@ -14,7 +13,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterUniqueTogether( name='courserunsocialnetwork', - unique_together=set([]), + unique_together=set(), ), migrations.RemoveField( model_name='courserunsocialnetwork', @@ -32,7 +31,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='personsocialnetwork', - unique_together=set([('person', 'type', 'title')]), + unique_together={('person', 'type', 'title')}, ), migrations.DeleteModel( name='CourseRunSocialNetwork', diff --git a/course_discovery/apps/course_metadata/migrations/0134_add_delete_person_dups_config.py b/course_discovery/apps/course_metadata/migrations/0134_add_delete_person_dups_config.py index 10b648edc7..8f78d1be7b 100644 --- a/course_discovery/apps/course_metadata/migrations/0134_add_delete_person_dups_config.py +++ b/course_discovery/apps/course_metadata/migrations/0134_add_delete_person_dups_config.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-03 20:56 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0135_remove_personwork.py b/course_discovery/apps/course_metadata/migrations/0135_remove_personwork.py index 823d27e38a..1e58f3ea58 100644 --- a/course_discovery/apps/course_metadata/migrations/0135_remove_personwork.py +++ b/course_discovery/apps/course_metadata/migrations/0135_remove_personwork.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-05 15:54 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0136_drupalpublishuuidconfig.py b/course_discovery/apps/course_metadata/migrations/0136_drupalpublishuuidconfig.py index 6f32256350..2a9c657f66 100644 --- a/course_discovery/apps/course_metadata/migrations/0136_drupalpublishuuidconfig.py +++ b/course_discovery/apps/course_metadata/migrations/0136_drupalpublishuuidconfig.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-05 21:12 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0137_personareaofexpertise.py b/course_discovery/apps/course_metadata/migrations/0137_personareaofexpertise.py index e6b7445d1e..e1deab9778 100644 --- a/course_discovery/apps/course_metadata/migrations/0137_personareaofexpertise.py +++ b/course_discovery/apps/course_metadata/migrations/0137_personareaofexpertise.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-11 17:24 -from __future__ import unicode_literals + from django.db import migrations, models import django.db.models.deletion diff --git a/course_discovery/apps/course_metadata/migrations/0138_profileimagedownloadconfig.py b/course_discovery/apps/course_metadata/migrations/0138_profileimagedownloadconfig.py index 77dcc234f3..883133b607 100644 --- a/course_discovery/apps/course_metadata/migrations/0138_profileimagedownloadconfig.py +++ b/course_discovery/apps/course_metadata/migrations/0138_profileimagedownloadconfig.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-12 13:50 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0139_drupalpublishuuidconfig_push_people.py b/course_discovery/apps/course_metadata/migrations/0139_drupalpublishuuidconfig_push_people.py index 5643be97d3..d4ff9fdab0 100644 --- a/course_discovery/apps/course_metadata/migrations/0139_drupalpublishuuidconfig_push_people.py +++ b/course_discovery/apps/course_metadata/migrations/0139_drupalpublishuuidconfig_push_people.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-12 20:59 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0140_remove_profile_url_and_fix_admin.py b/course_discovery/apps/course_metadata/migrations/0140_remove_profile_url_and_fix_admin.py index cb6a8c7c03..400080e7c5 100644 --- a/course_discovery/apps/course_metadata/migrations/0140_remove_profile_url_and_fix_admin.py +++ b/course_discovery/apps/course_metadata/migrations/0140_remove_profile_url_and_fix_admin.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-12 20:30 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0141_auto_20181221_1501.py b/course_discovery/apps/course_metadata/migrations/0141_auto_20181221_1501.py index 214e9ba972..9a3e0f52b8 100644 --- a/course_discovery/apps/course_metadata/migrations/0141_auto_20181221_1501.py +++ b/course_discovery/apps/course_metadata/migrations/0141_auto_20181221_1501.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-21 15:01 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0142_auto_20181226_2029.py b/course_discovery/apps/course_metadata/migrations/0142_auto_20181226_2029.py index 3b45b05a22..7243b1564d 100644 --- a/course_discovery/apps/course_metadata/migrations/0142_auto_20181226_2029.py +++ b/course_discovery/apps/course_metadata/migrations/0142_auto_20181226_2029.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-26 20:29 -from __future__ import unicode_literals + from django.db import migrations, models import taggit_autosuggest.managers diff --git a/course_discovery/apps/course_metadata/migrations/0143_remove_person_profile_image_url.py b/course_discovery/apps/course_metadata/migrations/0143_remove_person_profile_image_url.py index 2e85f69731..11dc6df38f 100644 --- a/course_discovery/apps/course_metadata/migrations/0143_remove_person_profile_image_url.py +++ b/course_discovery/apps/course_metadata/migrations/0143_remove_person_profile_image_url.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2019-01-02 14:29 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/course_metadata/migrations/0144_person_published.py b/course_discovery/apps/course_metadata/migrations/0144_person_published.py index 26d6630be9..e8073bd0df 100644 --- a/course_discovery/apps/course_metadata/migrations/0144_person_published.py +++ b/course_discovery/apps/course_metadata/migrations/0144_person_published.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-12-19 21:09 -from __future__ import unicode_literals + from django.db import migrations, models diff --git a/course_discovery/apps/course_metadata/migrations/0145_courserun_autoslug.py b/course_discovery/apps/course_metadata/migrations/0145_courserun_autoslug.py index f90dbef1f6..6d82ff85fe 100644 --- a/course_discovery/apps/course_metadata/migrations/0145_courserun_autoslug.py +++ b/course_discovery/apps/course_metadata/migrations/0145_courserun_autoslug.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2019-01-03 19:56 -from __future__ import unicode_literals + from django.db import migrations import django_extensions.db.fields diff --git a/course_discovery/apps/course_metadata/migrations/0146_remove_log_queries_switch.py b/course_discovery/apps/course_metadata/migrations/0146_remove_log_queries_switch.py index 4d8c46be2c..1f0cf07744 100644 --- a/course_discovery/apps/course_metadata/migrations/0146_remove_log_queries_switch.py +++ b/course_discovery/apps/course_metadata/migrations/0146_remove_log_queries_switch.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2019-01-17 20:18 -from __future__ import unicode_literals + from django.db import migrations diff --git a/course_discovery/apps/core/migrations/0006_user_referral_tracking_id.py b/course_discovery/apps/course_metadata/migrations/0147_courserun_invite_only.py similarity index 50% rename from course_discovery/apps/core/migrations/0006_user_referral_tracking_id.py rename to course_discovery/apps/course_metadata/migrations/0147_courserun_invite_only.py index 14651d15ae..f7d9db6cab 100644 --- a/course_discovery/apps/core/migrations/0006_user_referral_tracking_id.py +++ b/course_discovery/apps/course_metadata/migrations/0147_courserun_invite_only.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-04-07 12:16 from __future__ import unicode_literals from django.db import migrations, models @@ -7,13 +8,13 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0005_populate_currencies'), + ('course_metadata', '0146_remove_log_queries_switch'), ] operations = [ migrations.AddField( - model_name='user', - name='referral_tracking_id', - field=models.CharField(max_length=255, default='affiliate_partner', verbose_name=''), + model_name='courserun', + name='invite_only', + field=models.BooleanField(default=False), ), ] diff --git a/course_discovery/apps/course_metadata/migrations/0148_degree_hubspot_lead_capture_form_id.py b/course_discovery/apps/course_metadata/migrations/0148_degree_hubspot_lead_capture_form_id.py new file mode 100644 index 0000000000..ccf2a677f1 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0148_degree_hubspot_lead_capture_form_id.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-01-24 15:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0147_courserun_invite_only'), + ] + + operations = [ + migrations.AddField( + model_name='degree', + name='hubspot_lead_capture_form_id', + field=models.CharField(help_text='The Hubspot form ID for the lead capture form', max_length=128, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0149_auto_20190131_1954.py b/course_discovery/apps/course_metadata/migrations/0149_auto_20190131_1954.py new file mode 100644 index 0000000000..d878e52457 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0149_auto_20190131_1954.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-01-31 19:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0148_degree_hubspot_lead_capture_form_id'), + ] + + operations = [ + migrations.AlterField( + model_name='degree', + name='hubspot_lead_capture_form_id', + field=models.CharField(blank=True, help_text='The Hubspot form ID for the lead capture form', max_length=128, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0150_auto_20190201_1515.py b/course_discovery/apps/course_metadata/migrations/0150_auto_20190201_1515.py new file mode 100644 index 0000000000..14be552610 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0150_auto_20190201_1515.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-01 15:15 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0149_auto_20190131_1954'), + ] + + operations = [ + migrations.AlterField( + model_name='degreedeadline', + name='time', + field=models.CharField(blank=True, help_text='The time after which the deadline expires (e.g. 11:59 PM EST).', max_length=255), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0151_curriculum_program.py b/course_discovery/apps/course_metadata/migrations/0151_curriculum_program.py new file mode 100644 index 0000000000..4d95879e7e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0151_curriculum_program.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-01-29 15:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0150_auto_20190201_1515'), + ] + + operations = [ + migrations.AddField( + model_name='curriculum', + name='program', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='curricula', to='course_metadata.Program'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0152_remove_curriculum_degree.py b/course_discovery/apps/course_metadata/migrations/0152_remove_curriculum_degree.py new file mode 100644 index 0000000000..da3bc42121 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0152_remove_curriculum_degree.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-04 17:04 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0151_curriculum_program'), + ] + + operations = [ + migrations.RemoveField( + model_name='curriculum', + name='degree', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0153_curriculumcoursemembership_curriculumprogrammembership.py b/course_discovery/apps/course_metadata/migrations/0153_curriculumcoursemembership_curriculumprogrammembership.py new file mode 100644 index 0000000000..3d39a25f2f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0153_curriculumcoursemembership_curriculumprogrammembership.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-06 18:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0152_remove_curriculum_degree'), + ] + + operations = [ + migrations.CreateModel( + name='CurriculumCourseMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.Course')), + ('curriculum', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.Curriculum')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CurriculumProgramMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('curriculum', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.Curriculum')), + ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.Program')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0154_auto_20190206_2049.py b/course_discovery/apps/course_metadata/migrations/0154_auto_20190206_2049.py new file mode 100644 index 0000000000..0600c4485c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0154_auto_20190206_2049.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-06 20:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0153_curriculumcoursemembership_curriculumprogrammembership'), + ] + + operations = [ + migrations.AlterField( + model_name='curriculum', + name='course_curriculum', + field=models.ManyToManyField(related_name='degree_course_curricula', through='course_metadata.CurriculumCourseMembership', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='curriculum', + name='program_curriculum', + field=models.ManyToManyField(related_name='degree_program_curricula', through='course_metadata.CurriculumProgramMembership', to='course_metadata.Program'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0155_course_entitlement_default_currency.py b/course_discovery/apps/course_metadata/migrations/0155_course_entitlement_default_currency.py new file mode 100644 index 0000000000..202b423bec --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0155_course_entitlement_default_currency.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-06 21:09 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0154_auto_20190206_2049'), + ] + + operations = [ + migrations.AlterField( + model_name='courseentitlement', + name='currency', + field=models.ForeignKey(default='USD', on_delete=django.db.models.deletion.CASCADE, to='core.Currency'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0156_auto_20190207_1546.py b/course_discovery/apps/course_metadata/migrations/0156_auto_20190207_1546.py new file mode 100644 index 0000000000..9261ddab33 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0156_auto_20190207_1546.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-07 15:46 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0155_course_entitlement_default_currency'), + ] + + operations = [ + migrations.RemoveField( + model_name='degreecoursecurriculum', + name='course', + ), + migrations.RemoveField( + model_name='degreecoursecurriculum', + name='curriculum', + ), + migrations.RemoveField( + model_name='degreeprogramcurriculum', + name='curriculum', + ), + migrations.RemoveField( + model_name='degreeprogramcurriculum', + name='program', + ), + migrations.DeleteModel( + name='DegreeCourseCurriculum', + ), + migrations.DeleteModel( + name='DegreeProgramCurriculum', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0157_add_curriculum_name_active.py b/course_discovery/apps/course_metadata/migrations/0157_add_curriculum_name_active.py new file mode 100644 index 0000000000..a5de5c40cf --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0157_add_curriculum_name_active.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-05 15:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0156_auto_20190207_1546'), + ] + + operations = [ + migrations.AddField( + model_name='curriculum', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='curriculum', + name='name', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0158_curriculum_membership_is_active.py b/course_discovery/apps/course_metadata/migrations/0158_curriculum_membership_is_active.py new file mode 100644 index 0000000000..fad708728e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0158_curriculum_membership_is_active.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-08 20:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0157_add_curriculum_name_active'), + ] + + operations = [ + migrations.AddField( + model_name='curriculumcoursemembership', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='curriculumprogrammembership', + name='is_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0159_add_order_to_level_type.py b/course_discovery/apps/course_metadata/migrations/0159_add_order_to_level_type.py new file mode 100644 index 0000000000..8d9291b274 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0159_add_order_to_level_type.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-11 17:48 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def set_initial_order(apps, _schema_editor): + LevelType = apps.get_model('course_metadata', 'LevelType') + order = 0 + for item in LevelType.objects.all().order_by('id'): + order += 1 + item.order = order + item.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0158_curriculum_membership_is_active'), + ] + + operations = [ + migrations.AlterModelOptions( + name='leveltype', + options={'ordering': ('order',)}, + ), + migrations.AddField( + model_name='leveltype', + name='order', + field=models.PositiveSmallIntegerField(db_index=True, default=0), + ), + migrations.RunPython(set_initial_order, reverse_code=migrations.RunPython.noop), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0160_historicalcurriculummodels.py b/course_discovery/apps/course_metadata/migrations/0160_historicalcurriculummodels.py new file mode 100644 index 0000000000..cc3a52c829 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0160_historicalcurriculummodels.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-13 20:15 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0159_add_order_to_level_type'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalCurriculum', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(blank=True, db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ('marketing_text_brief', models.TextField(blank=True, help_text='A high-level overview of the degree\'s courseware. The "brief"\n text is the first 750 characters of "marketing_text" and must be\n valid HTML.', max_length=750, null=True)), + ('marketing_text', models.TextField(help_text="A high-level overview of the degree's courseware.", null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('program', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Program')), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical curriculum', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalCurriculumCourseMembership', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_active', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('course', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Course')), + ('curriculum', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Curriculum')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical curriculum course membership', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalCurriculumProgramMembership', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_active', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('curriculum', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Curriculum')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('program', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Program')), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical curriculum program membership', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalDegreeCost', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('description', models.CharField(help_text='Describes what the cost is for (e.g. Tuition)', max_length=255)), + ('amount', models.CharField(help_text='String-based field stating how much the cost is (e.g. $1000).', max_length=255)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('degree', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Degree')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical degree cost', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalDegreeDeadline', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('semester', models.CharField(help_text='Deadline applies for this semester (e.g. Spring 2019', max_length=255)), + ('name', models.CharField(help_text='Describes the deadline (e.g. Early Admission Deadline)', max_length=255)), + ('date', models.CharField(help_text='The date after which the deadline expires (e.g. January 1, 2019)', max_length=255)), + ('time', models.CharField(blank=True, help_text='The time after which the deadline expires (e.g. 11:59 PM EST).', max_length=255)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('degree', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Degree')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical degree deadline', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0161_add_course_editor_model.py b/course_discovery/apps/course_metadata/migrations/0161_add_course_editor_model.py new file mode 100644 index 0000000000..3281d93422 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0161_add_course_editor_model.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-15 21:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0160_historicalcurriculummodels'), + ] + + operations = [ + migrations.CreateModel( + name='CourseEditor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='editors', to='course_metadata.Course')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses_edited', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='courseeditor', + unique_together=set([('user', 'course')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0162_curriculum_course_run_exclusions.py b/course_discovery/apps/course_metadata/migrations/0162_curriculum_course_run_exclusions.py new file mode 100644 index 0000000000..de3c5ccf28 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0162_curriculum_course_run_exclusions.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-02-26 19:48 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0161_add_course_editor_model'), + ] + + operations = [ + migrations.CreateModel( + name='CurriculumCourseRunExclusion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('course_membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.CurriculumCourseMembership')), + ('course_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.CourseRun')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HistoricalCurriculumCourseRunExclusion', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('course_membership', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CurriculumCourseMembership')), + ('course_run', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseRun')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical curriculum course run exclusion', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name='curriculumcoursemembership', + name='course_run_exclusions', + field=models.ManyToManyField(related_name='curriculum_course_membership', through='course_metadata.CurriculumCourseRunExclusion', to='course_metadata.CourseRun'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0163_auto_20190318_1814.py b/course_discovery/apps/course_metadata/migrations/0163_auto_20190318_1814.py new file mode 100644 index 0000000000..0a22246539 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0163_auto_20190318_1814.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-03-18 18:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_partner_lms_commerce_api_url'), + ('course_metadata', '0162_curriculum_course_run_exclusions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='courserun', + options={}, + ), + migrations.AddField( + model_name='course', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AddField( + model_name='courseentitlement', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AddField( + model_name='courserun', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AddField( + model_name='image', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AddField( + model_name='person', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AddField( + model_name='seat', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AddField( + model_name='video', + name='draft', + field=models.BooleanField(default=False, help_text='Is this a draft version?'), + ), + migrations.AlterField( + model_name='courserun', + name='key', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='image', + name='src', + field=models.URLField(max_length=255), + ), + migrations.AlterField( + model_name='video', + name='src', + field=models.URLField(max_length=255), + ), + migrations.AlterUniqueTogether( + name='course', + unique_together=set([('partner', 'uuid', 'draft'), ('partner', 'key', 'draft')]), + ), + migrations.AlterUniqueTogether( + name='courseentitlement', + unique_together=set([('course', 'mode', 'draft')]), + ), + migrations.AlterUniqueTogether( + name='courserun', + unique_together=set([('key', 'draft')]), + ), + migrations.AlterUniqueTogether( + name='image', + unique_together=set([('src', 'draft')]), + ), + migrations.AlterUniqueTogether( + name='person', + unique_together=set([('partner', 'uuid', 'draft')]), + ), + migrations.AlterUniqueTogether( + name='seat', + unique_together=set([('course_run', 'type', 'currency', 'credit_provider', 'draft')]), + ), + migrations.AlterUniqueTogether( + name='video', + unique_together=set([('src', 'draft')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0164_drop_draft_unique_video_image_person.py b/course_discovery/apps/course_metadata/migrations/0164_drop_draft_unique_video_image_person.py new file mode 100644 index 0000000000..0629628c89 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0164_drop_draft_unique_video_image_person.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-03-22 19:17 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_partner_lms_commerce_api_url'), + ('course_metadata', '0163_auto_20190318_1814'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='src', + field=models.URLField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name='video', + name='src', + field=models.URLField(max_length=255, unique=True), + ), + migrations.AlterUniqueTogether( + name='image', + unique_together=set([]), + ), + migrations.AlterUniqueTogether( + name='person', + unique_together=set([('partner', 'uuid')]), + ), + migrations.AlterUniqueTogether( + name='video', + unique_together=set([]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0165_drop_draft_field_video_image_person.py b/course_discovery/apps/course_metadata/migrations/0165_drop_draft_field_video_image_person.py new file mode 100644 index 0000000000..42d80cf7a7 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0165_drop_draft_field_video_image_person.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-03-25 15:37 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0164_drop_draft_unique_video_image_person'), + ] + + operations = [ + migrations.RemoveField( + model_name='image', + name='draft', + ), + migrations.RemoveField( + model_name='person', + name='draft', + ), + migrations.RemoveField( + model_name='video', + name='draft', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0166_add_draft_version_field.py b/course_discovery/apps/course_metadata/migrations/0166_add_draft_version_field.py new file mode 100644 index 0000000000..ff2b933cb8 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0166_add_draft_version_field.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-01 19:26 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0165_drop_draft_field_video_image_person'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='draft_version', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.Course'), + ), + migrations.AddField( + model_name='courseentitlement', + name='draft_version', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.CourseEntitlement'), + ), + migrations.AddField( + model_name='courserun', + name='draft_version', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.CourseRun'), + ), + migrations.AddField( + model_name='seat', + name='draft_version', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.Seat'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0167_draft_version_is_blankable.py b/course_discovery/apps/course_metadata/migrations/0167_draft_version_is_blankable.py new file mode 100644 index 0000000000..c5394a919c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0167_draft_version_is_blankable.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-03 15:13 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0166_add_draft_version_field'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='courseentitlement', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.CourseEntitlement'), + ), + migrations.AlterField( + model_name='courserun', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.CourseRun'), + ), + migrations.AlterField( + model_name='seat', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='official_version', to='course_metadata.Seat'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0168_auto_20190403_1606.py b/course_discovery/apps/course_metadata/migrations/0168_auto_20190403_1606.py new file mode 100644 index 0000000000..0f35b49ec9 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0168_auto_20190403_1606.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-03 16:06 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0167_draft_version_is_blankable'), + ] + + operations = [ + migrations.AlterField( + model_name='curriculumcoursemembership', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='curriculum_course_membership', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='seat', + name='type', + field=models.CharField(choices=[('honor', 'Honor'), ('audit', 'Audit'), ('verified', 'Verified'), ('professional', 'Professional'), ('credit', 'Credit'), ('masters', 'Masters')], max_length=63), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0169_auto_20190404_1733.py b/course_discovery/apps/course_metadata/migrations/0169_auto_20190404_1733.py new file mode 100644 index 0000000000..6d63561965 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0169_auto_20190404_1733.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-04 17:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.manager +import djchoices.choices + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0168_auto_20190403_1606'), + ] + + operations = [ + migrations.AlterModelManagers( + name='course', + managers=[ + ('everything', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='courseentitlement', + managers=[ + ('everything', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='courserun', + managers=[ + ('everything', django.db.models.manager.Manager()), + ], + ), + migrations.AlterModelManagers( + name='seat', + managers=[ + ('everything', django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name='courserun', + name='status', + field=models.CharField(choices=[('published', 'Published'), ('unpublished', 'Unpublished'), ('reviewed', 'Reviewed'), ('review_by_legal', 'Awaiting Review from Legal'), ('review_by_internal', 'Awaiting Internal Review')], db_index=True, default='unpublished', max_length=255, validators=[djchoices.choices.ChoicesValidator({'published': 'Published', 'review_by_internal': 'Awaiting Internal Review', 'review_by_legal': 'Awaiting Review from Legal', 'reviewed': 'Reviewed', 'unpublished': 'Unpublished'})]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0170_rename_official_version.py b/course_discovery/apps/course_metadata/migrations/0170_rename_official_version.py new file mode 100644 index 0000000000..de7d729095 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0170_rename_official_version.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-08 17:47 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0169_auto_20190404_1733'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_official_version', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='courseentitlement', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_official_version', to='course_metadata.CourseEntitlement'), + ), + migrations.AlterField( + model_name='courserun', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_official_version', to='course_metadata.CourseRun'), + ), + migrations.AlterField( + model_name='seat', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_official_version', to='course_metadata.Seat'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0171_courserun_go_live_date.py b/course_discovery/apps/course_metadata/migrations/0171_courserun_go_live_date.py new file mode 100644 index 0000000000..ba7199fda2 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0171_courserun_go_live_date.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-22 14:01 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0170_rename_official_version'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='go_live_date', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0172_historicalcourserun.py b/course_discovery/apps/course_metadata/migrations/0172_historicalcourserun.py new file mode 100644 index 0000000000..2dc7a57d45 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0172_historicalcourserun.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-24 16:33 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import djchoices.choices +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('ietf_language_tags', '0001_squashed_0005_fix_language_tag_names_again'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0171_courserun_go_live_date'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalCourseRun', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('draft', models.BooleanField(default=False, help_text='Is this a draft version?')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('key', models.CharField(max_length=255)), + ('status', models.CharField(choices=[('published', 'Published'), ('unpublished', 'Unpublished'), ('reviewed', 'Reviewed'), ('review_by_legal', 'Awaiting Review from Legal'), ('review_by_internal', 'Awaiting Internal Review')], db_index=True, default='unpublished', max_length=255, validators=[djchoices.choices.ChoicesValidator({'published': 'Published', 'review_by_internal': 'Awaiting Internal Review', 'review_by_legal': 'Awaiting Review from Legal', 'reviewed': 'Reviewed', 'unpublished': 'Unpublished'})])), + ('title_override', models.CharField(blank=True, default=None, help_text="Title specific for this run of a course. Leave this value blank to default to the parent course's title.", max_length=255, null=True)), + ('start', models.DateTimeField(blank=True, db_index=True, null=True)), + ('end', models.DateTimeField(blank=True, db_index=True, null=True)), + ('go_live_date', models.DateTimeField(blank=True, null=True)), + ('enrollment_start', models.DateTimeField(blank=True, null=True)), + ('enrollment_end', models.DateTimeField(blank=True, db_index=True, null=True)), + ('announcement', models.DateTimeField(blank=True, null=True)), + ('short_description_override', models.TextField(blank=True, default=None, help_text="Short description specific for this run of a course. Leave this value blank to default to the parent course's short_description attribute.", null=True)), + ('full_description_override', models.TextField(blank=True, default=None, help_text="Full description specific for this run of a course. Leave this value blank to default to the parent course's full_description attribute.", null=True)), + ('min_effort', models.PositiveSmallIntegerField(blank=True, help_text='Estimated minimum number of hours per week needed to complete a course run.', null=True)), + ('max_effort', models.PositiveSmallIntegerField(blank=True, help_text='Estimated maximum number of hours per week needed to complete a course run.', null=True)), + ('weeks_to_complete', models.PositiveSmallIntegerField(blank=True, help_text='Estimated number of weeks needed to complete this course run.', null=True)), + ('pacing_type', models.CharField(blank=True, choices=[('instructor_paced', 'Instructor-paced'), ('self_paced', 'Self-paced')], db_index=True, max_length=255, null=True, validators=[djchoices.choices.ChoicesValidator({'instructor_paced': 'Instructor-paced', 'self_paced': 'Self-paced'})])), + ('enrollment_count', models.IntegerField(blank=True, default=0, help_text='Total number of learners who have enrolled in this course run', null=True)), + ('recent_enrollment_count', models.IntegerField(blank=True, default=0, help_text='Total number of learners who have enrolled in this course run in the last 6 months', null=True)), + ('card_image_url', models.URLField(blank=True, null=True)), + ('hidden', models.BooleanField(default=False)), + ('mobile_available', models.BooleanField(default=False)), + ('course_overridden', models.BooleanField(default=False, help_text='Indicates whether the course relation has been manually overridden.')), + ('reporting_type', models.CharField(choices=[('mooc', 'mooc'), ('spoc', 'spoc'), ('test', 'test'), ('demo', 'demo'), ('other', 'other')], default='mooc', max_length=255)), + ('eligible_for_financial_aid', models.BooleanField(default=True)), + ('license', models.CharField(blank=True, db_index=True, max_length=255)), + ('outcome_override', models.TextField(blank=True, default=None, help_text="'What You Will Learn' description for this particular course run. Leave this value blank to default to the parent course's Outcome attribute.", null=True)), + ('has_ofac_restrictions', models.BooleanField(default=False, verbose_name='Add OFAC restriction text to the FAQ section of the Marketing site')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('course', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Course')), + ('draft_version', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseRun')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('language', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='ietf_language_tags.LanguageTag')), + ('syllabus', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.SyllabusItem')), + ('video', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Video')), + ], + options={ + 'verbose_name': 'historical course run', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0173_historicalcourse.py b/course_discovery/apps/course_metadata/migrations/0173_historicalcourse.py new file mode 100644 index 0000000000..c13ca62b07 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0173_historicalcourse.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-04-24 17:31 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_remove_partner_lms_commerce_api_url'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0172_historicalcourserun'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalCourse', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('draft', models.BooleanField(default=False, help_text='Is this a draft version?')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('key', models.CharField(db_index=True, max_length=255)), + ('title', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('short_description', models.TextField(blank=True, default=None, null=True)), + ('full_description', models.TextField(blank=True, default=None, null=True)), + ('outcome', models.TextField(blank=True, null=True)), + ('prerequisites_raw', models.TextField(blank=True, null=True)), + ('syllabus_raw', models.TextField(blank=True, null=True)), + ('card_image_url', models.URLField(blank=True, null=True)), + ('image', models.TextField(blank=True, help_text='Add the course image', max_length=100, null=True)), + ('faq', models.TextField(blank=True, default=None, null=True, verbose_name='FAQ')), + ('learner_testimonials', models.TextField(blank=True, default=None, null=True)), + ('has_ofac_restrictions', models.BooleanField(default=False, verbose_name='Course Has OFAC Restrictions')), + ('enrollment_count', models.IntegerField(blank=True, default=0, help_text='Total number of learners who have enrolled in this course', null=True)), + ('recent_enrollment_count', models.IntegerField(blank=True, default=0, help_text='Total number of learners who have enrolled in this course in the last 6 months', null=True)), + ('number', models.CharField(blank=True, help_text='Course number format e.g CS002x, BIO1.1x, BIO1.2x', max_length=50, null=True)), + ('additional_information', models.TextField(blank=True, default=None, null=True, verbose_name='Additional Information')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('canonical_course_run', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseRun')), + ('draft_version', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Course')), + ('extra_description', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.AdditionalPromoArea')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('level_type', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.LevelType')), + ('partner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Partner')), + ('video', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Video')), + ], + options={ + 'verbose_name': 'historical course', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0174_course_entitlement_unique_update.py b/course_discovery/apps/course_metadata/migrations/0174_course_entitlement_unique_update.py new file mode 100644 index 0000000000..414b648109 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0174_course_entitlement_unique_update.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-05-01 17:07 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0173_historicalcourse'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='courseentitlement', + unique_together=set([('course', 'draft')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0175_reorder_course_run_statuses.py b/course_discovery/apps/course_metadata/migrations/0175_reorder_course_run_statuses.py new file mode 100644 index 0000000000..4bb68625af --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0175_reorder_course_run_statuses.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-05-02 14:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import djchoices.choices + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0174_course_entitlement_unique_update'), + ] + + operations = [ + migrations.AlterField( + model_name='courserun', + name='status', + field=models.CharField(choices=[('unpublished', 'Unpublished'), ('review_by_legal', 'Awaiting Review from Legal'), ('review_by_internal', 'Awaiting Internal Review'), ('reviewed', 'Reviewed'), ('published', 'Published')], db_index=True, default='unpublished', max_length=255, validators=[djchoices.choices.ChoicesValidator({'published': 'Published', 'review_by_internal': 'Awaiting Internal Review', 'review_by_legal': 'Awaiting Review from Legal', 'reviewed': 'Reviewed', 'unpublished': 'Unpublished'})]), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='status', + field=models.CharField(choices=[('unpublished', 'Unpublished'), ('review_by_legal', 'Awaiting Review from Legal'), ('review_by_internal', 'Awaiting Internal Review'), ('reviewed', 'Reviewed'), ('published', 'Published')], db_index=True, default='unpublished', max_length=255, validators=[djchoices.choices.ChoicesValidator({'published': 'Published', 'review_by_internal': 'Awaiting Internal Review', 'review_by_legal': 'Awaiting Review from Legal', 'reviewed': 'Reviewed', 'unpublished': 'Unpublished'})]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0176_degree_micromasters_org_name_override.py b/course_discovery/apps/course_metadata/migrations/0176_degree_micromasters_org_name_override.py new file mode 100644 index 0000000000..21e771caba --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0176_degree_micromasters_org_name_override.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-05-20 20:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0175_reorder_course_run_statuses'), + ] + + operations = [ + migrations.AddField( + model_name='degree', + name='micromasters_org_name_override', + field=models.CharField(blank=True, help_text='Override org name if micromasters program comes from different organization than Masters program', max_length=50, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0177_validate_html_fields.py b/course_discovery/apps/course_metadata/migrations/0177_validate_html_fields.py new file mode 100644 index 0000000000..440913581c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0177_validate_html_fields.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-05-22 14:12 +from __future__ import unicode_literals + +import course_discovery.apps.course_metadata.fields +import course_discovery.apps.course_metadata.validators +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0176_degree_micromasters_org_name_override'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='additional_information', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html], verbose_name='Additional Information'), + ), + migrations.AlterField( + model_name='course', + name='faq', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html], verbose_name='FAQ'), + ), + migrations.AlterField( + model_name='course', + name='full_description', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='course', + name='learner_testimonials', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='course', + name='outcome', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='course', + name='prerequisites_raw', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='course', + name='short_description', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='course', + name='syllabus_raw', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='courserun', + name='full_description_override', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text="Full description specific for this run of a course. Leave this value blank to default to the parent course's full_description attribute.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='courserun', + name='outcome_override', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text="'What You Will Learn' description for this particular course run. Leave this value blank to default to the parent course's Outcome attribute.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='courserun', + name='short_description_override', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text="Short description specific for this run of a course. Leave this value blank to default to the parent course's short_description attribute.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='curriculum', + name='marketing_text', + field=course_discovery.apps.course_metadata.fields.HtmlField(help_text="A high-level overview of the degree's courseware.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='curriculum', + name='marketing_text_brief', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text='A high-level overview of the degree\'s courseware. The "brief"\n text is the first 750 characters of "marketing_text" and must be\n valid HTML.', max_length=750, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourse', + name='additional_information', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html], verbose_name='Additional Information'), + ), + migrations.AlterField( + model_name='historicalcourse', + name='faq', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html], verbose_name='FAQ'), + ), + migrations.AlterField( + model_name='historicalcourse', + name='full_description', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourse', + name='learner_testimonials', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourse', + name='outcome', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourse', + name='prerequisites_raw', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourse', + name='short_description', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourse', + name='syllabus_raw', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='full_description_override', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text="Full description specific for this run of a course. Leave this value blank to default to the parent course's full_description attribute.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='outcome_override', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text="'What You Will Learn' description for this particular course run. Leave this value blank to default to the parent course's Outcome attribute.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='short_description_override', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text="Short description specific for this run of a course. Leave this value blank to default to the parent course's short_description attribute.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcurriculum', + name='marketing_text', + field=course_discovery.apps.course_metadata.fields.HtmlField(help_text="A high-level overview of the degree's courseware.", null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='historicalcurriculum', + name='marketing_text_brief', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, help_text='A high-level overview of the degree\'s courseware. The "brief"\n text is the first 750 characters of "marketing_text" and must be\n valid HTML.', max_length=750, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='person', + name='bio', + field=course_discovery.apps.course_metadata.fields.NullHtmlField(blank=True, default=None, null=True, validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + migrations.AlterField( + model_name='person', + name='major_works', + field=course_discovery.apps.course_metadata.fields.HtmlField(blank=True, help_text='A list of major works by this person. Must be valid HTML.', validators=[course_discovery.apps.course_metadata.validators.validate_html]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0178_historicalprogram_historicalprogramtype.py b/course_discovery/apps/course_metadata/migrations/0178_historicalprogram_historicalprogramtype.py new file mode 100644 index 0000000000..74d79bb717 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0178_historicalprogram_historicalprogramtype.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-06-03 18:18 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import djchoices.choices +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0011_remove_partner_lms_commerce_api_url'), + ('course_metadata', '0177_validate_html_fields'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalProgram', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(blank=True, db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('title', models.CharField(db_index=True, help_text='The user-facing display title for this Program.', max_length=255)), + ('subtitle', models.CharField(blank=True, help_text='A brief, descriptive subtitle for the Program.', max_length=255)), + ('status', models.CharField(choices=[('unpublished', 'Unpublished'), ('active', 'Active'), ('retired', 'Retired'), ('deleted', 'Deleted')], db_index=True, help_text='The lifecycle status of this Program.', max_length=24, validators=[djchoices.choices.ChoicesValidator({'active': 'Active', 'deleted': 'Deleted', 'retired': 'Retired', 'unpublished': 'Unpublished'})])), + ('marketing_slug', models.CharField(db_index=True, help_text='Slug used to generate links to the marketing site', max_length=255)), + ('order_courses_by_start_date', models.BooleanField(default=True, help_text='If this box is not checked, courses will be ordered as in the courses select box above.', verbose_name='Order Courses By Start Date')), + ('overview', models.TextField(blank=True, null=True)), + ('total_hours_of_effort', models.PositiveSmallIntegerField(blank=True, help_text='Total estimated time needed to complete all courses belonging to this program. This field is intended for display on program certificates.', null=True)), + ('weeks_to_complete', models.PositiveSmallIntegerField(blank=True, help_text='This field is now deprecated (ECOM-6021).Estimated number of weeks needed to complete a course run belonging to this program.', null=True)), + ('min_hours_effort_per_week', models.PositiveSmallIntegerField(blank=True, null=True)), + ('max_hours_effort_per_week', models.PositiveSmallIntegerField(blank=True, null=True)), + ('banner_image', models.TextField(blank=True, max_length=100, null=True)), + ('banner_image_url', models.URLField(blank=True, help_text='DEPRECATED: Use the banner image field.', null=True)), + ('card_image_url', models.URLField(blank=True, help_text='Image used for discovery cards', null=True)), + ('credit_redemption_overview', models.TextField(blank=True, help_text='The description of credit redemption for courses in program', null=True)), + ('one_click_purchase_enabled', models.BooleanField(default=True, help_text='Allow courses in this program to be purchased in a single transaction')), + ('hidden', models.BooleanField(db_index=True, default=False, help_text='Hide program on marketing site landing and search pages. This program MAY have a detail page.')), + ('enrollment_count', models.IntegerField(blank=True, default=0, help_text='Total number of learners who have enrolled in courses this program', null=True)), + ('recent_enrollment_count', models.IntegerField(blank=True, default=0, help_text='Total number of learners who have enrolled in courses in this program in the last 6 months', null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('partner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Partner')), + ('type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.ProgramType')), + ('video', models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Video')), + ], + options={ + 'verbose_name': 'historical program', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalProgramType', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('name', models.CharField(db_index=True, max_length=32)), + ('logo_image', models.TextField(blank=True, help_text='Please provide an image file with transparent background', max_length=100, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical program type', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0179_external-key.py b/course_discovery/apps/course_metadata/migrations/0179_external-key.py new file mode 100644 index 0000000000..4f6c6f1e95 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0179_external-key.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-06-06 18:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0178_historicalprogram_historicalprogramtype'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='external_key', + field=models.CharField(blank=True, max_length=225, null=True), + ), + migrations.AddField( + model_name='historicalcourserun', + name='external_key', + field=models.CharField(blank=True, max_length=225, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0180_historicalcourseentitlement_historicalseat.py b/course_discovery/apps/course_metadata/migrations/0180_historicalcourseentitlement_historicalseat.py new file mode 100644 index 0000000000..16664d5055 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0180_historicalcourseentitlement_historicalseat.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-06-11 19:04 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_historicalpartner'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0179_external-key'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalCourseEntitlement', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('draft', models.BooleanField(default=False, help_text='Is this a draft version?')), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), + ('sku', models.CharField(blank=True, max_length=128, null=True)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('course', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Course')), + ('currency', models.ForeignKey(blank=True, db_constraint=False, default='USD', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Currency')), + ('draft_version', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseEntitlement')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('mode', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.SeatType')), + ('partner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Partner')), + ], + options={ + 'verbose_name': 'historical course entitlement', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalSeat', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('draft', models.BooleanField(default=False, help_text='Is this a draft version?')), + ('type', models.CharField(choices=[('honor', 'Honor'), ('audit', 'Audit'), ('verified', 'Verified'), ('professional', 'Professional'), ('credit', 'Credit'), ('masters', 'Masters')], max_length=63)), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), + ('upgrade_deadline', models.DateTimeField(blank=True, null=True)), + ('credit_provider', models.CharField(blank=True, max_length=255, null=True)), + ('credit_hours', models.IntegerField(blank=True, null=True)), + ('sku', models.CharField(blank=True, max_length=128, null=True)), + ('bulk_sku', models.CharField(blank=True, max_length=128, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('course_run', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseRun')), + ('currency', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Currency')), + ('draft_version', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Seat')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical seat', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0181_seat_default_currency.py b/course_discovery/apps/course_metadata/migrations/0181_seat_default_currency.py new file mode 100644 index 0000000000..dab20e7498 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0181_seat_default_currency.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-06-03 16:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0180_historicalcourseentitlement_historicalseat'), + ] + + operations = [ + migrations.AlterField( + model_name='seat', + name='currency', + field=models.ForeignKey(default='USD', on_delete=django.db.models.deletion.CASCADE, to='core.Currency'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0182_auto_20190613_1440.py b/course_discovery/apps/course_metadata/migrations/0182_auto_20190613_1440.py new file mode 100644 index 0000000000..965689ee7c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0182_auto_20190613_1440.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-06-13 14:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0181_seat_default_currency'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalseat', + name='currency', + field=models.ForeignKey(blank=True, db_constraint=False, default='USD', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Currency'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0183_tagcourseuuidsconfig.py b/course_discovery/apps/course_metadata/migrations/0183_tagcourseuuidsconfig.py new file mode 100644 index 0000000000..0eb4d2291c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0183_tagcourseuuidsconfig.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-06-14 19:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0182_auto_20190613_1440'), + ] + + operations = [ + migrations.CreateModel( + name='TagCourseUuidsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.TextField(default=None, null=True, verbose_name='Tag')), + ('course_uuids', models.TextField(default=None, null=True, verbose_name='Course UUIDs')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0184_courserun_expected_program.py b/course_discovery/apps/course_metadata/migrations/0184_courserun_expected_program.py new file mode 100644 index 0000000000..2fbfb5cafc --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0184_courserun_expected_program.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-06-11 19:44 +from __future__ import unicode_literals +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0183_tagcourseuuidsconfig'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='expected_program_name', + field=models.CharField(default='', max_length=255, blank=True), + ), + migrations.AddField( + model_name='courserun', + name='expected_program_type', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='course_metadata.ProgramType'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='expected_program_name', + field=models.CharField(default='', max_length=255, blank=True), + ), + migrations.AddField( + model_name='historicalcourserun', + name='expected_program_type', + field=models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.ProgramType'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0185_migratecourseeditorsconfig.py b/course_discovery/apps/course_metadata/migrations/0185_migratecourseeditorsconfig.py new file mode 100644 index 0000000000..5e64ede8d5 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0185_migratecourseeditorsconfig.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-06-25 19:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0184_courserun_expected_program'), + ] + + operations = [ + migrations.CreateModel( + name='MigrateCourseEditorsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('org_keys', models.TextField(blank=True, default='', help_text='Comma separated organization keys e.g. edX, org2x,org3x, org4x', verbose_name='Organization Keys')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0186_migrate_publisher_to_course_metadata_config.py b/course_discovery/apps/course_metadata/migrations/0186_migrate_publisher_to_course_metadata_config.py new file mode 100644 index 0000000000..d266ececaa --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0186_migrate_publisher_to_course_metadata_config.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-07-10 16:11 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0185_migratecourseeditorsconfig'), + ] + + operations = [ + migrations.RenameModel( + old_name='MigrateCourseEditorsConfig', + new_name='MigratePublisherToCourseMetadataConfig', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0187_update_migrate_publisher_command.py b/course_discovery/apps/course_metadata/migrations/0187_update_migrate_publisher_command.py new file mode 100644 index 0000000000..dda3c25d95 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0187_update_migrate_publisher_command.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.22 on 2019-07-15 17:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import sortedm2m.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_add_lms_admin_url'), + ('course_metadata', '0186_migrate_publisher_to_course_metadata_config'), + ] + + operations = [ + migrations.RemoveField( + model_name='migratepublishertocoursemetadataconfig', + name='org_keys', + ), + migrations.AddField( + model_name='migratepublishertocoursemetadataconfig', + name='orgs', + field=sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, to='course_metadata.Organization'), + ), + migrations.AddField( + model_name='migratepublishertocoursemetadataconfig', + name='partner', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='core.Partner'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0188_update_course_run_ofac_add_comment.py b/course_discovery/apps/course_metadata/migrations/0188_update_course_run_ofac_add_comment.py new file mode 100644 index 0000000000..8085fbb50f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0188_update_course_run_ofac_add_comment.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.22 on 2019-08-02 17:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0187_update_migrate_publisher_command'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='ofac_comment', + field=models.TextField(blank=True, help_text='Comment related to OFAC restriction'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='ofac_comment', + field=models.TextField(blank=True, help_text='Comment related to OFAC restriction'), + ), + migrations.AlterField( + model_name='courserun', + name='has_ofac_restrictions', + field=models.NullBooleanField(choices=[('', '--'), (True, 'Restricted'), (False, 'Not restricted')], default=None, verbose_name='Add OFAC restriction text to the FAQ section of the Marketing site'), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='has_ofac_restrictions', + field=models.NullBooleanField(choices=[('', '--'), (True, 'Restricted'), (False, 'Not restricted')], default=None, verbose_name='Add OFAC restriction text to the FAQ section of the Marketing site'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0189_course_url_slug.py b/course_discovery/apps/course_metadata/migrations/0189_course_url_slug.py new file mode 100644 index 0000000000..95aba06de7 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0189_course_url_slug.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-08-13 21:09 +from __future__ import unicode_literals + +from django.db import migrations +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0188_update_course_run_ofac_add_comment'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='url_slug', + field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False, help_text='Leave this field blank to have the value generated automatically.', populate_from='title'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0190_add_salesforce_ids.py b/course_discovery/apps/course_metadata/migrations/0190_add_salesforce_ids.py new file mode 100644 index 0000000000..7ce1bc71f3 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0190_add_salesforce_ids.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.22 on 2019-07-30 18:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0189_course_url_slug'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='salesforce_case_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='course', + name='salesforce_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='courserun', + name='salesforce_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='historicalcourse', + name='salesforce_case_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='historicalcourse', + name='salesforce_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='historicalcourserun', + name='salesforce_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='organization', + name='salesforce_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0191_remove_entitlement_expires.py b/course_discovery/apps/course_metadata/migrations/0191_remove_entitlement_expires.py new file mode 100644 index 0000000000..117f54f46d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0191_remove_entitlement_expires.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-08-21 18:11 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0190_add_salesforce_ids'), + ] + + operations = [ + migrations.RemoveField( + model_name='courseentitlement', + name='expires', + ), + migrations.RemoveField( + model_name='historicalcourseentitlement', + name='expires', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0192_add_microbachelors_program_type.py b/course_discovery/apps/course_metadata/migrations/0192_add_microbachelors_program_type.py new file mode 100644 index 0000000000..b833e278f1 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0192_add_microbachelors_program_type.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-07-26 18:14 +from __future__ import unicode_literals + +from django.db import migrations + +SEAT_TYPES = ('audit', 'verified',) +PROGRAM_TYPES = ('MicroBachelors',) + + +def add_program_types(apps, schema_editor): # pylint: disable=unused-argument + SeatType = apps.get_model('course_metadata', 'SeatType') + ProgramType = apps.get_model('course_metadata', 'ProgramType') + + filtered_seat_types = SeatType.objects.filter(slug__in=SEAT_TYPES) + + for name in PROGRAM_TYPES: + program_type, __ = ProgramType.objects.update_or_create(name=name) + program_type.applicable_seat_types.clear() + program_type.applicable_seat_types.add(*filtered_seat_types) + program_type.save() + + +def drop_program_types(apps, schema_editor): # pylint: disable=unused-argument + ProgramType = apps.get_model('course_metadata', 'ProgramType') + ProgramType.objects.filter(name__in=PROGRAM_TYPES).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0191_remove_entitlement_expires'), + ] + + operations = [ + migrations.RunPython( + code=add_program_types, + reverse_code=drop_program_types, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0193_draft_version_set_null.py b/course_discovery/apps/course_metadata/migrations/0193_draft_version_set_null.py new file mode 100644 index 0000000000..7693897ce1 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0193_draft_version_set_null.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-08-26 14:56 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0192_add_microbachelors_program_type'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='courseentitlement', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.CourseEntitlement'), + ), + migrations.AlterField( + model_name='courserun', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.CourseRun'), + ), + migrations.AlterField( + model_name='seat', + name='draft_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.Seat'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0194_migratecommentstosalesforce.py b/course_discovery/apps/course_metadata/migrations/0194_migratecommentstosalesforce.py new file mode 100644 index 0000000000..f623e7baf9 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0194_migratecommentstosalesforce.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-08-29 13:46 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import sortedm2m.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ('course_metadata', '0193_draft_version_set_null'), + ] + + operations = [ + migrations.CreateModel( + name='MigrateCommentsToSalesforce', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('orgs', sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, to='course_metadata.Organization')), + ('partner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='core.Partner')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0195_initialize_course_url_slug.py b/course_discovery/apps/course_metadata/migrations/0195_initialize_course_url_slug.py new file mode 100644 index 0000000000..ddf8902c14 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0195_initialize_course_url_slug.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-09-04 18:12 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + def migrate_data_forward(apps, schema_editor): + updated_draft_pks = [] + Course = apps.get_model('course_metadata', 'course') + for instance in Course.everything.all().order_by('draft'): + # will set the url_slug + if not instance.draft: + instance.url_slug = '' + instance.save() + # update corresponding draft instance + if instance.draft_version: + instance.draft_version.url_slug = instance.url_slug + instance.draft_version.save() + updated_draft_pks.append(instance.draft_version.pk) + elif instance.pk not in updated_draft_pks: + # update any drafts that do not have published equivalents + instance.url_slug = '' + instance.save() + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ('course_metadata', '0194_migratecommentstosalesforce'), + ] + + operations = [ + migrations.RunPython( + migrate_data_forward, + migrations.RunPython.noop, + ), + migrations.AlterUniqueTogether( + name='course', + unique_together=set([('partner', 'url_slug', 'draft'), ('partner', 'uuid', 'draft'), ('partner', 'key', 'draft')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0196_auto_20190909_1601.py b/course_discovery/apps/course_metadata/migrations/0196_auto_20190909_1601.py new file mode 100644 index 0000000000..a2dcf9d445 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0196_auto_20190909_1601.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-09-09 16:01 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ('course_metadata', '0195_initialize_course_url_slug'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='course', + unique_together=set([('partner', 'uuid', 'draft'), ('partner', 'key', 'draft')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0197_auto_20190910_1714.py b/course_discovery/apps/course_metadata/migrations/0197_auto_20190910_1714.py new file mode 100644 index 0000000000..8c8eb424d7 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0197_auto_20190910_1714.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.23 on 2019-09-10 17:14 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ('course_metadata', '0196_auto_20190909_1601'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='course', + unique_together=set([('partner', 'key', 'draft'), ('partner', 'url_slug', 'draft'), ('partner', 'uuid', 'draft')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0198_add_course_key_for_reruns.py b/course_discovery/apps/course_metadata/migrations/0198_add_course_key_for_reruns.py new file mode 100644 index 0000000000..074cd691a8 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0198_add_course_key_for_reruns.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-09-19 16:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0197_auto_20190910_1714'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='key_for_reruns', + field=models.CharField(blank=True, help_text='When making reruns for this course, they will use this key instead of the course key.', max_length=255), + ), + migrations.AddField( + model_name='historicalcourse', + name='key_for_reruns', + field=models.CharField(blank=True, help_text='When making reruns for this course, they will use this key instead of the course key.', max_length=255), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0199_add_course_type_and_friends.py b/course_discovery/apps/course_metadata/migrations/0199_add_course_type_and_friends.py new file mode 100644 index 0000000000..1da24165fa --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0199_add_course_type_and_friends.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-01 17:37 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0198_add_course_key_for_reruns'), + ] + + operations = [ + migrations.CreateModel( + name='CourseRunType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', models.CharField(max_length=64)), + ('is_marketable', models.BooleanField(default=True)), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='CourseType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', models.CharField(max_length=64)), + ('course_run_types', models.ManyToManyField(to='course_metadata.CourseRunType')), + ('entitlement_types', models.ManyToManyField(to='course_metadata.SeatType')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='HistoricalCourseRunType', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', models.CharField(max_length=64)), + ('is_marketable', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical course run type', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalCourseType', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', models.CharField(max_length=64)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical course type', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalMode', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('name', models.CharField(max_length=64)), + ('slug', models.CharField(db_index=True, max_length=64)), + ('is_id_verified', models.BooleanField(default=False, help_text='This mode requires ID verification.')), + ('is_credit_eligible', models.BooleanField(default=False, help_text='Completion can grant credit toward an organization’s degree.')), + ('certificate_type', models.CharField(blank=True, choices=[('honor', 'Honor'), ('credit', 'Credit'), ('verified', 'Verified'), ('professional', 'Professional')], help_text='Certificate type granted if this mode is eligible for a certificate, or blank if not.', max_length=64)), + ('payee', models.CharField(choices=[('platform', 'Platform'), ('organization', 'Organization')], default='platform', help_text='Who gets paid for the course? Platform is the site owner, Organization is the school.', max_length=64)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical mode', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalTrack', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical track', + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Mode', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('name', models.CharField(max_length=64)), + ('slug', models.CharField(max_length=64, unique=True)), + ('is_id_verified', models.BooleanField(default=False, help_text='This mode requires ID verification.')), + ('is_credit_eligible', models.BooleanField(default=False, help_text='Completion can grant credit toward an organization’s degree.')), + ('certificate_type', models.CharField(blank=True, choices=[('honor', 'Honor'), ('credit', 'Credit'), ('verified', 'Verified'), ('professional', 'Professional')], help_text='Certificate type granted if this mode is eligible for a certificate, or blank if not.', max_length=64)), + ('payee', models.CharField(choices=[('platform', 'Platform'), ('organization', 'Organization')], default='platform', help_text='Who gets paid for the course? Platform is the site owner, Organization is the school.', max_length=64)), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='Track', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('mode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_metadata.Mode')), + ('seat_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course_metadata.SeatType')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.AddField( + model_name='historicaltrack', + name='mode', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Mode'), + ), + migrations.AddField( + model_name='historicaltrack', + name='seat_type', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.SeatType'), + ), + migrations.AddField( + model_name='courseruntype', + name='tracks', + field=models.ManyToManyField(to='course_metadata.Track'), + ), + migrations.AddField( + model_name='course', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course_metadata.CourseType'), + ), + migrations.AddField( + model_name='courserun', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course_metadata.CourseRunType'), + ), + migrations.AddField( + model_name='historicalcourse', + name='type', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseType'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='type', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseRunType'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0200_url_slug_history.py b/course_discovery/apps/course_metadata/migrations/0200_url_slug_history.py new file mode 100644 index 0000000000..9393cb9a1d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0200_url_slug_history.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-01 00:43 +from __future__ import unicode_literals + +from django.core.exceptions import ObjectDoesNotExist +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ('course_metadata', '0199_add_course_type_and_friends'), + ] + + def migrate_data_forward(apps, schema_editor): + published_drafts = [] + Course = apps.get_model('course_metadata', 'course') + CourseUrlSlug = apps.get_model('course_metadata', 'courseUrlSlug') + for instance in Course.everything.all().order_by('draft'): + + if instance.pk not in published_drafts: + historical_slug = CourseUrlSlug.objects.create( + course=instance, + partner=instance.partner, + is_active=True, + is_active_on_draft=True + ) + historical_slug.save() + + if instance.draft_version: + published_drafts.append(instance.draft_version_id) + + def migrate_data_backwards(apps, schema_editor): + Course = apps.get_model('course_metadata', 'course') + CourseUrlSlugHistory = apps.get_model('course_metadata', 'courseUrlSlug') + + def getActiveSlug(course_instance): + try: + active_slug = CourseUrlSlugHistory.objects.get(course=course_instance, is_active=True) + return active_slug.url_slug + except ObjectDoesNotExist: + pass + + try: + official_version = Course.everything.get(draft_version_id=course_instance.id) + active_slug = CourseUrlSlugHistory.objects.get(course=official_version,is_active_on_draft=True) + return active_slug.url_slug + except ObjectDoesNotExist: + pass + return '' + + for instance in Course.everything.all(): + instance.url_slug = getActiveSlug(instance) + instance.save() + + + operations = [ + migrations.CreateModel( + name='CourseUrlSlug', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('url_slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='course__title')), + ('is_active', models.BooleanField(default=False)), + ('is_active_on_draft', models.BooleanField(default=False)), + ], + ), + migrations.AlterUniqueTogether( + name='course', + unique_together=set([('partner', 'uuid', 'draft'), ('partner', 'key', 'draft')]), + ), + migrations.AddField( + model_name='courseurlslug', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='url_slug_history', to='course_metadata.Course'), + ), + migrations.AddField( + model_name='courseurlslug', + name='partner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.Partner'), + ), + migrations.AlterUniqueTogether( + name='courseurlslug', + unique_together=set([('partner', 'url_slug')]), + ), + migrations.RunPython( + migrate_data_forward, + migrate_data_backwards + ) + ] diff --git a/course_discovery/apps/course_metadata/migrations/0201_auto_20191007_1408.py b/course_discovery/apps/course_metadata/migrations/0201_auto_20191007_1408.py new file mode 100644 index 0000000000..e5e5736480 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0201_auto_20191007_1408.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-07 14:08 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0200_url_slug_history'), + ] + + operations = [ + migrations.AlterField( + model_name='courserun', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, verbose_name='UUID'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0202_add_organization_slug_remove_marketing_url.py b/course_discovery/apps/course_metadata/migrations/0202_add_organization_slug_remove_marketing_url.py new file mode 100644 index 0000000000..2678ea6e23 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0202_add_organization_slug_remove_marketing_url.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-02 16:40 +from __future__ import unicode_literals + +from django.db import migrations +import django_extensions.db.fields + + +def populate_slug(apps, schema_editor): + Organization = apps.get_model('course_metadata', 'Organization') + for org in Organization.objects.all(): + marketing_url = getattr(org, 'marketing_url_path') + org.slug = marketing_url.rsplit('/')[-1] if marketing_url else getattr(org, 'key', '').lower() + org.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0201_auto_20191007_1408'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='slug', + field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='key'), + ), + migrations.RunPython(populate_slug, migrations.RunPython.noop), + migrations.RemoveField( + model_name='organization', + name='marketing_url_path', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0203_backpopulatecoursetypeconfig.py b/course_discovery/apps/course_metadata/migrations/0203_backpopulatecoursetypeconfig.py new file mode 100644 index 0000000000..debbe6979d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0203_backpopulatecoursetypeconfig.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-10-04 19:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0202_add_organization_slug_remove_marketing_url'), + ] + + operations = [ + migrations.CreateModel( + name='BackpopulateCourseTypeConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('arguments', models.TextField(blank=True, default='', help_text='Useful for manually running a Jenkins job. Specify like "--org=key1 --org=key2".')), + ], + options={ + 'verbose_name': 'backpopulate_course_type argument', + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0204_make_type_uuids_unique.py b/course_discovery/apps/course_metadata/migrations/0204_make_type_uuids_unique.py new file mode 100644 index 0000000000..b8702d0e46 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0204_make_type_uuids_unique.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-08 18:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0203_backpopulatecoursetypeconfig'), + ] + + operations = [ + migrations.AlterField( + model_name='courseruntype', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='coursetype', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='historicalcourseruntype', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='historicalcoursetype', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0205_auto_20191015_1955.py b/course_discovery/apps/course_metadata/migrations/0205_auto_20191015_1955.py new file mode 100644 index 0000000000..39d307d06d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0205_auto_20191015_1955.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-15 19:55 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_add_case_record_type_id'), + ('course_metadata', '0204_make_type_uuids_unique'), + ] + + operations = [ + migrations.CreateModel( + name='CourseUrlRedirect', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('value', models.CharField(max_length=255)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='url_redirects', to='course_metadata.Course')), + ('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.Partner')), + ], + ), + migrations.AlterUniqueTogether( + name='courseurlredirect', + unique_together=set([('partner', 'value')]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0206_create_initial_coursetypes.py b/course_discovery/apps/course_metadata/migrations/0206_create_initial_coursetypes.py new file mode 100644 index 0000000000..5840ed880e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0206_create_initial_coursetypes.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +# Contains the slug (unique identifier) and any fields that are not the default values +MODE_SLUGS_AND_DEFAULTS = ( + ('audit', { + 'name': 'Audit', + }), + ('professional', { + 'name': 'Professional Certificate', + 'is_id_verified': True, + 'certificate_type': 'professional', + 'payee': 'platform', + }), + ('verified', { + 'name': 'Verified', + 'is_id_verified': True, + 'certificate_type': 'verified', + 'payee': 'platform', + }), + ('credit', { + 'name': 'Credit', + 'is_id_verified': True, + 'is_credit_eligible': 1, + 'certificate_type': 'credit', + 'payee': 'platform', + }), +) +# The slugs are identical across Mode and SeatType +TRACK_MODE_AND_SEAT_SLUGS = ('audit', 'professional', 'verified', 'credit',) +COURSE_RUN_TYPE_NAMES_SLUGS_AND_TRACK_SLUGS = ( + ('Audit Only', 'audit', ['audit']), + ('Professional Only', 'professional', ['professional']), + ('Verified and Audit', 'verified-audit', ['audit', 'verified']), + ('Credit', 'credit-verified-audit', ['audit', 'credit', 'verified']), +) +COURSE_TYPE_NAMES_SLUGS_SEAT_TYPE_SLUGS_AND_RUN_TYPE_NAMES = ( + ('Audit Only', 'audit', ['audit'], ['Audit Only']), + ('Professional Only', 'professional', ['professional'], ['Professional Only']), + ('Verified and Audit', 'verified-audit', ['verified'], ['Audit Only', 'Verified and Audit']), + ('Credit', 'credit-verified-audit', ['verified'], ['Audit Only', 'Credit', 'Verified and Audit']), +) + +def add_course_types_and_children(apps, schema_editor): + CourseType = apps.get_model('course_metadata', 'CourseType') + CourseRunType = apps.get_model('course_metadata', 'CourseRunType') + Track = apps.get_model('course_metadata', 'Track') + Mode = apps.get_model('course_metadata', 'Mode') + SeatType = apps.get_model('course_metadata', 'SeatType') + + for slug, defaults in MODE_SLUGS_AND_DEFAULTS: + Mode.objects.update_or_create(slug=slug, defaults=defaults) + for slug in TRACK_MODE_AND_SEAT_SLUGS: + mode = Mode.objects.get(slug=slug) + seat_type = SeatType.objects.get(slug=slug) + Track.objects.update_or_create(mode=mode, seat_type=seat_type) + for name, slug, track_slugs in COURSE_RUN_TYPE_NAMES_SLUGS_AND_TRACK_SLUGS: + tracks = [] + for track_slug in track_slugs: + mode = Mode.objects.get(slug=track_slug) + seat_type = SeatType.objects.get(slug=track_slug) + tracks.append(Track.objects.get(mode=mode, seat_type=seat_type)) + run_type, _created = CourseRunType.objects.update_or_create(name=name, slug=slug) + run_type.tracks.set(tracks) + for course_name, slug, seat_type_slugs, run_type_names in COURSE_TYPE_NAMES_SLUGS_SEAT_TYPE_SLUGS_AND_RUN_TYPE_NAMES: + course_type, _created = CourseType.objects.update_or_create(name=course_name, slug=slug) + + entitlement_types = [SeatType.objects.get(slug=seat_type_slug) for seat_type_slug in seat_type_slugs] + course_type.entitlement_types.set(entitlement_types) + + run_types = [CourseRunType.objects.get(name=run_type_name) for run_type_name in run_type_names] + course_type.course_run_types.set(run_types) + +def drop_course_types_and_children(apps, schema_editor): + CourseType = apps.get_model('course_metadata', 'CourseType') + CourseRunType = apps.get_model('course_metadata', 'CourseRunType') + Track = apps.get_model('course_metadata', 'Track') + Mode = apps.get_model('course_metadata', 'Mode') + + course_type_slugs = [slug for __, slug, __, __ in COURSE_TYPE_NAMES_SLUGS_SEAT_TYPE_SLUGS_AND_RUN_TYPE_NAMES] + course_run_type_slugs = [slug for __, slug, __ in COURSE_RUN_TYPE_NAMES_SLUGS_AND_TRACK_SLUGS] + mode_slugs = [slug for slug, __ in MODE_SLUGS_AND_DEFAULTS] + + CourseType.objects.filter(slug__in=course_type_slugs).delete() + CourseRunType.objects.filter(slug__in=course_run_type_slugs).delete() + + modes = [Mode.objects.get(slug=slug) for slug in TRACK_MODE_AND_SEAT_SLUGS] + Track.objects.filter(mode__in=modes).delete() + + Mode.objects.filter(slug__in=mode_slugs).delete() + +class Migration(migrations.Migration): + dependencies = [ + ('course_metadata', '0205_auto_20191015_1955'), + ] + + operations = [ + migrations.AddField( + model_name='courseruntype', + name='slug', + field=models.CharField(default=None, max_length=64, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='coursetype', + name='slug', + field=models.CharField(default=None, max_length=64, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='historicalcourseruntype', + name='slug', + field=models.CharField(db_index=True, default=None, max_length=64), + preserve_default=False, + ), + migrations.AddField( + model_name='historicalcoursetype', + name='slug', + field=models.CharField(db_index=True, default=None, max_length=64), + preserve_default=False, + ), + migrations.AlterField( + model_name='historicalmode', + name='payee', + field=models.CharField(blank=True, choices=[('platform', 'Platform'), ('organization', 'Organization')], default='', help_text='Who gets paid for the course? Platform is the site owner, Organization is the school.', max_length=64), + ), + migrations.AlterField( + model_name='mode', + name='payee', + field=models.CharField(blank=True, choices=[('platform', 'Platform'), ('organization', 'Organization')], default='', help_text='Who gets paid for the course? Platform is the site owner, Organization is the school.', max_length=64), + ), + migrations.RunPython( + code=add_course_types_and_children, + reverse_code=drop_course_types_and_children, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0207_seat_type_to_fk.py b/course_discovery/apps/course_metadata/migrations/0207_seat_type_to_fk.py new file mode 100644 index 0000000000..c73258a7f8 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0207_seat_type_to_fk.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-17 19:50 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +def create_missing_seat_types(apps, schema_editor): + Seat = apps.get_model('course_metadata', 'Seat') + SeatType = apps.get_model('course_metadata', 'SeatType') + for type_slug in set(Seat.everything.values_list('type', flat=True)): + SeatType.objects.get_or_create(slug=type_slug, defaults={'name': type_slug.capitalize()}) + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0206_create_initial_coursetypes'), + ] + + operations = [ + migrations.RunPython(create_missing_seat_types, migrations.RunPython.noop), + migrations.AlterField( + model_name='coursetype', + name='entitlement_types', + field=models.ManyToManyField(blank=True, to='course_metadata.SeatType'), + ), + migrations.AlterField( + model_name='seattype', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='seattype', + name='slug', + field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='name', unique=True), + ), + migrations.AlterField( + model_name='historicalseat', + name='type', + field=models.ForeignKey(blank=True, db_column='type', db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.SeatType', to_field='slug'), + ), + migrations.AlterField( + model_name='seat', + name='type', + field=models.ForeignKey(db_column='type', on_delete=django.db.models.deletion.CASCADE, to='course_metadata.SeatType', to_field='slug'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0208_auto_20191025_1939.py b/course_discovery/apps/course_metadata/migrations/0208_auto_20191025_1939.py new file mode 100644 index 0000000000..2b5d0d970f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0208_auto_20191025_1939.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-25 19:39 +from __future__ import unicode_literals + +from django.db import migrations +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0207_seat_type_to_fk'), + ] + + operations = [ + migrations.AlterField( + model_name='courseurlslug', + name='url_slug', + field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='course__title'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0209_make_course_has_ofac_restrictions_nullable.py b/course_discovery/apps/course_metadata/migrations/0209_make_course_has_ofac_restrictions_nullable.py new file mode 100644 index 0000000000..76ee81eab1 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0209_make_course_has_ofac_restrictions_nullable.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0208_auto_20191025_1939'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='has_ofac_restrictions', + field=models.NullBooleanField(verbose_name='Course Has OFAC Restrictions'), + ), + migrations.AlterField( + model_name='historicalcourse', + name='has_ofac_restrictions', + field=models.NullBooleanField(verbose_name='Course Has OFAC Restrictions'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0210_no_additional_info_validation.py b/course_discovery/apps/course_metadata/migrations/0210_no_additional_info_validation.py new file mode 100644 index 0000000000..14d63c8f31 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0210_no_additional_info_validation.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-01 17:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0209_make_course_has_ofac_restrictions_nullable'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='additional_information', + field=models.TextField(blank=True, null=True, default=None, verbose_name='Additional Information'), + ), + migrations.AlterField( + model_name='historicalcourse', + name='additional_information', + field=models.TextField(blank=True, null=True, default=None, verbose_name='Additional Information'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0211_add_unique_together_to_courserun_uuid_and_draft.py b/course_discovery/apps/course_metadata/migrations/0211_add_unique_together_to_courserun_uuid_and_draft.py new file mode 100644 index 0000000000..d8bb80599c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0211_add_unique_together_to_courserun_uuid_and_draft.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-10-29 12:41 +from __future__ import unicode_literals + +import uuid + +from django.db import migrations +from django.db.models import Count + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0210_no_additional_info_validation'), + ] + + def migrate_data_forward(apps, schema_editor): + """ + Data needs to be migrated forward for this migration to occur, in that we previously did not require + any uniqueness on our Course Run UUIDs due to microsite support (though because of key uniqueness, if you had + an identical course run between two, you still were not able to save that course run). This created a new + UUID for a run where a key was modified to successfully save, but the UUID was not, and updates relevant drafts. + """ + CourseRun = apps.get_model('course_metadata', 'CourseRun') + + duplicate_uuid_values = CourseRun.everything.values_list('uuid', flat=True).annotate(Count('uuid')).filter( + uuid__count__gt=1, + draft=False, # Only official rows should ever need this migration + ) + + for duplicate_uuid_value in duplicate_uuid_values: + duplicate_course_runs = CourseRun.everything.filter(uuid=duplicate_uuid_value, draft=False).order_by( + 'created') + if duplicate_course_runs.count() > 1: + # Skip the first element as we don't need to update it + for duplicate_course_run in duplicate_course_runs[1:]: + new_uuid = uuid.uuid4() + duplicate_course_run.uuid = new_uuid + duplicate_course_run.save() + if duplicate_course_run.draft_version: + duplicate_course_run.draft_version.uuid = new_uuid + duplicate_course_run.draft_version.save() + + operations = [ + migrations.RunPython( + migrate_data_forward, + reverse_code=migrations.RunPython.noop + ), + migrations.AlterUniqueTogether( + name='courserun', + unique_together=set([('uuid', 'draft'), ('key', 'draft')]), + ), + + ] diff --git a/course_discovery/apps/course_metadata/migrations/0212_remove_course_has_ofac_restrictions.py b/course_discovery/apps/course_metadata/migrations/0212_remove_course_has_ofac_restrictions.py new file mode 100644 index 0000000000..4749caf992 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0212_remove_course_has_ofac_restrictions.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-11-01 19:34 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0211_add_unique_together_to_courserun_uuid_and_draft'), + ] + + operations = [ + migrations.RemoveField( + model_name='course', + name='has_ofac_restrictions', + ), + migrations.RemoveField( + model_name='historicalcourse', + name='has_ofac_restrictions', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0213_order_course_run_types.py b/course_discovery/apps/course_metadata/migrations/0213_order_course_run_types.py new file mode 100644 index 0000000000..f95b72da6b --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0213_order_course_run_types.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-06 20:36 +from __future__ import unicode_literals + +from django.db import migrations +import sortedm2m.fields +from sortedm2m.operations import AlterSortedManyToManyField + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0212_remove_course_has_ofac_restrictions'), + ] + + operations = [ + AlterSortedManyToManyField( + model_name='coursetype', + name='course_run_types', + field=sortedm2m.fields.SortedManyToManyField(help_text='Sets the order for displaying Course Run Types.', to='course_metadata.CourseRunType'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0214_removeredirectsconfig.py b/course_discovery/apps/course_metadata/migrations/0214_removeredirectsconfig.py new file mode 100644 index 0000000000..43c5510a1e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0214_removeredirectsconfig.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-11-08 18:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0213_order_course_run_types'), + ] + + operations = [ + migrations.CreateModel( + name='RemoveRedirectsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remove_all', models.BooleanField(default=False, verbose_name='Remove All Redirects')), + ('url_paths', models.TextField(blank=True, default='', verbose_name='Url Paths')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0215_add_auto_generate_course_run_keys_to_organization.py b/course_discovery/apps/course_metadata/migrations/0215_add_auto_generate_course_run_keys_to_organization.py new file mode 100644 index 0000000000..54446e4b86 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0215_add_auto_generate_course_run_keys_to_organization.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-11-04 20:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0214_removeredirectsconfig'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='auto_generate_course_run_keys', + field=models.BooleanField(default=True, help_text='When this flag is enabled, the key of a new course run will be auto generated. When this flag is disabled, the key can be manually set.', verbose_name='Automatically generate course run keys'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0216_coursetype_white_listed_orgs.py b/course_discovery/apps/course_metadata/migrations/0216_coursetype_white_listed_orgs.py new file mode 100644 index 0000000000..dc61bb2ab7 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0216_coursetype_white_listed_orgs.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-12 19:15 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0215_add_auto_generate_course_run_keys_to_organization'), + ] + + operations = [ + migrations.AddField( + model_name='coursetype', + name='white_listed_orgs', + field=models.ManyToManyField(blank=True, help_text='Leave this blank to allow all orgs. Otherwise, specifies which orgs can see this course type in Publisher.', to='course_metadata.Organization'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0217_add_empty_course_type.py b/course_discovery/apps/course_metadata/migrations/0217_add_empty_course_type.py new file mode 100644 index 0000000000..d1ffd7720b --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0217_add_empty_course_type.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +EMPTY_NAME = 'Empty' +EMPTY_SLUG = 'empty' + +def add_empty_course_type(apps, schema_editor): + CourseType = apps.get_model('course_metadata', 'CourseType') + CourseRunType = apps.get_model('course_metadata', 'CourseRunType') + + CourseType.objects.update_or_create(slug=EMPTY_SLUG, defaults={'name': EMPTY_NAME}) + CourseRunType.objects.update_or_create(slug=EMPTY_SLUG, defaults={'name': EMPTY_NAME, 'is_marketable': False}) + +def drop_empty_course_type(apps, schema_editor): + CourseType = apps.get_model('course_metadata', 'CourseType') + CourseRunType = apps.get_model('course_metadata', 'CourseRunType') + + CourseType.objects.filter(slug__in=EMPTY_SLUG).delete() + CourseRunType.objects.filter(slug__in=EMPTY_SLUG).delete() + +class Migration(migrations.Migration): + dependencies = [ + ('course_metadata', '0216_coursetype_white_listed_orgs'), + ] + + operations = [ + migrations.RunPython( + code=add_empty_course_type, + reverse_code=drop_empty_course_type, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0218_leveltype_sort_value.py b/course_discovery/apps/course_metadata/migrations/0218_leveltype_sort_value.py new file mode 100644 index 0000000000..01d3e78117 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0218_leveltype_sort_value.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-18 22:01 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0217_add_empty_course_type'), + ] + + operations = [ + migrations.AddField( + model_name='leveltype', + name='sort_value', + field=models.PositiveSmallIntegerField(db_index=True, default=0), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0219_leveltype_sort_value_copy_values.py b/course_discovery/apps/course_metadata/migrations/0219_leveltype_sort_value_copy_values.py new file mode 100644 index 0000000000..51456c68e8 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0219_leveltype_sort_value_copy_values.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-01 17:04 +from __future__ import unicode_literals + +from django.db import migrations + + +def copy_column_values_forwards(apps, schema_editor): + """ + Copy the order field into the sort_value field. + + This table should have only about 3 rows, so there is no risk of extended + table locking during this migration. + """ + LevelType = apps.get_model('course_metadata', 'LevelType') + for level_type in LevelType.objects.all(): + level_type.sort_value = level_type.order + level_type.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0218_leveltype_sort_value'), + ] + + operations = [ + migrations.RunPython( + copy_column_values_forwards, + reverse_code=migrations.RunPython.noop, # Allow reverse migrations, but make it a no-op. + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0220_leveltype_ordering.py b/course_discovery/apps/course_metadata/migrations/0220_leveltype_ordering.py new file mode 100644 index 0000000000..0d1d417e90 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0220_leveltype_ordering.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0219_leveltype_sort_value_copy_values'), + ] + + operations = [ + migrations.AlterModelOptions( + name='leveltype', + options={'ordering': ('sort_value',)}, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0221_leveltype_remove_order.py b/course_discovery/apps/course_metadata/migrations/0221_leveltype_remove_order.py new file mode 100644 index 0000000000..0273fd213e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0221_leveltype_remove_order.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0220_leveltype_ordering'), + ] + + operations = [ + migrations.RemoveField( + model_name='leveltype', + name='order', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0222_adds_seat_deadline_overried.py b/course_discovery/apps/course_metadata/migrations/0222_adds_seat_deadline_overried.py new file mode 100644 index 0000000000..c4ec8f0269 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0222_adds_seat_deadline_overried.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-11-20 17:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0221_leveltype_remove_order'), + ] + + operations = [ + migrations.AddField( + model_name='historicalseat', + name='upgrade_deadline_override', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='seat', + name='upgrade_deadline_override', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0223_rename_upgrade_deadline.py b/course_discovery/apps/course_metadata/migrations/0223_rename_upgrade_deadline.py new file mode 100644 index 0000000000..8eccb34cdf --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0223_rename_upgrade_deadline.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-11-26 17:33 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0222_adds_seat_deadline_overried'), + ] + + operations = [ + migrations.AlterField( + model_name='seat', + name='upgrade_deadline', + field=models.DateTimeField(blank=True, db_column='upgrade_deadline', null=True), + ), + migrations.RenameField( + model_name='seat', + old_name='upgrade_deadline', + new_name='_upgrade_deadline', + ), + migrations.AlterField( + model_name='historicalseat', + name='upgrade_deadline', + field=models.DateTimeField(blank=True, db_column='upgrade_deadline', null=True), + ), + migrations.RenameField( + model_name='historicalseat', + old_name='upgrade_deadline', + new_name='_upgrade_deadline', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0224_fill_in_course_types.py b/course_discovery/apps/course_metadata/migrations/0224_fill_in_course_types.py new file mode 100644 index 0000000000..42b070457c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0224_fill_in_course_types.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-09 14:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +def null_to_empty(apps, _schema_editor): + """ + Replaces any None CourseType or CourseRunType values with the special empty type. + """ + for model_name in ('Course', 'CourseRun'): + type_model = apps.get_model('course_metadata', model_name + 'Type') + empty = type_model.objects.get(slug='empty') + + model = apps.get_model('course_metadata', model_name) + for obj in model.everything.all(): + if obj.type is None: + obj.type = empty + obj.save() + + +def drop_audit_entitlement(apps, _schema_editor): + """ + Audit course types used to allow audit entitlements to support frontend-app-publisher. That's no longer necessary. + """ + CourseType = apps.get_model('course_metadata', 'CourseType') + audit = CourseType.objects.get(slug='audit') + audit.entitlement_types.clear() + + # Also delete any draft audit entitlements that got created while we were making them. + CourseEntitlement = apps.get_model('course_metadata', 'CourseEntitlement') + CourseEntitlement.everything.filter(draft=True, mode__slug='audit').delete() + + +def set_audit_entitlement(apps, _schema_editor): + """ + Audit course types used to allow audit entitlements to support frontend-app-publisher. Add it back. + """ + CourseType = apps.get_model('course_metadata', 'CourseType') + SeatType = apps.get_model('course_metadata', 'SeatType') + audit_course_type = CourseType.objects.get(slug='audit') + audit_seat_type = SeatType.objects.get(slug='audit') + audit_course_type.entitlement_types.set([audit_seat_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0223_rename_upgrade_deadline'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='course_metadata.CourseType'), + ), + migrations.AlterField( + model_name='courserun', + name='type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='course_metadata.CourseRunType'), + ), + migrations.RunPython( + null_to_empty, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + drop_audit_entitlement, + set_audit_entitlement, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0225_add_program_hooks.py b/course_discovery/apps/course_metadata/migrations/0225_add_program_hooks.py new file mode 100644 index 0000000000..c5e65ebb6b --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0225_add_program_hooks.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-09 12:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0224_fill_in_course_types'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprogram', + name='marketing_hook', + field=models.CharField(blank=True, help_text='A brief hook for the marketing website', max_length=255), + ), + migrations.AddField( + model_name='program', + name='marketing_hook', + field=models.CharField(blank=True, help_text='A brief hook for the marketing website', max_length=255), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0226_credit_not_verified.py b/course_discovery/apps/course_metadata/migrations/0226_credit_not_verified.py new file mode 100644 index 0000000000..dba0aad89f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0226_credit_not_verified.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def credit_is_not_verified(apps, _schema_editor): + Mode = apps.get_model('course_metadata', 'Mode') + Mode.objects.filter(slug='credit').update(is_id_verified=False) + + +def credit_is_verified(apps, _schema_editor): + Mode = apps.get_model('course_metadata', 'Mode') + Mode.objects.filter(slug='credit').update(is_id_verified=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0225_add_program_hooks'), + ] + + operations = [ + migrations.RunPython( + credit_is_not_verified, + credit_is_verified, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0227_historicalorganization.py b/course_discovery/apps/course_metadata/migrations/0227_historicalorganization.py new file mode 100644 index 0000000000..18e68cbd5f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0227_historicalorganization.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-12 20:43 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0017_drop_oidc_fields'), + ('course_metadata', '0226_credit_not_verified'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalOrganization', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('key', models.CharField(help_text="Please do not use any spaces or special characters other than period, underscore or hyphen. This key will be used in the course's course key.", max_length=255)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('homepage_url', models.URLField(blank=True, max_length=255, null=True)), + ('logo_image_url', models.URLField(blank=True, null=True)), + ('banner_image_url', models.URLField(blank=True, null=True)), + ('certificate_logo_image_url', models.URLField(blank=True, help_text='Logo to be displayed on certificates. If this logo is the same as logo_image_url, copy and paste the same value to both fields.', null=True)), + ('salesforce_id', models.CharField(blank=True, max_length=255, null=True)), + ('auto_generate_course_run_keys', models.BooleanField(default=True, help_text='When this flag is enabled, the key of a new course run will be auto generated. When this flag is disabled, the key can be manually set.', verbose_name='Automatically generate course run keys')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('partner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.Partner')), + ], + options={ + 'verbose_name': 'historical organization', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0228_bulkmodifyprogramhook.py b/course_discovery/apps/course_metadata/migrations/0228_bulkmodifyprogramhook.py new file mode 100644 index 0000000000..43743aab36 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0228_bulkmodifyprogramhook.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-13 20:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0227_historicalorganization'), + ] + + operations = [ + migrations.CreateModel( + name='BulkModifyProgramHookConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('program_hooks', models.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0229_add_certificate_name_to_org.py b/course_discovery/apps/course_metadata/migrations/0229_add_certificate_name_to_org.py new file mode 100644 index 0000000000..97904edccc --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0229_add_certificate_name_to_org.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-18 14:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0228_bulkmodifyprogramhook'), + ] + + operations = [ + migrations.AddField( + model_name='historicalorganization', + name='certificate_name', + field=models.CharField(help_text='If populated, this field will overwrite name in platform.', max_length=255, null=True), + ), + migrations.AddField( + model_name='organization', + name='certificate_name', + field=models.CharField(help_text='If populated, this field will overwrite name in platform.', max_length=255, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0230_update_ofac_choices.py b/course_discovery/apps/course_metadata/migrations/0230_update_ofac_choices.py new file mode 100644 index 0000000000..f2d8a9222a --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0230_update_ofac_choices.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-18 18:30 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0229_add_certificate_name_to_org'), + ] + + operations = [ + migrations.AlterField( + model_name='courserun', + name='has_ofac_restrictions', + field=models.NullBooleanField(choices=[('', '--'), (True, 'Blocked'), (False, 'Unrestricted')], default=None, verbose_name='Add OFAC restriction text to the FAQ section of the Marketing site'), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='has_ofac_restrictions', + field=models.NullBooleanField(choices=[('', '--'), (True, 'Blocked'), (False, 'Unrestricted')], default=None, verbose_name='Add OFAC restriction text to the FAQ section of the Marketing site'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0231_add_org_image_fields.py b/course_discovery/apps/course_metadata/migrations/0231_add_org_image_fields.py new file mode 100644 index 0000000000..f9342fc2b6 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0231_add_org_image_fields.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-17 17:35 +from __future__ import unicode_literals + +import course_discovery.apps.course_metadata.utils +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0230_update_ofac_choices'), + ] + + operations = [ + migrations.AddField( + model_name='historicalorganization', + name='banner_image', + field=models.TextField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalorganization', + name='certificate_logo_image', + field=models.TextField(blank=True, max_length=100, null=True, validators=[django.core.validators.FileExtensionValidator(['png'])]), + ), + migrations.AddField( + model_name='historicalorganization', + name='logo_image', + field=models.TextField(blank=True, max_length=100, null=True, validators=[django.core.validators.FileExtensionValidator(['png'])]), + ), + migrations.AddField( + model_name='organization', + name='banner_image', + field=models.ImageField(blank=True, null=True, upload_to=course_discovery.apps.course_metadata.utils.UploadToFieldNamePath('uuid', path='organization/banner_images')), + ), + migrations.AddField( + model_name='organization', + name='certificate_logo_image', + field=models.ImageField(blank=True, null=True, upload_to=course_discovery.apps.course_metadata.utils.UploadToFieldNamePath('uuid', path='organization/certificate_logos'), validators=[django.core.validators.FileExtensionValidator(['png'])]), + ), + migrations.AddField( + model_name='organization', + name='logo_image', + field=models.ImageField(blank=True, null=True, upload_to=course_discovery.apps.course_metadata.utils.UploadToFieldNamePath('uuid', path='organization/logos'), validators=[django.core.validators.FileExtensionValidator(['png'])]), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0232_remove_url_fields_from_org.py b/course_discovery/apps/course_metadata/migrations/0232_remove_url_fields_from_org.py new file mode 100644 index 0000000000..dffb8ea4fb --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0232_remove_url_fields_from_org.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.27 on 2020-01-08 19:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0231_add_org_image_fields'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalorganization', + name='banner_image_url', + ), + migrations.RemoveField( + model_name='historicalorganization', + name='certificate_logo_image_url', + ), + migrations.RemoveField( + model_name='historicalorganization', + name='logo_image_url', + ), + migrations.RemoveField( + model_name='organization', + name='banner_image_url', + ), + migrations.RemoveField( + model_name='organization', + name='certificate_logo_image_url', + ), + migrations.RemoveField( + model_name='organization', + name='logo_image_url', + ), + migrations.AlterField( + model_name='historicalorganization', + name='certificate_name', + field=models.CharField(blank=True, help_text='If populated, this field will overwrite name in platform.', max_length=255, null=True), + ), + migrations.AlterField( + model_name='organization', + name='certificate_name', + field=models.CharField(blank=True, help_text='If populated, this field will overwrite name in platform.', max_length=255, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0233_backfillcourserunslugsconfig.py b/course_discovery/apps/course_metadata/migrations/0233_backfillcourserunslugsconfig.py new file mode 100644 index 0000000000..5d6a5d49b6 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0233_backfillcourserunslugsconfig.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.27 on 2020-01-13 19:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0232_remove_url_fields_from_org'), + ] + + operations = [ + migrations.CreateModel( + name='BackfillCourseRunSlugsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('all', models.BooleanField(default=False, verbose_name='Add redirects from all published course url slugs')), + ('uuids', models.TextField(blank=True, default='', verbose_name='Course uuids')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0234_add_credit_value_to_program_model.py b/course_discovery/apps/course_metadata/migrations/0234_add_credit_value_to_program_model.py new file mode 100644 index 0000000000..32e2a042b3 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0234_add_credit_value_to_program_model.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.27 on 2020-01-23 16:08 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0233_backfillcourserunslugsconfig'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprogram', + name='credit_value', + field=models.PositiveSmallIntegerField(blank=True, default=0, help_text='Number of credits a learner will earn upon successful completion of the program'), + ), + migrations.AddField( + model_name='program', + name='credit_value', + field=models.PositiveSmallIntegerField(blank=True, default=0, help_text='Number of credits a learner will earn upon successful completion of the program'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0235_auto_20200207_1314.py b/course_discovery/apps/course_metadata/migrations/0235_auto_20200207_1314.py new file mode 100644 index 0000000000..96ae672e62 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0235_auto_20200207_1314.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-02-07 13:14 +from __future__ import unicode_literals + +import stdimage.models +from django.db import migrations + +from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0234_add_credit_value_to_program_model'), + ] + + operations = [ + migrations.AlterField( + model_name='programtype', + name='logo_image', + field=stdimage.models.StdImageField(blank=True, help_text='Please provide an image file with transparent background', null=True, upload_to=UploadToFieldNamePath(populate_from='name', path='media/program_types/logo_images/')), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0236_auto_20200225_1340.py b/course_discovery/apps/course_metadata/migrations/0236_auto_20200225_1340.py new file mode 100644 index 0000000000..2a68e33c5f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0236_auto_20200225_1340.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-02-25 13:40 +from __future__ import unicode_literals + +from django.db import migrations +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0235_auto_20200207_1314'), + ] + + operations = [ + migrations.AlterField( + model_name='courserun', + name='slug', + field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from=['title', 'key']), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0237_add_program_type_uuid_and_coaching.py b/course_discovery/apps/course_metadata/migrations/0237_add_program_type_uuid_and_coaching.py new file mode 100644 index 0000000000..c78ea0af86 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0237_add_program_type_uuid_and_coaching.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-02-25 13:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +def add_uuid_to_program_types(apps, schema_editor): + program_type = apps.get_model('course_metadata', 'ProgramType') + + for obj in program_type.objects.all(): + obj.uuid = uuid.uuid4() + obj.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0236_auto_20200225_1340'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprogramtype', + name='coaching_supported', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalprogramtype', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID'), + ), + migrations.AddField( + model_name='programtype', + name='coaching_supported', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='programtype', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID', null=True), + ), + migrations.RunPython(add_uuid_to_program_types, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='programtype', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, verbose_name='UUID', editable=False, unique=True, null=False), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0238_algoliaproxycourse_algoliaproxyprogram.py b/course_discovery/apps/course_metadata/migrations/0238_algoliaproxycourse_algoliaproxyprogram.py new file mode 100644 index 0000000000..807bb2fa47 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0238_algoliaproxycourse_algoliaproxyprogram.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-02-27 16:38 +from __future__ import unicode_literals + +from django.db import migrations +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0237_add_program_type_uuid_and_coaching'), + ] + + operations = [ + migrations.CreateModel( + name='AlgoliaProxyCourse', + fields=[ + ], + options={ + 'indexes': [], + 'proxy': True, + }, + bases=('course_metadata.course',), + managers=[ + ('everything', django.db.models.manager.Manager()), + ], + ), + migrations.CreateModel( + name='AlgoliaProxyProgram', + fields=[ + ], + options={ + 'indexes': [], + 'proxy': True, + }, + bases=('course_metadata.program',), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0239_auto_20200408_1952.py b/course_discovery/apps/course_metadata/migrations/0239_auto_20200408_1952.py new file mode 100644 index 0000000000..28a1edeb79 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0239_auto_20200408_1952.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-04-08 19:52 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0238_algoliaproxycourse_algoliaproxyprogram'), + ] + + operations = [ + migrations.CreateModel( + name='AlgoliaProxyProduct', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + }, + bases=('course_metadata.program',), + ), + migrations.AlterModelOptions( + name='person', + options={'ordering': ['id'], 'verbose_name_plural': 'People'}, + ), + migrations.AlterField( + model_name='courserun', + name='hidden', + field=models.BooleanField(default=False, help_text='Whether this run should be hidden from API responses. Do not edit here - this value will be overwritten by the "Course Visibility In Catalog" field in Studio via Refresh Course Metadata.'), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='hidden', + field=models.BooleanField(default=False, help_text='Whether this run should be hidden from API responses. Do not edit here - this value will be overwritten by the "Course Visibility In Catalog" field in Studio via Refresh Course Metadata.'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0240_auto_20200409_1937.py b/course_discovery/apps/course_metadata/migrations/0240_auto_20200409_1937.py new file mode 100644 index 0000000000..2a663dd32e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0240_auto_20200409_1937.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-04-09 19:37 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0239_auto_20200408_1952'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalcourserun', + name='history_user', + field=models.ForeignKey(db_constraint=False, null=True, on_delete=models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0241_readd_foreign_key_to_history_user_field.py b/course_discovery/apps/course_metadata/migrations/0241_readd_foreign_key_to_history_user_field.py new file mode 100644 index 0000000000..052634b495 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0241_readd_foreign_key_to_history_user_field.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.12 on 2020-04-14 15:30 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0240_auto_20200409_1937'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalcourserun', + name='history_user', + field=models.ForeignKey(db_constraint=True, null=True, on_delete=models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0242_auto_20200415_0643.py b/course_discovery/apps/course_metadata/migrations/0242_auto_20200415_0643.py new file mode 100644 index 0000000000..2808f3a669 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0242_auto_20200415_0643.py @@ -0,0 +1,66 @@ +# Generated by Django 2.2.12 on 2020-04-15 06:43 + +from django.db import migrations, models +import django.db.models.deletion +import sortedm2m.fields +import taggit_autosuggest.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0241_readd_foreign_key_to_history_user_field'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='draft_version', + field=models.OneToOneField(blank=True, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='course', + name='topics', + field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='Pick a tag from the suggestions. To make a new tag, add a comma after the tag name.', related_name='course_topics', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AlterField( + model_name='courseentitlement', + name='draft_version', + field=models.OneToOneField(blank=True, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.CourseEntitlement'), + ), + migrations.AlterField( + model_name='courserun', + name='draft_version', + field=models.OneToOneField(blank=True, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.CourseRun'), + ), + migrations.AlterField( + model_name='historicalcourse', + name='draft_version', + field=models.ForeignKey(blank=True, db_constraint=False, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='historicalcourseentitlement', + name='draft_version', + field=models.ForeignKey(blank=True, db_constraint=False, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseEntitlement'), + ), + migrations.AlterField( + model_name='historicalcourserun', + name='draft_version', + field=models.ForeignKey(blank=True, db_constraint=False, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.CourseRun'), + ), + migrations.AlterField( + model_name='historicalseat', + name='draft_version', + field=models.ForeignKey(blank=True, db_constraint=False, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.Seat'), + ), + migrations.AlterField( + model_name='program', + name='courses', + field=sortedm2m.fields.SortedManyToManyField(help_text=None, limit_choices_to={'draft': False}, related_name='programs', to='course_metadata.Course'), + ), + migrations.AlterField( + model_name='seat', + name='draft_version', + field=models.OneToOneField(blank=True, limit_choices_to={'draft': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='_official_version', to='course_metadata.Seat'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0243_auto_20200427_1636.py b/course_discovery/apps/course_metadata/migrations/0243_auto_20200427_1636.py new file mode 100644 index 0000000000..005f6794f7 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0243_auto_20200427_1636.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.12 on 2020-04-27 16:36 + +from django.db import migrations, models +import django.db.models.deletion +import parler.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0242_auto_20200415_0643'), + ] + + operations = [ + migrations.AlterModelOptions( + name='programtype', + options={}, + ), + migrations.CreateModel( + name='ProgramTypeTranslation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')), + ('name_t', models.CharField(max_length=32)), + ('master', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='course_metadata.ProgramType')), + ], + options={ + 'unique_together': {('name_t', 'language_code'), ('language_code', 'master')}, + 'verbose_name': 'ProgramType model translations', + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0244_auto_20200427_1514.py b/course_discovery/apps/course_metadata/migrations/0244_auto_20200427_1514.py new file mode 100644 index 0000000000..072594603b --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0244_auto_20200427_1514.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-04-27 15:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0243_auto_20200427_1636'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalprogramtype', + name='name', + field=models.CharField(max_length=32), + ), + migrations.AlterField( + model_name='programtype', + name='name', + field=models.CharField(max_length=32), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0245_migrate_programtype_name_to_translatable_model.py b/course_discovery/apps/course_metadata/migrations/0245_migrate_programtype_name_to_translatable_model.py new file mode 100644 index 0000000000..e8a62aaa49 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0245_migrate_programtype_name_to_translatable_model.py @@ -0,0 +1,50 @@ +# Generated by Django 2.2.12 on 2020-04-23 18:11 + +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-09-11 17:06 +from __future__ import unicode_literals + +import logging + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.db import migrations + +logger = logging.getLogger(__name__) + + +def forwards_func(apps, schema_editor): + ProgramType = apps.get_model('course_metadata', 'ProgramType') + ProgramTypeTranslation = apps.get_model('course_metadata', 'ProgramTypeTranslation') + + for program_type in ProgramType.objects.all(): + ProgramTypeTranslation.objects.update_or_create( + master_id=program_type.pk, + language_code=settings.PARLER_DEFAULT_LANGUAGE_CODE, + name_t=program_type.name, + ) + + +def backwards_func(apps, schema_editor): + ProgramType = apps.get_model('course_metadata', 'ProgramType') + ProgramTypeTranslation = apps.get_model('course_metadata', 'ProgramTypeTranslation') + + for program_type in ProgramType.objects.all(): + try: + translation = ProgramTypeTranslation.objects.get(master_id=program_type.pk, language_code=settings.LANGUAGE_CODE) + program_type.name = translation.name_t + program_type.save() # Note this only calls Model.save() + except ObjectDoesNotExist: + # nothing to migrate + logger.warning('Migrating data from ProgramTypeTranslation for master_id={} DoesNotExist'.format(program_type.pk)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0244_auto_20200427_1514'), + ] + + operations = [ + migrations.RunPython(forwards_func, backwards_func), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0246_leveltypetranslation.py b/course_discovery/apps/course_metadata/migrations/0246_leveltypetranslation.py new file mode 100644 index 0000000000..8de1fa9554 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0246_leveltypetranslation.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.12 on 2020-04-28 18:01 + +from django.db import migrations, models +import django.db.models.deletion +import parler.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0245_migrate_programtype_name_to_translatable_model'), + ] + + operations = [ + migrations.CreateModel( + name='LevelTypeTranslation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')), + ('name_t', models.CharField(max_length=255)), + ('master', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='course_metadata.LevelType')), + ], + options={ + 'verbose_name': 'LevelType model translations', + 'unique_together': {('language_code', 'master'), ('language_code', 'name_t')}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0247_auto_20200428_1908.py b/course_discovery/apps/course_metadata/migrations/0247_auto_20200428_1908.py new file mode 100644 index 0000000000..9032df8317 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0247_auto_20200428_1908.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-04-28 19:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0246_leveltypetranslation'), + ] + + operations = [ + migrations.AlterField( + model_name='programtypetranslation', + name='name_t', + field=models.CharField(max_length=32, verbose_name='name'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0248_auto_20200428_1910.py b/course_discovery/apps/course_metadata/migrations/0248_auto_20200428_1910.py new file mode 100644 index 0000000000..df9be5ae86 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0248_auto_20200428_1910.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.12 on 2020-04-28 19:10 + +from django.db import migrations +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0247_auto_20200428_1908'), + ] + + operations = [ + migrations.AlterField( + model_name='programtype', + name='slug', + field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False, help_text='Leave this field blank to have the value generated automatically.', populate_from='name_t', unique=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0249_auto_20200430_1211.py b/course_discovery/apps/course_metadata/migrations/0249_auto_20200430_1211.py new file mode 100644 index 0000000000..de2ebb9b1d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0249_auto_20200430_1211.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-04-30 12:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0248_auto_20200428_1910'), + ] + + operations = [ + migrations.AlterField( + model_name='leveltype', + name='name', + field=models.CharField(max_length=255), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0250_copy_level_type_data.py b/course_discovery/apps/course_metadata/migrations/0250_copy_level_type_data.py new file mode 100644 index 0000000000..06363a3c6d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0250_copy_level_type_data.py @@ -0,0 +1,48 @@ +# Generated by Django 2.2.12 on 2020-04-30 12:00 +from __future__ import unicode_literals + +import logging + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.db import migrations + +logger = logging.getLogger(__name__) + + +def forwards_func(apps, schema_editor): + LevelType = apps.get_model('course_metadata', 'LevelType') + LevelTypeTranslation = apps.get_model('course_metadata', 'LevelTypeTranslation') + + for level_type in LevelType.objects.all(): + LevelTypeTranslation.objects.update_or_create( + master_id=level_type.pk, + language_code=settings.PARLER_DEFAULT_LANGUAGE_CODE, + name_t=level_type.name, + ) + + +def backwards_func(apps, schema_editor): + LevelType = apps.get_model('course_metadata', 'LevelType') + LevelTypeTranslation = apps.get_model('course_metadata', 'LevelTypeTranslation') + + for level_type in LevelType.objects.all(): + try: + translation = LevelTypeTranslation.objects.get(master_id=level_type.pk, language_code=settings.LANGUAGE_CODE) + level_type.name = translation.name_t + level_type.save() # Note this only calls Model.save() + except ObjectDoesNotExist: + # nothing to migrate + logger.warning('Migrating data from LevelTypeTranslation for master_id={} DoesNotExist'.format(level_type.pk)) + + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0249_auto_20200430_1211'), + ] + + operations = [ + migrations.RunPython(forwards_func, backwards_func), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0251_auto_20200518_2054.py b/course_discovery/apps/course_metadata/migrations/0251_auto_20200518_2054.py new file mode 100644 index 0000000000..4fe6ab758f --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0251_auto_20200518_2054.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-05-18 20:54 + +from django.db import migrations, models +import sortedm2m.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0250_copy_level_type_data'), + ] + + operations = [ + migrations.CreateModel( + name='SearchDefaultResultsConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('index_name', models.CharField(max_length=32, unique=True)), + ('courses', sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, to='course_metadata.Course')), + ('programs', sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, to='course_metadata.Program')), + ], + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0252_add_honor_course_type.py b/course_discovery/apps/course_metadata/migrations/0252_add_honor_course_type.py new file mode 100644 index 0000000000..739d41e4f8 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0252_add_honor_course_type.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +# Contains the slug (unique identifier) and any fields that are not the default-model values +MODE_SLUGS_AND_DEFAULTS = ( + ('honor', { + 'name': 'Honor', + 'certificate_type': 'honor', + }), +) +# The slugs are identical across Mode and SeatType +TRACK_MODE_AND_SEAT_SLUGS = ('honor',) +COURSE_RUN_TYPE_NAMES_SLUGS_AND_TRACK_SLUGS = ( + ('Honor Only', 'honor', ['honor']), + ('Verified and Honor', 'verified-honor', ['honor', 'verified']), + ('Credit with Honor', 'credit-verified-honor', ['credit', 'honor', 'verified']), +) +COURSE_TYPE_NAMES_SLUGS_SEAT_TYPE_SLUGS_AND_RUN_TYPE_SLUGS = ( + ('Honor Only', 'honor', [], ['honor']), + ('Verified and Honor', 'verified-honor', ['verified'], ['honor', 'verified-honor']), + ('Credit with Honor', 'credit-verified-honor', ['verified'], ['honor', 'verified-honor', 'credit-verified-honor']), +) + +def add_honor_course_type_and_children(apps, schema_editor): + CourseType = apps.get_model('course_metadata', 'CourseType') + CourseRunType = apps.get_model('course_metadata', 'CourseRunType') + Track = apps.get_model('course_metadata', 'Track') + Mode = apps.get_model('course_metadata', 'Mode') + SeatType = apps.get_model('course_metadata', 'SeatType') + + for slug, defaults in MODE_SLUGS_AND_DEFAULTS: + Mode.objects.get_or_create(slug=slug, defaults=defaults) + for slug in TRACK_MODE_AND_SEAT_SLUGS: + mode = Mode.objects.get(slug=slug) + seat_type, _created = SeatType.objects.get_or_create(slug=slug, defaults={'name': slug.capitalize()}) + Track.objects.get_or_create(mode=mode, seat_type=seat_type) + for name, slug, track_slugs in COURSE_RUN_TYPE_NAMES_SLUGS_AND_TRACK_SLUGS: + tracks = [] + for track_slug in track_slugs: + mode = Mode.objects.get(slug=track_slug) + seat_type = SeatType.objects.get(slug=track_slug) + tracks.append(Track.objects.get(mode=mode, seat_type=seat_type)) + run_type, _created = CourseRunType.objects.get_or_create(slug=slug, defaults={'name': name}) + run_type.tracks.set(tracks) + for course_name, slug, seat_type_slugs, run_type_slugs in COURSE_TYPE_NAMES_SLUGS_SEAT_TYPE_SLUGS_AND_RUN_TYPE_SLUGS: + course_type, _created = CourseType.objects.get_or_create(slug=slug, defaults={'name': course_name}) + + entitlement_types = [SeatType.objects.get(slug=seat_type_slug) for seat_type_slug in seat_type_slugs] + course_type.entitlement_types.set(entitlement_types) + + run_types = [CourseRunType.objects.get(slug=run_type_slug) for run_type_slug in run_type_slugs] + course_type.course_run_types.set(run_types) + +def drop_honor_course_type_and_children(apps, schema_editor): + CourseType = apps.get_model('course_metadata', 'CourseType') + CourseRunType = apps.get_model('course_metadata', 'CourseRunType') + Track = apps.get_model('course_metadata', 'Track') + Mode = apps.get_model('course_metadata', 'Mode') + + course_type_slugs = [slug for __, slug, __, __ in COURSE_TYPE_NAMES_SLUGS_SEAT_TYPE_SLUGS_AND_RUN_TYPE_SLUGS] + course_run_type_slugs = [slug for __, slug, __ in COURSE_RUN_TYPE_NAMES_SLUGS_AND_TRACK_SLUGS] + mode_slugs = [slug for slug, __ in MODE_SLUGS_AND_DEFAULTS] + + CourseType.objects.filter(slug__in=course_type_slugs).delete() + CourseRunType.objects.filter(slug__in=course_run_type_slugs).delete() + + modes = [Mode.objects.get(slug=slug) for slug in TRACK_MODE_AND_SEAT_SLUGS] + Track.objects.filter(mode__in=modes).delete() + + Mode.objects.filter(slug__in=mode_slugs).delete() + +class Migration(migrations.Migration): + dependencies = [ + ('course_metadata', '0251_auto_20200518_2054'), + ] + + operations = [ + migrations.RunPython( + code=add_honor_course_type_and_children, + reverse_code=drop_honor_course_type_and_children, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0253_auto_20210119_0650.py b/course_discovery/apps/course_metadata/migrations/0253_auto_20210119_0650.py new file mode 100644 index 0000000000..cf7f102276 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0253_auto_20210119_0650.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2021-01-19 06:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0252_add_honor_course_type'), + ] + + operations = [ + migrations.AddField( + model_name='historicalcourserun', + name='invite_only', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='leveltypetranslation', + name='name_t', + field=models.CharField(max_length=255, verbose_name='name'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0254_auto_20210201_1107.py b/course_discovery/apps/course_metadata/migrations/0254_auto_20210201_1107.py new file mode 100644 index 0000000000..b98dfe1c53 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0254_auto_20210201_1107.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2021-02-01 11:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0253_auto_20210119_0650'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='featured', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalcourserun', + name='featured', + field=models.BooleanField(default=False), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0255_add_designation_and_profile_image_url_to_person.py b/course_discovery/apps/course_metadata/migrations/0255_add_designation_and_profile_image_url_to_person.py new file mode 100644 index 0000000000..0a81762aa0 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0255_add_designation_and_profile_image_url_to_person.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2021-02-15 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0254_auto_20210201_1107'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='designation', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='person', + name='profile_image_url', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0256_add_instructor_info_fields_to_person.py b/course_discovery/apps/course_metadata/migrations/0256_add_instructor_info_fields_to_person.py new file mode 100644 index 0000000000..3fda8e4aac --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0256_add_instructor_info_fields_to_person.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.16 on 2021-02-18 08:09 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0255_add_designation_and_profile_image_url_to_person'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='marketing_id', + field=models.PositiveIntegerField(blank=True, help_text='This field contains instructor post ID from wordpress.', null=True), + ), + migrations.AddField( + model_name='person', + name='marketing_url', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='person', + name='phone_number', + field=models.CharField(blank=True, max_length=50, null=True, validators=[django.core.validators.RegexValidator(message='Phone number can only contain numbers.', regex='^\\+?1?\\d*$')]), + ), + migrations.AddField( + model_name='person', + name='website', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0257_add_social_media_type_choices.py b/course_discovery/apps/course_metadata/migrations/0257_add_social_media_type_choices.py new file mode 100644 index 0000000000..2c1dcf1b72 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0257_add_social_media_type_choices.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.16 on 2021-02-18 08:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0256_add_instructor_info_fields_to_person'), + ] + + operations = [ + migrations.AlterField( + model_name='personsocialnetwork', + name='type', + field=models.CharField(choices=[('blog', 'Blog'), ('dribbble', 'Dribbble'), ('facebook', 'Facebook'), ('github', 'github'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn'), ('medium', 'medium'), ('others', 'Others'), ('skype', 'Skype'), ('stackoverflow', 'stackoverflow'), ('twitter', 'Twitter'), ('youtube', 'Youtube')], db_index=True, max_length=15), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0258_subject_marketing_url.py b/course_discovery/apps/course_metadata/migrations/0258_subject_marketing_url.py new file mode 100644 index 0000000000..0e58451721 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0258_subject_marketing_url.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.16 on 2021-04-19 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0257_add_social_media_type_choices'), + ] + + operations = [ + migrations.AddField( + model_name='subject', + name='marketing_url', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0259_add_price_fields.py b/course_discovery/apps/course_metadata/migrations/0259_add_price_fields.py new file mode 100644 index 0000000000..fed1e1da7c --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0259_add_price_fields.py @@ -0,0 +1,48 @@ +# Generated by Django 2.2.16 on 2021-04-29 07:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0258_subject_marketing_url'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='is_marketing_price_hidden', + field=models.BooleanField(default=False, verbose_name='Hide Price'), + ), + migrations.AddField( + model_name='courserun', + name='is_marketing_price_set', + field=models.BooleanField(default=False, help_text='Indicates whether the course on marketing site is marked paid', verbose_name='Price'), + ), + migrations.AddField( + model_name='courserun', + name='marketing_price_value', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Price Value'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='is_marketing_price_hidden', + field=models.BooleanField(default=False, verbose_name='Hide Price'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='is_marketing_price_set', + field=models.BooleanField(default=False, help_text='Indicates whether the course on marketing site is marked paid', verbose_name='Price'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='marketing_price_value', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Price Value'), + ), + migrations.AddField( + model_name='subject', + name='marketing_id', + field=models.PositiveIntegerField(blank=True, help_text='This field contains subject post ID from marketing site.', null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0260_auto_20211025_1035.py b/course_discovery/apps/course_metadata/migrations/0260_auto_20211025_1035.py new file mode 100644 index 0000000000..1ceae25f04 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0260_auto_20211025_1035.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.16 on 2021-10-25 10:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0259_add_price_fields'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='average_rating', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=30), + ), + migrations.AddField( + model_name='courserun', + name='total_raters', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='historicalcourserun', + name='average_rating', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=30), + ), + migrations.AddField( + model_name='historicalcourserun', + name='total_raters', + field=models.IntegerField(default=0), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0261_auto_20221216_0743.py b/course_discovery/apps/course_metadata/migrations/0261_auto_20221216_0743.py new file mode 100644 index 0000000000..6cddf3ffb5 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0261_auto_20221216_0743.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2022-12-16 07:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0260_auto_20211025_1035'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='yt_video_url', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Youtube Video URL'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='yt_video_url', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Youtube Video URL'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0262_auto_20200624_1533.py b/course_discovery/apps/course_metadata/migrations/0262_auto_20200624_1533.py new file mode 100644 index 0000000000..2be6e6545e --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0262_auto_20200624_1533.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.12 on 2020-06-24 15:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0262_auto_20230621_1144'), + ] + + operations = [ + migrations.DeleteModel( + name='SearchDefaultResultsConfiguration', + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0262_auto_20230621_1144.py b/course_discovery/apps/course_metadata/migrations/0262_auto_20230621_1144.py new file mode 100644 index 0000000000..c160dc0aa5 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0262_auto_20230621_1144.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2023-06-21 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0261_auto_20221216_0743'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='course_duration_override', + field=models.PositiveIntegerField(blank=True, help_text='This field contains override course duration value.', null=True, verbose_name='Course Duration Override'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_duration_override', + field=models.PositiveIntegerField(blank=True, help_text='This field contains override course duration value.', null=True, verbose_name='Course Duration Override'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0263_auto_20200709_1828.py b/course_discovery/apps/course_metadata/migrations/0263_auto_20200709_1828.py new file mode 100644 index 0000000000..0e50c1bbdc --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0263_auto_20200709_1828.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-07-09 18:28 + +from django.db import migrations, models +import sortedm2m.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0262_auto_20200624_1533'), + ] + + operations = [ + migrations.CreateModel( + name='SearchDefaultResultsConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('index_name', models.CharField(max_length=32, unique=True)), + ('courses', sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, to='course_metadata.Course')), + ('programs', sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, to='course_metadata.Program')), + ], + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0264_course_collaborator.py b/course_discovery/apps/course_metadata/migrations/0264_course_collaborator.py new file mode 100644 index 0000000000..22239956c6 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0264_course_collaborator.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.13 on 2020-07-13 19:00 + +import course_discovery.apps.course_metadata.utils +from django.db import migrations, models +import django_extensions.db.fields +import sortedm2m.fields +import stdimage.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0263_auto_20200709_1828'), + ] + + operations = [ + migrations.CreateModel( + name='Collaborator', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('image', stdimage.models.StdImageField(blank=True, help_text='Add the collaborator image, please make sure its dimensions are 80x80px', null=True, upload_to=course_discovery.apps.course_metadata.utils.UploadToFieldNamePath(path='media/course/collaborator/image/', populate_from='uuid'))), + ('name', models.CharField(default='', max_length=255)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ], + options={ + 'get_latest_by': 'modified', + 'ordering': ('-modified', '-created'), + 'abstract': False, + }, + ), + migrations.AddField( + model_name='course', + name='collaborators', + field=sortedm2m.fields.SortedManyToManyField(blank=True, help_text=None, related_name='courses_collaborated', to='course_metadata.Collaborator'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0265_auto_20200804_1401.py b/course_discovery/apps/course_metadata/migrations/0265_auto_20200804_1401.py new file mode 100644 index 0000000000..cec1b04984 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0265_auto_20200804_1401.py @@ -0,0 +1,82 @@ +# Generated by Django 2.2.14 on 2020-08-04 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0264_course_collaborator'), + ] + + operations = [ + migrations.AlterModelOptions( + name='collaborator', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='corporateendorsement', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='courseruntype', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='coursetype', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='curriculum', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='curriculumcoursemembership', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='curriculumcourserunexclusion', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='curriculumprogrammembership', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='endorsement', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='mode', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='pathway', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='position', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='program', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='ranking', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='seattype', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterModelOptions( + name='track', + options={'get_latest_by': 'modified'}, + ), + migrations.AlterField( + model_name='leveltypetranslation', + name='name_t', + field=models.CharField(max_length=255, verbose_name='name'), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0266_curriculum_membership_uniqueness.py b/course_discovery/apps/course_metadata/migrations/0266_curriculum_membership_uniqueness.py new file mode 100644 index 0000000000..153a0b9a15 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0266_curriculum_membership_uniqueness.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.14 on 2020-08-04 17:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0265_auto_20200804_1401'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='curriculumcoursemembership', + unique_together={('curriculum', 'course')}, + ), + migrations.AlterUniqueTogether( + name='curriculumprogrammembership', + unique_together={('curriculum', 'program')}, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0267_auto_20200813_1422.py b/course_discovery/apps/course_metadata/migrations/0267_auto_20200813_1422.py new file mode 100644 index 0000000000..d648cbdc12 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0267_auto_20200813_1422.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.15 on 2020-08-13 14:22 + +import course_discovery.apps.course_metadata.utils +from django.db import migrations +import stdimage.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0266_curriculum_membership_uniqueness'), + ] + + operations = [ + migrations.AlterField( + model_name='collaborator', + name='image', + field=stdimage.models.StdImageField(blank=True, help_text='Add the collaborator image, please make sure its dimensions are 200x100px', null=True, upload_to=course_discovery.apps.course_metadata.utils.UploadToFieldNamePath(path='media/course/collaborator/image/', populate_from='uuid')), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0268_auto_20201019_1913.py b/course_discovery/apps/course_metadata/migrations/0268_auto_20201019_1913.py new file mode 100644 index 0000000000..4ba2167728 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0268_auto_20201019_1913.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.16 on 2020-10-19 19:13 + +import course_discovery.apps.course_metadata.utils +from django.db import migrations, models +import stdimage.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0267_auto_20200813_1422'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprogram', + name='card_image', + field=models.TextField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='program', + name='card_image', + field=stdimage.models.StdImageField(blank=True, null=True, upload_to=course_discovery.apps.course_metadata.utils.UploadToFieldNamePath(path='media/programs/card_images/', populate_from='uuid')), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0269_depr_program_card_image_url_and_add_exec_ed_cert_type.py b/course_discovery/apps/course_metadata/migrations/0269_depr_program_card_image_url_and_add_exec_ed_cert_type.py new file mode 100644 index 0000000000..55666dae9d --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0269_depr_program_card_image_url_and_add_exec_ed_cert_type.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.16 on 2020-10-28 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0268_auto_20201019_1913'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalmode', + name='certificate_type', + field=models.CharField(blank=True, choices=[('honor', 'Honor'), ('credit', 'Credit'), ('verified', 'Verified'), ('professional', 'Professional'), ('executive-education', 'Executive Education')], help_text='Certificate type granted if this mode is eligible for a certificate, or blank if not.', max_length=64), + ), + migrations.AlterField( + model_name='historicalprogram', + name='card_image_url', + field=models.URLField(blank=True, help_text='DEPRECATED: Use the card image field', null=True), + ), + migrations.AlterField( + model_name='mode', + name='certificate_type', + field=models.CharField(blank=True, choices=[('honor', 'Honor'), ('credit', 'Credit'), ('verified', 'Verified'), ('professional', 'Professional'), ('executive-education', 'Executive Education')], help_text='Certificate type granted if this mode is eligible for a certificate, or blank if not.', max_length=64), + ), + migrations.AlterField( + model_name='program', + name='card_image_url', + field=models.URLField(blank=True, help_text='DEPRECATED: Use the card image field', null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0270_auto_20231024_0956.py b/course_discovery/apps/course_metadata/migrations/0270_auto_20231024_0956.py new file mode 100644 index 0000000000..db3af0b5a7 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0270_auto_20231024_0956.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.24 on 2023-10-24 09:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_auto_20200414_0739'), + ('course_metadata', '0269_depr_program_card_image_url_and_add_exec_ed_cert_type'), + ] + + operations = [ + migrations.AlterModelOptions( + name='program', + options={}, + ), + migrations.AlterField( + model_name='historicalprogram', + name='title', + field=models.CharField(help_text='The user-facing display title for this Program.', max_length=255), + ), + migrations.AlterField( + model_name='program', + name='title', + field=models.CharField(help_text='The user-facing display title for this Program.', max_length=255), + ), + migrations.AlterUniqueTogether( + name='program', + unique_together={('partner', 'title')}, + ), + ] diff --git a/course_discovery/apps/course_metadata/migrations/0271_auto_20240507_0825.py b/course_discovery/apps/course_metadata/migrations/0271_auto_20240507_0825.py new file mode 100644 index 0000000000..e6064eaf50 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0271_auto_20240507_0825.py @@ -0,0 +1,73 @@ +# Generated by Django 2.2.16 on 2024-05-07 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0270_auto_20231024_0956'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='course_certifications', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Certifications'), + ), + migrations.AddField( + model_name='courserun', + name='course_department', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Department'), + ), + migrations.AddField( + model_name='courserun', + name='course_difficulty_level', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Difficulty Level'), + ), + migrations.AddField( + model_name='courserun', + name='course_format', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Format'), + ), + migrations.AddField( + model_name='courserun', + name='course_language', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Language'), + ), + migrations.AddField( + model_name='courserun', + name='course_training_packages', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Training Packages'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_certifications', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Certifications'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_department', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Department'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_difficulty_level', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Difficulty Level'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_format', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Format'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_language', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Language'), + ), + migrations.AddField( + model_name='historicalcourserun', + name='course_training_packages', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Course Training Packages'), + ), + ] diff --git a/course_discovery/apps/course_metadata/models.py b/course_discovery/apps/course_metadata/models.py index 8aae184a2b..3eebd20e52 100644 --- a/course_discovery/apps/course_metadata/models.py +++ b/course_discovery/apps/course_metadata/models.py @@ -2,37 +2,47 @@ import itertools import logging from collections import Counter, defaultdict +from operator import attrgetter from urllib.parse import urljoin from uuid import uuid4 import pytz +import requests import waffle -from django.core.exceptions import ValidationError +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import FileExtensionValidator, RegexValidator from django.db import models, transaction -from django.db.models.functions import Lower -from django.db.models.query_utils import Q +from django.db.models import F, Q from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django_extensions.db.fields import AutoSlugField from django_extensions.db.models import TimeStampedModel from haystack.query import SearchQuerySet from parler.models import TranslatableModel, TranslatedFieldsModel +from simple_history.models import HistoricalRecords from solo.models import SingletonModel from sortedm2m.fields import SortedManyToManyField from stdimage.models import StdImageField -from stdimage.utils import UploadToAutoSlug from taggit_autosuggest.managers import TaggableManager from course_discovery.apps.core.models import Currency, Partner -from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus, ProgramStatus, ReportingType +from course_discovery.apps.course_metadata import emails +from course_discovery.apps.course_metadata.choices import ( + CertificateType, CourseRunPacing, CourseRunStatus, PayeeType, ProgramStatus, ReportingType +) from course_discovery.apps.course_metadata.constants import PathwayType +from course_discovery.apps.course_metadata.fields import HtmlField, NullHtmlField +from course_discovery.apps.course_metadata.managers import DraftManager from course_discovery.apps.course_metadata.people import MarketingSitePeople from course_discovery.apps.course_metadata.publishers import ( CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher ) from course_discovery.apps.course_metadata.query import CourseQuerySet, CourseRunQuerySet, ProgramQuerySet from course_discovery.apps.course_metadata.utils import ( - UploadToFieldNamePath, clean_query, custom_render_variations, uslugify + UploadToFieldNamePath, clean_query, custom_render_variations, push_to_ecommerce_for_course_run, + push_tracks_to_lms_for_course_run, set_official_state, subtract_deadline_delta, uslugify ) from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.publisher.utils import VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY @@ -40,6 +50,62 @@ logger = logging.getLogger(__name__) +class DraftModelMixin(models.Model): + """ + Defines a draft boolean field and an object manager to make supporting drafts more transparent. + + This defines two managers. The 'everything' manager will return all rows. The 'objects' manager will exclude + draft versions by default unless you also define the 'objects' manager. + + Remember to add 'draft' to your unique_together clauses. + + Django doesn't allow real model mixins, but since everything has to inherit from models.Model, we shouldn't be + stepping on anyone's toes. This is the best advice I could find (at time of writing for Django 1.11). + + .. no_pii: + """ + draft = models.BooleanField(default=False, help_text='Is this a draft version?') + draft_version = models.OneToOneField('self', models.SET_NULL, null=True, blank=True, + related_name='_official_version', limit_choices_to={'draft': True}) + + everything = models.Manager() + objects = DraftManager() + + @property + def official_version(self): + """ + Related name fields will return an exception when there is no connection. In that case we want to return None + Returns: + None: if there is no Official Version + """ + try: + return self._official_version + except ObjectDoesNotExist: + return None + + class Meta: + abstract = True + + +class CachedMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._cache = dict(self.__dict__) + + def refresh_from_db(self, using=None, fields=None, **kwargs): + super().refresh_from_db(using, fields, **kwargs) + self.__dict__.pop('_cache', None) + self._cache = dict(self.__dict__) + + def save(self, **kwargs): + super().save(**kwargs) + self.__dict__.pop('_cache', None) + self._cache = dict(self.__dict__) + + def did_change(self, field): + return field in self.__dict__ and (field not in self._cache or getattr(self, field) != self._cache[field]) + + class AbstractNamedModel(TimeStampedModel): """ Abstract base class for models with only a name field. """ name = models.CharField(max_length=255, unique=True) @@ -47,7 +113,7 @@ class AbstractNamedModel(TimeStampedModel): def __str__(self): return self.name - class Meta(object): + class Meta: abstract = True @@ -58,7 +124,7 @@ class AbstractValueModel(TimeStampedModel): def __str__(self): return self.value - class Meta(object): + class Meta: abstract = True @@ -70,7 +136,7 @@ class AbstractMediaModel(TimeStampedModel): def __str__(self): return self.src - class Meta(object): + class Meta: abstract = True @@ -84,10 +150,117 @@ def __str__(self): return self.title return self.description - class Meta(object): + class Meta: abstract = True +class Organization(CachedMixin, TimeStampedModel): + """ Organization model. """ + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) + uuid = models.UUIDField(blank=False, null=False, default=uuid4, editable=False, verbose_name=_('UUID')) + key = models.CharField(max_length=255, help_text=_('Please do not use any spaces or special characters other ' + 'than period, underscore or hyphen. This key will be used ' + 'in the course\'s course key.')) + name = models.CharField(max_length=255) + certificate_name = models.CharField( + max_length=255, null=True, blank=True, help_text=_('If populated, this field will overwrite name in platform.') + ) + slug = AutoSlugField(populate_from='key', editable=False, slugify_function=uslugify) + description = models.TextField(null=True, blank=True) + homepage_url = models.URLField(max_length=255, null=True, blank=True) + logo_image = models.ImageField( + upload_to=UploadToFieldNamePath(populate_from='uuid', path='organization/logos'), + blank=True, + null=True, + validators=[FileExtensionValidator(['png'])] + ) + certificate_logo_image = models.ImageField( + upload_to=UploadToFieldNamePath(populate_from='uuid', path='organization/certificate_logos'), + blank=True, + null=True, + validators=[FileExtensionValidator(['png'])] + ) + banner_image = models.ImageField( + upload_to=UploadToFieldNamePath(populate_from='uuid', path='organization/banner_images'), + blank=True, + null=True, + ) + salesforce_id = models.CharField(max_length=255, null=True, blank=True) # Publisher_Organization__c in Salesforce + + tags = TaggableManager( + blank=True, + help_text=_('Pick a tag from the suggestions. To make a new tag, add a comma after the tag name.'), + ) + auto_generate_course_run_keys = models.BooleanField( + default=True, + verbose_name=_('Automatically generate course run keys'), + help_text=_( + "When this flag is enabled, the key of a new course run will be auto" + " generated. When this flag is disabled, the key can be manually set." + ) + ) + # Do not record the slug field in the history table because AutoSlugField is not compatible with + # django-simple-history. Background: https://github.com/edx/course-discovery/pull/332 + history = HistoricalRecords(excluded_fields=['slug']) + + def clean(self): + if not VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY.match(self.key): + raise ValidationError(_('Please do not use any spaces or special characters other than period, ' + 'underscore or hyphen in the key field.')) + + class Meta: + unique_together = ( + ('partner', 'key'), + ('partner', 'uuid'), + ) + ordering = ['created'] + + def __str__(self): + if self.name and self.name != self.key: + return '{key}: {name}'.format(key=self.key, name=self.name) + else: + return self.key + + @property + def marketing_url(self): + if self.slug and self.partner: + return urljoin(self.partner.marketing_site_url_root, 'school/' + self.slug) + + return None + + @classmethod + def user_organizations(cls, user): + return cls.objects.filter(organization_extension__group__in=user.groups.all()) + + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + """ + We cache the key here before saving the record so that we can hit the correct + endpoint in lms. + """ + key = self._cache['key'] + super(Organization, self).save(*args, **kwargs) + key = key or self.key + partner = self.partner + data = { + 'name': self.certificate_name or self.name, + 'short_name': self.key, + 'description': self.description, + } + logo = self.certificate_logo_image + if logo: + base_url = getattr(settings, 'ORG_BASE_LOGO_URL', None) + logo_url = '{}{}'.format(base_url, logo) if base_url else logo.url + data['logo_url'] = logo_url + organizations_url = '{}organizations/{}/'.format(partner.organizations_api_url, key) + if partner.lms_api_client: + try: + partner.lms_api_client.put(organizations_url, json=data) + except requests.exceptions.ConnectionError as e: + logger.error('[%s]: Unable to push organization [%s] to lms.', e, self.uuid) + except Exception as e: + raise e + + class Image(AbstractMediaModel): """ Image model. """ height = models.IntegerField(null=True, blank=True) @@ -96,15 +269,241 @@ class Image(AbstractMediaModel): class Video(AbstractMediaModel): """ Video model. """ - image = models.ForeignKey(Image, null=True, blank=True) + image = models.ForeignKey(Image, models.CASCADE, null=True, blank=True) def __str__(self): return '{src}: {description}'.format(src=self.src, description=self.description) -class LevelType(AbstractNamedModel): +class LevelType(TranslatableModel, TimeStampedModel): """ LevelType model. """ - pass + # This field determines ordering by which level types are presented in the + # Publisher tool, by virtue of the order in which the level types are + # returned by the serializer, and in turn the OPTIONS requests against the + # course and courserun view sets. + name = models.CharField(max_length=255) + sort_value = models.PositiveSmallIntegerField(default=0, db_index=True) + + def __str__(self): + return self.name_t + + class Meta: + ordering = ('sort_value',) + + +class LevelTypeTranslation(TranslatedFieldsModel): + master = models.ForeignKey(LevelType, models.CASCADE, related_name='translations', null=True) + name_t = models.CharField('name', max_length=255) + + class Meta: + unique_together = (('language_code', 'name_t'), ('language_code', 'master')) + verbose_name = _('LevelType model translations') + + +class SeatType(TimeStampedModel): + name = models.CharField(max_length=64) + slug = AutoSlugField(populate_from='name', slugify_function=uslugify, unique=True) + + def __str__(self): + return self.name + + +class ProgramType(TranslatableModel, TimeStampedModel): + XSERIES = 'xseries' + MICROMASTERS = 'micromasters' + PROFESSIONAL_CERTIFICATE = 'professional-certificate' + PROFESSIONAL_PROGRAM_WL = 'professional-program-wl' + MASTERS = 'masters' + MICROBACHELORS = 'microbachelors' + + name = models.CharField(max_length=32, blank=False) + applicable_seat_types = models.ManyToManyField( + SeatType, help_text=_('Seat types that qualify for completion of programs of this type. Learners completing ' + 'associated courses, but enrolled in other seat types, will NOT have their completion ' + 'of the course counted toward the completion of the program.'), + ) + logo_image = StdImageField( + upload_to=UploadToFieldNamePath(populate_from='name', path='media/program_types/logo_images/'), + blank=True, + null=True, + variations={ + 'large': (256, 256), + 'medium': (128, 128), + 'small': (64, 64), + 'x-small': (32, 32), + }, + help_text=_('Please provide an image file with transparent background'), + ) + slug = AutoSlugField(populate_from='name_t', editable=True, unique=True, slugify_function=uslugify, + help_text=_('Leave this field blank to have the value generated automatically.')) + uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID'), unique=True) + coaching_supported = models.BooleanField(default=False) + + # Do not record the slug field in the history table because AutoSlugField is not compatible with + # django-simple-history. Background: https://github.com/edx/course-discovery/pull/332 + history = HistoricalRecords(excluded_fields=['slug']) + + def __str__(self): + return self.name_t + + @staticmethod + def get_program_type_data(pub_course_run, program_model): + slug = None + name = None + program_type = None + + if pub_course_run.is_micromasters: + slug = ProgramType.MICROMASTERS + name = pub_course_run.micromasters_name + elif pub_course_run.is_professional_certificate: + slug = ProgramType.PROFESSIONAL_CERTIFICATE + name = pub_course_run.professional_certificate_name + elif pub_course_run.is_xseries: + slug = ProgramType.XSERIES + name = pub_course_run.xseries_name + if slug: + program_type = program_model.objects.get(slug=slug) + return program_type, name + + +class ProgramTypeTranslation(TranslatedFieldsModel): + master = models.ForeignKey(ProgramType, models.CASCADE, related_name='translations', null=True) + + name_t = models.CharField("name", max_length=32, blank=False, null=False) + + class Meta: + unique_together = (('language_code', 'master'), ('name_t', 'language_code')) + verbose_name = _('ProgramType model translations') + + +class Mode(TimeStampedModel): + """ + This model is similar to the LMS CourseMode model. + + It holds several fields that (one day will) control logic for handling enrollments in this mode. + Examples of names would be "Verified", "Credit", or "Masters" + + See docs/decisions/0009-LMS-types-in-course-metadata.rst for more information. + """ + name = models.CharField(max_length=64) + slug = models.CharField(max_length=64, unique=True) + is_id_verified = models.BooleanField(default=False, help_text=_('This mode requires ID verification.')) + is_credit_eligible = models.BooleanField( + default=False, + help_text=_('Completion can grant credit toward an organization’s degree.'), + ) + certificate_type = models.CharField( + max_length=64, choices=CertificateType, blank=True, + help_text=_('Certificate type granted if this mode is eligible for a certificate, or blank if not.'), + ) + payee = models.CharField( + max_length=64, choices=PayeeType, default='', blank=True, + help_text=_('Who gets paid for the course? Platform is the site owner, Organization is the school.'), + ) + + history = HistoricalRecords() + + def __str__(self): + return self.name + + @property + def is_certificate_eligible(self): + """ + Returns True if completion can impart any kind of certificate to the learner. + """ + return bool(self.certificate_type) + + +class Track(TimeStampedModel): + """ + This model ties a Mode (an LMS concept) with a SeatType (an E-Commerce concept) + + Basically, a track is all the metadata for a single enrollment type, with both the course logic and product sides. + + See docs/decisions/0009-LMS-types-in-course-metadata.rst for more information. + """ + seat_type = models.ForeignKey(SeatType, models.CASCADE, null=True, blank=True) + mode = models.ForeignKey(Mode, models.CASCADE) + + history = HistoricalRecords() + + def __str__(self): + return self.mode.name + + +class CourseRunType(TimeStampedModel): + """ + This model defines the enrollment options (Tracks) for a given course run. + + A single course might have runs with different enrollment options. Like a course that has a + "Masters, Verified, and Audit" CourseType might contain CourseRunTypes named + - "Masters, Verified, and Audit" (pointing to three different tracks) + - "Verified and Audit" + - "Audit only" + - "Masters only" + + See docs/decisions/0009-LMS-types-in-course-metadata.rst for more information. + """ + AUDIT = 'audit' + VERIFIED_AUDIT = 'verified-audit' + PROFESSIONAL = 'professional' + CREDIT_VERIFIED_AUDIT = 'credit-verified-audit' + HONOR = 'honor' + VERIFIED_HONOR = 'verified-honor' + VERIFIED_AUDIT_HONOR = 'verified-audit-honor' + EMPTY = 'empty' + + uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID'), unique=True) + name = models.CharField(max_length=64) + slug = models.CharField(max_length=64, unique=True) + tracks = models.ManyToManyField(Track) + is_marketable = models.BooleanField(default=True) + + history = HistoricalRecords() + + def __str__(self): + return self.name + + @property + def empty(self): + """ Empty types are special - they are the default type used when we don't know a real type """ + return self.slug == self.EMPTY + + +class CourseType(TimeStampedModel): + """ + This model defines the permissible types of enrollments provided by a whole course. + + It holds a list of permissible entitlement options and a list of permissible CourseRunTypes. + + Examples of names would be "Masters, Verified, and Audit" or "Verified and Audit" + """ + AUDIT = 'audit' + VERIFIED_AUDIT = 'verified-audit' + PROFESSIONAL = 'professional' + CREDIT_VERIFIED_AUDIT = 'credit-verified-audit' + EMPTY = 'empty' + + uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID'), unique=True) + name = models.CharField(max_length=64) + slug = models.CharField(max_length=64, unique=True) + entitlement_types = models.ManyToManyField(SeatType, blank=True) + course_run_types = SortedManyToManyField( + CourseRunType, help_text=_('Sets the order for displaying Course Run Types.') + ) + white_listed_orgs = models.ManyToManyField(Organization, blank=True, help_text=_( + 'Leave this blank to allow all orgs. Otherwise, specifies which orgs can see this course type in Publisher.' + )) + + history = HistoricalRecords() + + def __str__(self): + return self.name + + @property + def empty(self): + """ Empty types are special - they are the default type used when we don't know a real type """ + return self.slug == self.EMPTY class Subject(TranslatableModel, TimeStampedModel): @@ -115,7 +514,11 @@ class Subject(TranslatableModel, TimeStampedModel): slug = AutoSlugField(populate_from='name', editable=True, blank=True, slugify_function=uslugify, help_text=_('Leave this field blank to have the value generated automatically.')) - partner = models.ForeignKey(Partner) + partner = models.ForeignKey(Partner, models.CASCADE) + marketing_id = models.PositiveIntegerField( + null=True, blank=True, help_text=_('This field contains subject post ID from marketing site.') + ) + marketing_url = models.URLField(null=True, blank=True) def __str__(self): return self.name @@ -135,7 +538,7 @@ def validate_unique(self, *args, **kwargs): # pylint: disable=arguments-differ class SubjectTranslation(TranslatedFieldsModel): - master = models.ForeignKey(Subject, related_name='translations', null=True) + master = models.ForeignKey(Subject, models.CASCADE, related_name='translations', null=True) name = models.CharField(max_length=255, blank=False, null=False) subtitle = models.CharField(max_length=255, blank=True, null=True) @@ -153,7 +556,7 @@ class Topic(TranslatableModel, TimeStampedModel): slug = AutoSlugField(populate_from='name', editable=True, blank=True, slugify_function=uslugify, help_text=_('Leave this field blank to have the value generated automatically.')) - partner = models.ForeignKey(Partner) + partner = models.ForeignKey(Partner, models.CASCADE) def __str__(self): return self.name @@ -173,7 +576,7 @@ def validate_unique(self, *args, **kwargs): # pylint: disable=arguments-differ class TopicTranslation(TranslatedFieldsModel): - master = models.ForeignKey(Topic, related_name='translations', null=True) + master = models.ForeignKey(Topic, models.CASCADE, related_name='translations', null=True) name = models.CharField(max_length=255, blank=False, null=False) subtitle = models.CharField(max_length=255, blank=True, null=True) @@ -187,84 +590,34 @@ class Meta: class Prerequisite(AbstractNamedModel): """ Prerequisite model. """ - pass class ExpectedLearningItem(AbstractValueModel): """ ExpectedLearningItem model. """ - pass class JobOutlookItem(AbstractValueModel): """ JobOutlookItem model. """ - pass class SyllabusItem(AbstractValueModel): """ SyllabusItem model. """ - parent = models.ForeignKey('self', blank=True, null=True, related_name='children') + parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children') class AdditionalPromoArea(AbstractTitleDescriptionModel): """ Additional Promo Area Model """ - pass - - -class Organization(TimeStampedModel): - """ Organization model. """ - partner = models.ForeignKey(Partner, null=True, blank=False) - uuid = models.UUIDField(blank=False, null=False, default=uuid4, editable=False, verbose_name=_('UUID')) - key = models.CharField(max_length=255, help_text=_('Please do not use any spaces or special characters other ' - 'than period, underscore or hyphen. This key will be used ' - 'in the course\'s course key.')) - name = models.CharField(max_length=255) - marketing_url_path = models.CharField(max_length=255, null=True, blank=True) - description = models.TextField(null=True, blank=True) - homepage_url = models.URLField(max_length=255, null=True, blank=True) - logo_image_url = models.URLField(null=True, blank=True) - banner_image_url = models.URLField(null=True, blank=True) - certificate_logo_image_url = models.URLField( - null=True, blank=True, help_text=_('Logo to be displayed on certificates. If this logo is the same as ' - 'logo_image_url, copy and paste the same value to both fields.') - ) - - tags = TaggableManager( - blank=True, - help_text=_('Pick a tag from the suggestions. To make a new tag, add a comma after the tag name.'), - ) - - def clean(self): - if not VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY.match(self.key): - raise ValidationError(_('Please do not use any spaces or special characters other than period, ' - 'underscore or hyphen in the key field.')) - - class Meta: - unique_together = ( - ('partner', 'key'), - ('partner', 'uuid'), - ) - ordering = ['created'] - - def __str__(self): - return '{key}: {name}'.format(key=self.key, name=self.name) - - @property - def marketing_url(self): - if self.marketing_url_path: - return urljoin(self.partner.marketing_site_url_root, self.marketing_url_path) - - return None class Person(TimeStampedModel): """ Person model. """ uuid = models.UUIDField(blank=False, null=False, default=uuid4, editable=False, verbose_name=_('UUID')) - partner = models.ForeignKey(Partner, null=True, blank=False) + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) salutation = models.CharField(max_length=10, null=True, blank=True) given_name = models.CharField(max_length=255) family_name = models.CharField(max_length=255, null=True, blank=True) - bio = models.TextField(null=True, blank=True) - bio_language = models.ForeignKey(LanguageTag, null=True, blank=True) + bio = NullHtmlField() + bio_language = models.ForeignKey(LanguageTag, models.CASCADE, null=True, blank=True) profile_image = StdImageField( upload_to=UploadToFieldNamePath(populate_from='uuid', path='media/people/profile_images'), blank=True, @@ -275,18 +628,25 @@ class Person(TimeStampedModel): ) slug = AutoSlugField(populate_from=('given_name', 'family_name'), editable=True, slugify_function=uslugify) email = models.EmailField(null=True, blank=True, max_length=255) - major_works = models.TextField( + major_works = HtmlField( blank=True, help_text=_('A list of major works by this person. Must be valid HTML.'), ) published = models.BooleanField(default=False) + designation = models.TextField(null=True, blank=True) + profile_image_url = models.URLField(null=True, blank=True) + marketing_id = models.PositiveIntegerField(null=True, blank=True, help_text=_('This field contains instructor post ID from wordpress.')) + marketing_url = models.URLField(null=True, blank=True) + phone_regex = RegexValidator(regex=r'^\+?1?\d*$', message="Phone number can only contain numbers.") + phone_number = models.CharField(validators=[phone_regex], null=True, blank=True, max_length=50) + website = models.URLField(null=True, blank=True) class Meta: unique_together = ( ('partner', 'uuid'), ) verbose_name_plural = _('People') - ordering = ['created'] + ordering = ['id'] def __str__(self): return self.full_name @@ -318,7 +678,8 @@ def profile_url(self): def get_profile_image_url(self): if self.profile_image and hasattr(self.profile_image, 'url'): return self.profile_image.url - return None + else: + return self.profile_image_url class Position(TimeStampedModel): @@ -326,9 +687,9 @@ class Position(TimeStampedModel): This model represent's a `Person`'s role at an organization. """ - person = models.OneToOneField(Person) + person = models.OneToOneField(Person, models.CASCADE) title = models.CharField(max_length=255) - organization = models.ForeignKey(Organization, null=True, blank=True) + organization = models.ForeignKey(Organization, models.CASCADE, null=True, blank=True) organization_override = models.CharField(max_length=255, null=True, blank=True) def __str__(self): @@ -350,51 +711,89 @@ class PkSearchableMixin: """ @classmethod - def search(cls, query): + def search(cls, query, queryset=None): """ Queries the search index. Args: query (str) -- Elasticsearch querystring (e.g. `title:intro*`) + queryset (models.QuerySet) -- base queryset to search, defaults to objects.all() Returns: QuerySet """ query = clean_query(query) + if queryset is None: + queryset = cls.objects.all() + if query == '(*)': # Early-exit optimization. Wildcard searching is very expensive in elasticsearch. And since we just # want everything, we don't need to actually query elasticsearch at all. - return cls.objects.all() + return queryset results = SearchQuerySet().models(cls).raw_search(query) ids = {result.pk for result in results} - return cls.objects.filter(pk__in=ids) + return queryset.filter(pk__in=ids) + + +class Collaborator(TimeStampedModel): + """ + Collaborator model, defining any collaborators who helped write course content. + """ + image = StdImageField( + upload_to=UploadToFieldNamePath(populate_from='uuid', path='media/course/collaborator/image'), + blank=True, + null=True, + variations={ + 'original': (200, 100), + }, + help_text=_('Add the collaborator image, please make sure its dimensions are 200x100px') + ) + name = models.CharField(max_length=255, default='') + uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID')) + + @property + def image_url(self): + if self.image and hasattr(self.image, 'url'): + return self.image.url + return None + + def __str__(self): + return '{name}'.format(name=self.name) -class Course(PkSearchableMixin, TimeStampedModel): +class Course(DraftModelMixin, PkSearchableMixin, CachedMixin, TimeStampedModel): """ Course model. """ - partner = models.ForeignKey(Partner) + partner = models.ForeignKey(Partner, models.CASCADE) uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID')) canonical_course_run = models.OneToOneField( - 'course_metadata.CourseRun', related_name='canonical_for_course', default=None, null=True, blank=True + 'course_metadata.CourseRun', models.CASCADE, related_name='canonical_for_course', + default=None, null=True, blank=True, ) key = models.CharField(max_length=255, db_index=True) + key_for_reruns = models.CharField( + max_length=255, blank=True, + help_text=_('When making reruns for this course, they will use this key instead of the course key.'), + ) title = models.CharField(max_length=255, default=None, null=True, blank=True) - short_description = models.TextField(default=None, null=True, blank=True) - full_description = models.TextField(default=None, null=True, blank=True) + url_slug = AutoSlugField(populate_from='title', editable=True, slugify_function=uslugify, overwrite_on_add=False, + help_text=_('Leave this field blank to have the value generated automatically.')) + short_description = NullHtmlField() + full_description = NullHtmlField() extra_description = models.ForeignKey( - AdditionalPromoArea, default=None, null=True, blank=True, related_name='extra_description' + AdditionalPromoArea, models.CASCADE, default=None, null=True, blank=True, related_name='extra_description', ) authoring_organizations = SortedManyToManyField(Organization, blank=True, related_name='authored_courses') sponsoring_organizations = SortedManyToManyField(Organization, blank=True, related_name='sponsored_courses') + collaborators = SortedManyToManyField(Collaborator, blank=True, related_name='courses_collaborated') subjects = SortedManyToManyField(Subject, blank=True) prerequisites = models.ManyToManyField(Prerequisite, blank=True) - level_type = models.ForeignKey(LevelType, default=None, null=True, blank=True) + level_type = models.ForeignKey(LevelType, models.CASCADE, default=None, null=True, blank=True) expected_learning_items = SortedManyToManyField(ExpectedLearningItem, blank=True) - outcome = models.TextField(blank=True, null=True) - prerequisites_raw = models.TextField(blank=True, null=True) - syllabus_raw = models.TextField(blank=True, null=True) + outcome = NullHtmlField() + prerequisites_raw = NullHtmlField() + syllabus_raw = NullHtmlField() card_image_url = models.URLField(null=True, blank=True) image = StdImageField( upload_to=UploadToFieldNamePath(populate_from='uuid', path='media/course/image'), @@ -407,10 +806,9 @@ class Course(PkSearchableMixin, TimeStampedModel): help_text=_('Add the course image') ) slug = AutoSlugField(populate_from='key', editable=True, slugify_function=uslugify) - video = models.ForeignKey(Video, default=None, null=True, blank=True) - faq = models.TextField(default=None, null=True, blank=True, verbose_name=_('FAQ')) - learner_testimonials = models.TextField(default=None, null=True, blank=True) - has_ofac_restrictions = models.BooleanField(default=False, verbose_name=_('Course Has OFAC Restrictions')) + video = models.ForeignKey(Video, models.CASCADE, default=None, null=True, blank=True) + faq = NullHtmlField(verbose_name=_('FAQ')) + learner_testimonials = NullHtmlField() enrollment_count = models.IntegerField( null=True, blank=True, default=0, help_text=_('Total number of learners who have enrolled in this course') ) @@ -419,6 +817,13 @@ class Course(PkSearchableMixin, TimeStampedModel): 'Total number of learners who have enrolled in this course in the last 6 months' ) ) + salesforce_id = models.CharField(max_length=255, null=True, blank=True) # Course__c in Salesforce + salesforce_case_id = models.CharField(max_length=255, null=True, blank=True) # Case in Salesforce + type = models.ForeignKey(CourseType, models.CASCADE, null=True) # while null IS True, it should always be set + + # Do not record the slug field in the history table because AutoSlugField is not compatible with + # django-simple-history. Background: https://github.com/edx/course-discovery/pull/332 + history = HistoricalRecords(excluded_fields=['slug', 'url_slug']) # TODO Remove this field. number = models.CharField( @@ -433,16 +838,19 @@ class Course(PkSearchableMixin, TimeStampedModel): related_name='course_topics', ) - additional_information = models.TextField( - default=None, null=True, blank=True, verbose_name=_('Additional Information') - ) + # The 'additional_information' field holds HTML content, but we don't use a NullHtmlField for it, because we don't + # want to validate its content at all. This is filled in by administrators, not course teams, and may hold special + # HTML that isn't normally allowed. + additional_information = models.TextField(blank=True, null=True, default=None, + verbose_name=_('Additional Information')) - objects = CourseQuerySet.as_manager() + everything = CourseQuerySet.as_manager() + objects = DraftManager.from_queryset(CourseQuerySet)() class Meta: unique_together = ( - ('partner', 'uuid'), - ('partner', 'key'), + ('partner', 'uuid', 'draft'), + ('partner', 'key', 'draft'), ) ordering = ['id'] @@ -473,11 +881,33 @@ def original_image_url(self): def marketing_url(self): url = None if self.partner.marketing_site_url_root: - path = 'course/{slug}'.format(slug=self.slug) + path = 'course/{slug}'.format(slug=self.active_url_slug) url = urljoin(self.partner.marketing_site_url_root, path) return url + @property + def active_url_slug(self): + """ Official rows just return whatever slug is active, draft rows will first look for an associated active + slug and, if they fail to find one, take the slug associated with the official course that has + is_active_on_draft: True.""" + active_url = self.url_slug_history.filter(is_active=True).first() + if not active_url and self.draft and self.official_version: + # current draft url slug has already been published at least once, so get it from the official course + active_url = self.official_version.url_slug_history.filter(is_active_on_draft=True).first() + return getattr(active_url, 'url_slug', None) + + def course_run_sort(self, course_run): + """ + Sort course runs by enrollment_start or start, preferring the former + + A missing date is stubbed to max datetime to be sorted last + """ + date = course_run.enrollment_start or course_run.start + if date: + return date + return datetime.datetime.max.replace(tzinfo=pytz.UTC) + @property def active_course_runs(self): """ Returns course runs that have not yet ended and meet the following enrollment criteria: @@ -485,6 +915,8 @@ def active_course_runs(self): - OR will be open for enrollment in the future - OR have no specified enrollment close date (e.g. self-paced courses) + This is basically a QuerySet version of "all runs where has_enrollment_ended is False" + Returns: QuerySet """ @@ -499,18 +931,289 @@ def active_course_runs(self): @property def first_enrollable_paid_seat_price(self): - for course_run in self.active_course_runs.order_by(Lower('key')): + """ + Sort the course runs with sorted rather than order_by to avoid + additional calls to the database + """ + for course_run in sorted( + self.active_course_runs, + key=lambda active_course_run: active_course_run.key.lower(), + ): if course_run.has_enrollable_paid_seats(): return course_run.first_enrollable_paid_seat_price return None + @property + def course_run_statuses(self): + """ + Returns all unique course run status values inside this course. -class CourseRun(TimeStampedModel): + Note that it skips hidden and archived courses - this list is typically used for presentational purposes. + """ + now = datetime.datetime.now(pytz.UTC) + runs = self.course_runs.exclude(hidden=True).exclude(status=CourseRunStatus.Unpublished, end__lt=now) + statuses = runs.values_list('status', flat=True).distinct().order_by('status') + return list(statuses) + + def unpublish_inactive_runs(self, published_runs=None): + """ + Find old course runs that are no longer active but still published, these will be unpublished. + + Designed to work on official runs. + + Arguments: + published_runs (iterable): optional optimization; pass published CourseRuns to avoid a lookup + + Returns: + True if any runs were unpublished + """ + if not self.partner.has_marketing_site: + return False + + if published_runs is None: + published_runs = self.course_runs.filter(status=CourseRunStatus.Published).iterator() + published_runs = frozenset(published_runs) + + # Now separate out the active ones from the inactive + # (done in Python rather than hitting self.active_course_runs to avoid a second db query) + now = datetime.datetime.now(pytz.UTC) + inactive_runs = {run for run in published_runs if run.has_enrollment_ended(now)} + marketable_runs = {run for run in published_runs - inactive_runs if run.could_be_marketable} + if not marketable_runs or not inactive_runs: + # if there are no inactive runs, there's no point in continuing - and ensure that we always have at least + # one marketable run around by not unpublishing if we would get rid of all of them + return False + + for run in inactive_runs: + run.status = CourseRunStatus.Unpublished + run.save() + if run.draft_version: + run.draft_version.status = CourseRunStatus.Unpublished + run.draft_version.save() + + return True + + def _update_or_create_official_version(self, course_run): + """ + Should only be called from CourseRun.update_or_create_official_version. Because we only need to make draft + changes official in the context of a course run and because certain actions (like publishing data to ecommerce) + happen when we make official versions and we want to do those as a bundle with the course run. + """ + draft_version = Course.everything.get(pk=self.pk) + # If there isn't an official_version set yet, then this is a create instead of update + # and we will want to set additional attributes. + creating = not self.official_version + + official_version = set_official_state(draft_version, Course) + + for entitlement in self.entitlements.all(): + # The draft version could have audit entitlements, but we only + # want to create official entitlements for the valid entitlement modes. + if entitlement.mode.slug in Seat.ENTITLEMENT_MODES: + set_official_state(entitlement, CourseEntitlement, {'course': official_version}) + + official_version.set_active_url_slug(self.active_url_slug) + + if creating: + official_version.canonical_course_run = course_run + official_version.slug = self.slug + official_version.save() + self.canonical_course_run = course_run.draft_version + self.save() + + return official_version + + @transaction.atomic + def set_active_url_slug(self, slug): + # logging to help debug error around course url slugs incrementing + logger.info('The current slug is {}; The slug to be set is {}; Current course is a draft: {}' + .format(self.url_slug, slug, self.draft)) + + if self.draft: + active_draft_url_slug_object = self.url_slug_history.filter(is_active=True).first() + + # case 1: new slug matches an entry in the course's slug history + if self.official_version: + found = False + for url_entry in self.official_version.url_slug_history.filter(Q(is_active_on_draft=True) | + Q(url_slug=slug)): + match = url_entry.url_slug == slug + url_entry.is_active_on_draft = match + found = found or match + url_entry.save() + if found: + # we will get the active slug via the official object, so delete the draft one + if active_draft_url_slug_object: + active_draft_url_slug_object.delete() + return + # case 2: slug has not been used for this course before + obj = self.url_slug_history.update_or_create(is_active=True, defaults={ # pylint: disable=no-member + 'course': self, + 'partner': self.partner, + 'is_active': True, + 'url_slug': slug, + })[0] # update_or_create returns an (obj, created?) tuple, so just get the object + # this line necessary to clear the prefetch cache + self.url_slug_history.add(obj) # pylint: disable=no-member + else: + if self.draft_version: + self.draft_version.url_slug_history.filter(is_active=True).delete() + obj = self.url_slug_history.update_or_create(url_slug=slug, defaults={ # pylint: disable=no-member + 'url_slug': slug, + 'is_active': True, + 'is_active_on_draft': True, + 'partner': self.partner, + })[0] + for other_slug in self.url_slug_history.filter(Q(is_active=True) | + Q(is_active_on_draft=True)).exclude(url_slug=obj.url_slug): + other_slug.is_active = False + other_slug.is_active_on_draft = False + other_slug.save() + + @cached_property + def advertised_course_run(self): + now = datetime.datetime.now(pytz.UTC) + min_date = datetime.datetime.min.replace(tzinfo=pytz.UTC) + max_date = datetime.datetime.max.replace(tzinfo=pytz.UTC) + + tier_one = [] + tier_two = [] + tier_three = [] + + marketable_course_runs = [course_run for course_run in self.course_runs.all() if course_run.is_marketable] + + for course_run in marketable_course_runs: + course_run_started = (not course_run.start) or (course_run.start and course_run.start < now) + if course_run.is_current_and_still_upgradeable(): + tier_one.append(course_run) + elif not course_run_started and course_run.is_upgradeable(): + tier_two.append(course_run) + else: + tier_three.append(course_run) + + advertised_course_run = None + + # start should almost never be null, default added to take care of older incomplete data + if tier_one: + advertised_course_run = sorted(tier_one, key=lambda run: run.start or min_date, reverse=True)[0] + elif tier_two: + advertised_course_run = sorted(tier_two, key=lambda run: run.start or max_date)[0] + elif tier_three: + advertised_course_run = sorted(tier_three, key=lambda run: run.start or min_date, reverse=True)[0] + + return advertised_course_run + + +class CourseEditor(TimeStampedModel): + """ + CourseEditor model, defining who can edit a course and its course runs. + + .. no_pii: + """ + user = models.ForeignKey(get_user_model(), models.CASCADE, related_name='courses_edited') + course = models.ForeignKey(Course, models.CASCADE, related_name='editors') + + class Meta: + unique_together = ('user', 'course',) + + # The logic for whether a user can edit a course gets a little complicated, so try to use the following class + # utility methods when possible. Read 0003-publisher-permission.rst for more context on the why. + + @classmethod + def can_create_course(cls, user, organization_key): + if user.is_staff: + return True + + # You must be a member of the organization within which you are creating a course + return user.groups.filter(organization_extension__organization__key=organization_key).exists() + + @classmethod + def course_editors(cls, course): + """ + Returns an iterable of User objects. + """ + authoring_orgs = course.authoring_organizations.all() + + # No matter what, if an editor or their organization has been removed from the course, they can't be an editor + # for it. This handles cases of being dropped from an org... But might be too restrictive in case we want + # to allow outside guest editors on a course? Let's try this for now and see how it goes. + valid_editors = course.editors.filter(user__groups__organization_extension__organization__in=authoring_orgs) + valid_editors = valid_editors.select_related('user') + + if valid_editors: + return {editor.user for editor in valid_editors} + + # No valid editors - this is an edge case where we just grant anyone in an authoring org access + user_model = get_user_model() + return user_model.objects.filter(groups__organization_extension__organization__in=authoring_orgs).distinct() + + @classmethod + def editors_for_user(cls, user): + if user.is_staff: + return CourseEditor.objects.all() + + user_orgs = Organization.user_organizations(user) + return CourseEditor.objects.filter(user__groups__organization_extension__organization__in=user_orgs) + + @classmethod + def is_course_editable(cls, user, course): + if user.is_staff: + return True + + return user in cls.course_editors(course) + + @classmethod + def editable_courses(cls, user, queryset, check_editors=True): + if user.is_staff: + return queryset + + # We must be a valid editor for this course + if check_editors: + has_valid_editors = Q( + editors__user__groups__organization_extension__organization__in=F('authoring_organizations') + ) + has_user_editor = Q(editors__user=user) + queryset = queryset.filter(has_user_editor | ~has_valid_editors) + + # And the course has to be authored by an org we belong to + user_orgs = Organization.user_organizations(user) + queryset = queryset.filter(authoring_organizations__in=user_orgs) + + # We use distinct() here because the query is complicated enough, spanning tables and following lists of + # foreign keys, that django will return duplicate rows if we aren't careful to ask it not to. + return queryset.distinct() + + @classmethod + def editable_course_runs(cls, user, queryset): + if user.is_staff: + return queryset + + user_orgs = Organization.user_organizations(user) + has_valid_editors = Q( + course__editors__user__groups__organization_extension__organization__in=F('course__authoring_organizations') + ) + has_user_editor = Q(course__editors__user=user) + user_can_edit = has_user_editor | ~has_valid_editors + + # We use distinct() here because the query is complicated enough, spanning tables and following lists of + # foreign keys, that django will return duplicate rows if we aren't careful to ask it not to. + return queryset.filter(user_can_edit, course__authoring_organizations__in=user_orgs).distinct() + + +class CourseRun(DraftModelMixin, CachedMixin, TimeStampedModel): """ CourseRun model. """ - uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID')) - course = models.ForeignKey(Course, related_name='course_runs') - key = models.CharField(max_length=255, unique=True) + OFAC_RESTRICTION_CHOICES = ( + ('', '--'), + (True, _('Blocked')), + (False, _('Unrestricted')), + ) + + uuid = models.UUIDField(default=uuid4, verbose_name=_('UUID')) + course = models.ForeignKey(Course, models.CASCADE, related_name='course_runs') + key = models.CharField(max_length=255) + # There is a post save function in signals.py that verifies that this is unique within a program + external_key = models.CharField(max_length=225, blank=True, null=True) status = models.CharField(default=CourseRunStatus.Unpublished, max_length=255, null=False, blank=False, db_index=True, choices=CourseRunStatus.choices, validators=[CourseRunStatus.validator]) title_override = models.CharField( @@ -519,16 +1222,15 @@ class CourseRun(TimeStampedModel): "Title specific for this run of a course. Leave this value blank to default to the parent course's title.")) start = models.DateTimeField(null=True, blank=True, db_index=True) end = models.DateTimeField(null=True, blank=True, db_index=True) + go_live_date = models.DateTimeField(null=True, blank=True) enrollment_start = models.DateTimeField(null=True, blank=True) enrollment_end = models.DateTimeField(null=True, blank=True, db_index=True) announcement = models.DateTimeField(null=True, blank=True) - short_description_override = models.TextField( - default=None, null=True, blank=True, + short_description_override = NullHtmlField( help_text=_( "Short description specific for this run of a course. Leave this value blank to default to " "the parent course's short_description attribute.")) - full_description_override = models.TextField( - default=None, null=True, blank=True, + full_description_override = NullHtmlField( help_text=_( "Full description specific for this run of a course. Leave this value blank to default to " "the parent course's full_description attribute.")) @@ -542,11 +1244,11 @@ class CourseRun(TimeStampedModel): weeks_to_complete = models.PositiveSmallIntegerField( null=True, blank=True, help_text=_('Estimated number of weeks needed to complete this course run.')) - language = models.ForeignKey(LanguageTag, null=True, blank=True) + language = models.ForeignKey(LanguageTag, models.CASCADE, null=True, blank=True) transcript_languages = models.ManyToManyField(LanguageTag, blank=True, related_name='transcript_courses') pacing_type = models.CharField(max_length=255, db_index=True, null=True, blank=True, choices=CourseRunPacing.choices, validators=[CourseRunPacing.validator]) - syllabus = models.ForeignKey(SyllabusItem, default=None, null=True, blank=True) + syllabus = models.ForeignKey(SyllabusItem, models.CASCADE, default=None, null=True, blank=True) enrollment_count = models.IntegerField( null=True, blank=True, default=0, help_text=_('Total number of learners who have enrolled in this course run') ) @@ -558,12 +1260,16 @@ class CourseRun(TimeStampedModel): # TODO Ditch this, and fallback to the course card_image_url = models.URLField(null=True, blank=True) - video = models.ForeignKey(Video, default=None, null=True, blank=True) + video = models.ForeignKey(Video, models.CASCADE, default=None, null=True, blank=True) video_translation_languages = models.ManyToManyField( LanguageTag, blank=True, related_name='+') - slug = AutoSlugField(max_length=255, populate_from='title', slugify_function=uslugify, db_index=True, + slug = AutoSlugField(max_length=255, populate_from=['title', 'key'], slugify_function=uslugify, db_index=True, editable=True) - hidden = models.BooleanField(default=False) + hidden = models.BooleanField( + default=False, + help_text=_('Whether this run should be hidden from API responses. Do not edit here - this value will be ' + 'overwritten by the "Course Visibility In Catalog" field in Studio via Refresh Course Metadata.') + ) mobile_available = models.BooleanField(default=False) course_overridden = models.BooleanField( default=False, @@ -572,30 +1278,118 @@ class CourseRun(TimeStampedModel): reporting_type = models.CharField(max_length=255, choices=ReportingType.choices, default=ReportingType.mooc) eligible_for_financial_aid = models.BooleanField(default=True) license = models.CharField(max_length=255, blank=True, db_index=True) - outcome_override = models.TextField( - default=None, blank=True, null=True, + outcome_override = NullHtmlField( help_text=_( "'What You Will Learn' description for this particular course run. Leave this value blank to default " "to the parent course's Outcome attribute.")) + type = models.ForeignKey(CourseRunType, models.CASCADE, null=True) # while null IS True, it should always be set tags = TaggableManager( blank=True, help_text=_('Pick a tag from the suggestions. To make a new tag, add a comma after the tag name.'), ) - has_ofac_restrictions = models.BooleanField( + has_ofac_restrictions = models.NullBooleanField( + blank=True, + choices=OFAC_RESTRICTION_CHOICES, + default=None, + verbose_name=_('Add OFAC restriction text to the FAQ section of the Marketing site'), + ) + ofac_comment = models.TextField(blank=True, help_text='Comment related to OFAC restriction') + + # The expected_program_type and expected_program_name are here in support of Publisher and may not reflect the + # final program information. + expected_program_type = models.ForeignKey(ProgramType, models.CASCADE, default=None, null=True, blank=True) + expected_program_name = models.CharField(max_length=255, default='', blank=True) + + everything = CourseRunQuerySet.as_manager() + objects = DraftManager.from_queryset(CourseRunQuerySet)() + + # Do not record the slug field in the history table because AutoSlugField is not compatible with + # django-simple-history. Background: https://github.com/edx/course-discovery/pull/332 + history = HistoricalRecords(excluded_fields=['slug']) + + salesforce_id = models.CharField(max_length=255, null=True, blank=True) # Course_Run__c in Salesforce + + invite_only = models.BooleanField(default=False) + featured = models.BooleanField(default=False) + is_marketing_price_set = models.BooleanField( default=False, - verbose_name=_('Add OFAC restriction text to the FAQ section of the Marketing site') + verbose_name=_('Price'), + help_text=_( 'Indicates whether the course on marketing site is marked paid') + ) + marketing_price_value = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Price Value')) + is_marketing_price_hidden = models.BooleanField(default=False, verbose_name=_('Hide Price')) + average_rating = models.DecimalField(default=0.0, max_digits=30, decimal_places=2) + total_raters = models.IntegerField(default=0) + yt_video_url = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Youtube Video URL')) + course_duration_override = models.PositiveIntegerField( + null=True, blank=True, help_text=_('This field contains override course duration value.'), + verbose_name=_('Course Duration Override') + ) + course_training_packages = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_('Course Training Packages')) + course_department = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_('Course Department')) + course_certifications = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_('Course Certifications')) + course_format = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_('Course Format')) + course_difficulty_level = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_('Course Difficulty Level')) + course_language = models.CharField( + max_length=255, null=True, blank=True, verbose_name=_('Course Language')) + + STATUS_CHANGE_EXEMPT_FIELDS = [ + 'start', + 'end', + 'go_live_date', + 'staff', + 'min_effort', + 'max_effort', + 'weeks_to_complete', + 'language', + 'transcript_languages', + 'pacing_type', + ] + + INTERNAL_REVIEW_FIELDS = ( + 'status', + 'has_ofac_restrictions', + 'ofac_comment', ) - objects = CourseRunQuerySet.as_manager() + class Meta: + unique_together = ( + ('key', 'draft'), + ('uuid', 'draft'), + ) + + def __init__(self, *args, **kwargs): + super(CourseRun, self).__init__(*args, **kwargs) + self._old_status = self.status + + def _upgrade_deadline_sort(self, seat): + """ + Stub missing upgrade_deadlines to max datetime so they are ordered last + """ + if seat and seat.upgrade_deadline: + return seat.upgrade_deadline + return datetime.datetime.max.replace(tzinfo=pytz.UTC) def _enrollable_paid_seats(self): """ - Return a QuerySet that may be used to fetch the enrollable paid Seats (Seats with price > 0 and no + Return a list that may be used to fetch the enrollable paid Seats (Seats with price > 0 and no prerequisites) associated with this CourseRun. + + We don't use django's built in filter() here since the API should have prefetched seats and + filter() would hit the database again """ - return self.seats.exclude(type__in=Seat.SEATS_WITH_PREREQUISITES).filter(price__gt=0.0) + seats = [] + for seat in self.seats.all(): + if seat.type.slug not in Seat.SEATS_WITH_PREREQUISITES and seat.price > 0.0: + seats.append(seat) + return seats def clean(self): # See https://stackoverflow.com/questions/47819247 @@ -606,7 +1400,8 @@ def clean(self): @property def first_enrollable_paid_seat_price(self): - seats = list(self._enrollable_paid_seats().order_by('upgrade_deadline')) + # Sort in python to avoid an additional request to the database for order_by + seats = sorted(self._enrollable_paid_seats(), key=self._upgrade_deadline_sort) if not seats: # Enrollable paid seats are not available for this CourseRun. return None @@ -615,7 +1410,8 @@ def first_enrollable_paid_seat_price(self): return price def first_enrollable_paid_seat_sku(self): - seats = list(self._enrollable_paid_seats().order_by('upgrade_deadline')) + # Sort in python to avoid an additional request to the database for order_by + seats = sorted(self._enrollable_paid_seats(), key=self._upgrade_deadline_sort) if not seats: # Enrollable paid seats are not available for this CourseRun. return None @@ -629,6 +1425,14 @@ def has_enrollable_paid_seats(self): """ return len(self._enrollable_paid_seats()[:1]) > 0 + def is_current(self): + # Return true if today is after the run start (or start is none) and two weeks from the run end (or end is none) + now = datetime.datetime.now(pytz.UTC) + two_weeks = datetime.timedelta(days=14) + after_start = (not self.start) or self.start < now + ends_in_more_than_two_weeks = (not self.end) or (now.date() <= self.end.date() - two_weeks) + return after_start and ends_in_more_than_two_weeks + def is_current_and_still_upgradeable(self): """ Return true if @@ -636,66 +1440,61 @@ def is_current_and_still_upgradeable(self): 2. The run has a seat that is still enrollable and upgradeable and false otherwise """ + return self.is_current() and self.is_upgradeable() + + def is_upcoming(self): + # Return true if course has start date and start date is in the future + now = datetime.datetime.now(pytz.UTC) - two_weeks = datetime.timedelta(days=14) - after_start = (not self.start) or (self.start and self.start < now) - ends_in_more_than_two_weeks = (not self.end) or (self.end.date() and now.date() <= self.end.date() - two_weeks) - if after_start and ends_in_more_than_two_weeks: - paid_seat_enrollment_end = self.get_paid_seat_enrollment_end() - if paid_seat_enrollment_end and now < paid_seat_enrollment_end: - return True - return False + return self.start and self.start >= now def get_paid_seat_enrollment_end(self): """ Return the final date for which an unenrolled user may enroll and purchase a paid Seat for this CourseRun, or None if the date is unknown or enrollable paid Seats are not available. """ - seats = list(self._enrollable_paid_seats().order_by('-upgrade_deadline')) + # Sort in python to avoid an additional request to the database for order_by + seats = sorted( + self._enrollable_paid_seats(), + key=self._upgrade_deadline_sort, + reverse=True, + ) if not seats: # Enrollable paid seats are not available for this CourseRun. return None - # An unenrolled user may not enroll and purchase paid seats after the course has ended. - deadline = self.end - - # An unenrolled user may not enroll and purchase paid seats after enrollment has ended. - if self.enrollment_end and (deadline is None or self.enrollment_end < deadline): - deadline = self.enrollment_end + # An unenrolled user may not enroll and purchase paid seats after the course or enrollment has ended. + deadline = self.enrollment_deadline - # Note that even though we're sorting in descending order by upgrade_deadline, we will need to look at - # both the first and last record in the result set to determine which Seat has the latest upgrade_deadline. - # We consider Null values to be > than non-Null values, and Null values may sort to the top or bottom of - # the result set, depending on the DB backend. - latest_seat = seats[-1] if seats[-1].upgrade_deadline is None else seats[0] - if latest_seat.upgrade_deadline and (deadline is None or latest_seat.upgrade_deadline < deadline): - deadline = latest_seat.upgrade_deadline + seat = seats[0] + if seat.upgrade_deadline and (deadline is None or seat.upgrade_deadline < deadline): + deadline = seat.upgrade_deadline return deadline + def is_upgradeable(self): + upgrade_deadline = self.get_paid_seat_enrollment_end() + upgradeable = bool(upgrade_deadline) and (datetime.datetime.now(pytz.UTC) < upgrade_deadline) + return upgradeable + def enrollable_seats(self, types=None): """ Returns seats, of the given type(s), that can be enrolled in/purchased. Arguments: - types (list of seat type names): Type of seats to limit the returned value to. + types (list of SeatTypes): Type of seats to limit the returned value to. Returns: List of Seats """ now = datetime.datetime.now(pytz.UTC) - enrollable_seats = [] - if self.end and now > self.end: - return enrollable_seats + enrolls_in_future = self.enrollment_start and self.enrollment_start > now + if self.has_enrollment_ended(now) or enrolls_in_future: + return [] - if self.enrollment_start and self.enrollment_start > now: - return enrollable_seats - - if self.enrollment_end and now > self.enrollment_end: - return enrollable_seats - - types = types or Seat.SEAT_TYPES + enrollable_seats = [] + types = types or SeatType.objects.all() for seat in self.seats.all(): if seat.type in types and (not seat.upgrade_deadline or now < seat.upgrade_deadline): enrollable_seats.append(seat) @@ -768,6 +1567,10 @@ def full_description(self, value): def outcome(self): return self.outcome_override or self.course.outcome + @property + def in_review(self): + return self.status in CourseRunStatus.REVIEW_STATES() + @outcome.setter def outcome(self, value): # Treat empty strings as NULL @@ -792,15 +1595,27 @@ def prerequisites(self): @property def programs(self): - return self.course.programs + return self.course.programs # pylint: disable=no-member @property def seat_types(self): return [seat.type for seat in self.seats.all()] @property - def type(self): - seat_types = set(self.seat_types) + def type_legacy(self): + """ + Calculates a single type slug from the seats in this run. + + This is a property that makes less sense these days. It used to be called simply `type`. But now that Tracks + and Modes and CourseRunType have made our mode / type situation less rigid, this is losing relevance. + + For example, this cannot support modes that don't have corresponding seats (like Masters). + + It's better to just look at all the modes in the run via type -> tracks -> modes and base any logic off that + rather than trying to read the tea leaves at the entire run level. The mode combinations are just too complex + these days. + """ + seat_types = {t.slug for t in self.seat_types} mapping = ( ('credit', {'credit'}), ('professional', {'professional', 'no-id-professional'}), @@ -813,7 +1628,6 @@ def type(self): if matching_seat_types & seat_types: return course_run_type - logger.debug('Unable to determine type for course run [%s]. Seat types are [%s]', self.key, seat_types) return None @property @@ -825,7 +1639,7 @@ def availability(self): now = datetime.datetime.now(pytz.UTC) upcoming_cutoff = now + datetime.timedelta(days=60) - if self.end and self.end <= now: + if self.has_ended(now): return _('Archived') elif self.start and (self.start <= now): return _('Current') @@ -841,10 +1655,8 @@ def get_video(self): @classmethod def search(cls, query): """ Queries the search index. - Args: query (str) -- Elasticsearch querystring (e.g. `title:intro*`) - Returns: SearchQuerySet """ @@ -861,94 +1673,326 @@ def search(cls, query): def __str__(self): return '{key}: {title}'.format(key=self.key, title=self.title) - def save(self, *args, **kwargs): # pylint: disable=arguments-differ - is_new_course_run = not self.pk - suppress_publication = kwargs.pop('suppress_publication', False) - is_publishable = ( - self.course.partner.has_marketing_site and - waffle.switch_is_active('publish_course_runs_to_marketing_site') and - # Pop to clean the kwargs for the base class save call below - not suppress_publication + def validate_seat_upgrade(self, seat_types): + """ + If a course run has an official version, then ecom products have already been created and + we only support changing mode from audit -> verified + """ + if self.official_version: + old_types = set(self.official_version.seats.values_list('type', flat=True)) # returns strings + new_types = {t.slug for t in seat_types} + if new_types & set(Seat.REQUIRES_AUDIT_SEAT): + new_types.add(Seat.AUDIT) + if old_types - new_types: + raise ValidationError(_('Switching seat types after being reviewed is not supported. Please reach out ' + 'to your project coordinator for additional help if necessary.')) + + def get_seat_upgrade_deadline(self, seat_type): + deadline = None + # only verified seats have a deadline specified + if seat_type.slug == Seat.VERIFIED: + seats = self.seats.filter(type=seat_type) + if seats: + deadline = seats[0].upgrade_deadline + else: + deadline = subtract_deadline_delta(self.end, settings.PUBLISHER_UPGRADE_DEADLINE_DAYS) + return deadline + + def update_or_create_seat_helper(self, seat_type, prices): + defaults = { + 'upgrade_deadline': self.get_seat_upgrade_deadline(seat_type), + } + if seat_type.slug in prices: + defaults['price'] = prices[seat_type.slug] + + seat, __ = Seat.everything.update_or_create( + course_run=self, + type=seat_type, + draft=True, + defaults=defaults, ) + return seat - if is_publishable: - publisher = CourseRunMarketingSitePublisher(self.course.partner) - previous_obj = CourseRun.objects.get(id=self.id) if self.id else None + def update_or_create_seats(self, run_type=None, prices=None): + """ + Updates or creates draft seats for a course run. - if not self.slug and self.id: - # If we are publishing this object to marketing site, let's make sure slug is defined. - # Nowadays slugs will be defined at creation time by AutoSlugField for us, so we only need this code - # path for database rows that were empty before we started using AutoSlugField. - self.slug = CourseRun._meta.get_field('slug').create_slug(self, True) + Supports being able to switch seat types to any type before an official version of the + course run exists. After an official version of the course run exists, it only supports + price changes or upgrades from Audit -> Verified. + """ + prices = dict(prices or {}) + seat_types = {track.seat_type for track in run_type.tracks.exclude(seat_type=None)} - with transaction.atomic(): - super(CourseRun, self).save(*args, **kwargs) - publisher.publish_obj(self, previous_obj=previous_obj) - else: - logger.info('Course run [%s] is not publishable.', self.key) - super(CourseRun, self).save(*args, **kwargs) + self.validate_seat_upgrade(seat_types) + + seats = [] + for seat_type in seat_types: + seats.append(self.update_or_create_seat_helper(seat_type, prices)) + + # Deleting seats here since they would be orphaned otherwise. + # One example of how this situation can happen is if a course team is switching between + # professional and verified before actually publishing their course run. + self.seats.exclude(type__in=seat_types).delete() + self.seats.set(seats) # pylint: disable=no-member + + def update_or_create_official_version(self, notify_services=True): + draft_version = CourseRun.everything.get(pk=self.pk) + official_version = set_official_state(draft_version, CourseRun) + + for seat in self.seats.all(): + set_official_state(seat, Seat, {'course_run': official_version}) + + official_course = self.course._update_or_create_official_version(official_version) # pylint: disable=protected-access + official_version.slug = self.slug + official_version.course = official_course + + # During this save, the pre_save hook `ensure_external_key_uniqueness__course_run` in signals.py + # is run. We rely on there being a save of the official version after the call to set_official_state + # and the setting of the official_course. + official_version.save() + + if notify_services: + # Push any track changes to ecommerce and the LMS as well + push_to_ecommerce_for_course_run(official_version) + push_tracks_to_lms_for_course_run(official_version) + return official_version + + def handle_status_change(self, send_emails): + """ + If a row's status changed, take any cleanup actions necessary. + + Mostly this is sending email notifications to interested parties or converting a draft row to an official + one. + """ + if self._old_status == self.status: + return + self._old_status = self.status + + # We currently only care about draft course runs + if not self.draft: + return + + # OK, now check for various status change triggers + + email_method = None + + if self.status == CourseRunStatus.LegalReview: + email_method = emails.send_email_for_legal_review + + elif self.status == CourseRunStatus.InternalReview: + email_method = emails.send_email_for_internal_review + + elif self.status == CourseRunStatus.Reviewed: + official_version = self.update_or_create_official_version() + + # If we're due to go live already and we just now got reviewed, immediately go live + if self.go_live_date and self.go_live_date <= datetime.datetime.now(pytz.UTC): + official_version.publish() # will edit/save us too + else: # The publish status check will send an email for go-live + email_method = emails.send_email_for_reviewed + + elif self.status == CourseRunStatus.Published: + email_method = emails.send_email_for_go_live + + if send_emails and email_method: + email_method(self) + + def save(self, suppress_publication=False, send_emails=True, **kwargs): # pylint: disable=arguments-differ + """ + Arguments: + suppress_publication (bool): if True, we won't push the run data to the marketing site + send_emails (bool): whether to send email notifications for status changes from this save + """ + is_new_course_run = not self.id + push_to_marketing = (not suppress_publication and + self.course.partner.has_marketing_site and + waffle.switch_is_active('publish_course_runs_to_marketing_site') and + self.could_be_marketable) + + with transaction.atomic(): + if push_to_marketing: + previous_obj = CourseRun.objects.get(id=self.id) if self.id else None + + super().save(**kwargs) + self.handle_status_change(send_emails) + + if push_to_marketing: + self.push_to_marketing_site(previous_obj) if is_new_course_run: retired_programs = self.programs.filter(status=ProgramStatus.Retired) for program in retired_programs: program.excluded_course_runs.add(self) + def publish(self, send_emails=True): + """ + Marks the course run as announced and published if it is time to do so. -class SeatType(TimeStampedModel): - name = models.CharField(max_length=64, unique=True) - slug = AutoSlugField(populate_from='name', slugify_function=uslugify) + Course run must be an official version - both it and any draft version will be published. + Marketing site redirects will also be updated. - def __str__(self): - return self.name + Args: + send_emails (bool): whether to send email notifications for this publish action + + Returns: + True if the run was published, False if it was not eligible + """ + if self.draft: + return False + + now = datetime.datetime.now(pytz.UTC) + with transaction.atomic(): + for run in filter(None, [self, self.draft_version]): + run.announcement = now + run.status = CourseRunStatus.Published + run.save(send_emails=send_emails) + + # It is likely that we are sunsetting an old run in favor of this new run, so unpublish old runs just in case + self.course.unpublish_inactive_runs() + + # Add a redirect from the course run URL to the canonical course URL if one doesn't already exist + existing_slug = CourseUrlSlug.objects.filter(url_slug=self.slug, + partner=self.course.partner).first() + if existing_slug and existing_slug.course.uuid == self.course.uuid: + return True + self.course.url_slug_history.create(url_slug=self.slug, partner=self.course.partner, course=self.course) + + return True + + def push_to_marketing_site(self, previous_obj): + publisher = CourseRunMarketingSitePublisher(self.course.partner) + publisher.publish_obj(self, previous_obj=previous_obj) + + def has_ended(self, when=None): + """ + Returns: + True if course run has a defined end and it has passed + """ + when = when or datetime.datetime.now(pytz.UTC) + return bool(self.end and self.end < when) + + def has_enrollment_ended(self, when=None): + """ + Returns: + True if the enrollment deadline is defined and has passed + """ + when = when or datetime.datetime.now(pytz.UTC) + deadline = self.enrollment_deadline + return bool(deadline and deadline < when) + + @property + def enrollment_deadline(self): + """ + Returns: + The datetime past which this run cannot be enrolled in (or ideally, marketed) or None if no restriction + """ + dates = set(filter(None, [self.end, self.enrollment_end])) + return min(dates) if dates else None + + @property + def is_enrollable(self): + """ + Checks if the course run is currently enrollable + + Note that missing enrollment_end or enrollment_start are considered to + mean that the course run does not have a restriction on the respective + fields. + Additionally, we don't consider the end date because archived course + runs may have ended, but they are always enrollable since they have + null enrollment_start and enrollment_end. + """ + now = datetime.datetime.now(pytz.UTC) + return ((not self.enrollment_end or self.enrollment_end >= now) and + (not self.enrollment_start or self.enrollment_start <= now)) + + @property + def could_be_marketable(self): + """ + Checks if the course_run is possibly marketable. + + A course run is considered possibly marketable if it would ever be put on + a marketing site (so things that would *never* be marketable are not). + """ + if not self.type.is_marketable: + return False + return not self.draft + + @property + def is_marketable(self): + """ + Checks if the course_run is currently marketable + + A course run is considered marketable if it's published, has seats, and + a non-empty marketing url. + + If you change this, also change the marketable() queries in query.py. + """ + if not self.could_be_marketable: + return False + + is_published = self.status == CourseRunStatus.Published + return is_published and self.seats.exists() and bool(self.marketing_url) -class Seat(TimeStampedModel): +class Seat(DraftModelMixin, TimeStampedModel): """ Seat model. """ + + # This set of class variables is historic. Before CourseType and Mode and all that jazz, Seat used to just hold + # a CharField 'type' and the logic around what that meant often used these variables. We can slowly remove + # these hardcoded variables as code stops referencing them and using dynamic Modes. HONOR = 'honor' AUDIT = 'audit' VERIFIED = 'verified' PROFESSIONAL = 'professional' CREDIT = 'credit' - - SEAT_TYPES = [HONOR, AUDIT, VERIFIED, PROFESSIONAL, CREDIT] - + MASTERS = 'masters' + EXECUTIVE_EDUCATION = 'executive-education' + ENTITLEMENT_MODES = [VERIFIED, PROFESSIONAL, EXECUTIVE_EDUCATION] + REQUIRES_AUDIT_SEAT = [VERIFIED] # Seat types that may not be purchased without first purchasing another Seat type. # EX: 'credit' seats may not be purchased without first purchasing a 'verified' Seat. SEATS_WITH_PREREQUISITES = [CREDIT] - SEAT_TYPE_CHOICES = ( - (HONOR, _('Honor')), - (AUDIT, _('Audit')), - (VERIFIED, _('Verified')), - (PROFESSIONAL, _('Professional')), - (CREDIT, _('Credit')), - ) - PRICE_FIELD_CONFIG = { 'decimal_places': 2, 'max_digits': 10, 'null': False, 'default': 0.00, } - course_run = models.ForeignKey(CourseRun, related_name='seats') - # TODO Replace with FK to SeatType model - type = models.CharField(max_length=63, choices=SEAT_TYPE_CHOICES) + course_run = models.ForeignKey(CourseRun, models.CASCADE, related_name='seats') + # The 'type' field used to be a CharField but when we converted to a ForeignKey, we kept the db column the same, + # by specifying a bunch of these extra kwargs. + type = models.ForeignKey(SeatType, models.CASCADE, + to_field='slug', # this keeps the columns as a string, not an int + db_column='type') # this avoids renaming the column to type_id price = models.DecimalField(**PRICE_FIELD_CONFIG) - currency = models.ForeignKey(Currency) - upgrade_deadline = models.DateTimeField(null=True, blank=True) + currency = models.ForeignKey(Currency, models.CASCADE, default='USD') + _upgrade_deadline = models.DateTimeField(null=True, blank=True, db_column='upgrade_deadline') + upgrade_deadline_override = models.DateTimeField(null=True, blank=True) credit_provider = models.CharField(max_length=255, null=True, blank=True) credit_hours = models.IntegerField(null=True, blank=True) sku = models.CharField(max_length=128, null=True, blank=True) bulk_sku = models.CharField(max_length=128, null=True, blank=True) - class Meta(object): + history = HistoricalRecords() + + class Meta: unique_together = ( - ('course_run', 'type', 'currency', 'credit_provider') + ('course_run', 'type', 'currency', 'credit_provider', 'draft') ) ordering = ['created'] + @property + def upgrade_deadline(self): + return self.upgrade_deadline_override or self._upgrade_deadline + + @upgrade_deadline.setter + def upgrade_deadline(self, value): + self._upgrade_deadline = value + -class CourseEntitlement(TimeStampedModel): +class CourseEntitlement(DraftModelMixin, TimeStampedModel): """ Model storing product metadata for a Course. """ PRICE_FIELD_CONFIG = { 'decimal_places': 2, @@ -956,23 +2000,24 @@ class CourseEntitlement(TimeStampedModel): 'null': False, 'default': 0.00, } - course = models.ForeignKey(Course, related_name='entitlements') - mode = models.ForeignKey(SeatType) - partner = models.ForeignKey(Partner, null=True, blank=False) + course = models.ForeignKey(Course, models.CASCADE, related_name='entitlements') + mode = models.ForeignKey(SeatType, models.CASCADE) + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) price = models.DecimalField(**PRICE_FIELD_CONFIG) - currency = models.ForeignKey(Currency) + currency = models.ForeignKey(Currency, models.CASCADE, default='USD') sku = models.CharField(max_length=128, null=True, blank=True) - expires = models.DateTimeField(null=True, blank=True) - class Meta(object): + history = HistoricalRecords() + + class Meta: unique_together = ( - ('course', 'mode') + ('course', 'draft') ) ordering = ['created'] class Endorsement(TimeStampedModel): - endorser = models.ForeignKey(Person, blank=False, null=False) + endorser = models.ForeignKey(Person, models.CASCADE, blank=False, null=False) quote = models.TextField(blank=False, null=False) def __str__(self): @@ -982,7 +2027,7 @@ def __str__(self): class CorporateEndorsement(TimeStampedModel): corporation_name = models.CharField(max_length=128, blank=False, null=False) statement = models.TextField(null=True, blank=True) - image = models.ForeignKey(Image, blank=True, null=True) + image = models.ForeignKey(Image, models.CASCADE, blank=True, null=True) individual_endorsements = SortedManyToManyField(Endorsement) def __str__(self): @@ -1002,46 +2047,24 @@ def __str__(self): return self.question -class ProgramType(TimeStampedModel): - name = models.CharField(max_length=32, unique=True, null=False, blank=False) - applicable_seat_types = models.ManyToManyField( - SeatType, help_text=_('Seat types that qualify for completion of programs of this type. Learners completing ' - 'associated courses, but enrolled in other seat types, will NOT have their completion ' - 'of the course counted toward the completion of the program.'), - ) - logo_image = StdImageField( - upload_to=UploadToAutoSlug(populate_from='name', path='media/program_types/logo_images'), - blank=True, - null=True, - variations={ - 'large': (256, 256), - 'medium': (128, 128), - 'small': (64, 64), - 'x-small': (32, 32), - }, - help_text=_('Please provide an image file with transparent background'), - ) - slug = AutoSlugField(populate_from='name', editable=True, unique=True, slugify_function=uslugify, - help_text=_('Leave this field blank to have the value generated automatically.')) - - def __str__(self): - return self.name - - class Program(PkSearchableMixin, TimeStampedModel): uuid = models.UUIDField(blank=True, default=uuid4, editable=False, unique=True, verbose_name=_('UUID')) title = models.CharField( - help_text=_('The user-facing display title for this Program.'), max_length=255, unique=True) + help_text=_('The user-facing display title for this Program.'), max_length=255) subtitle = models.CharField( help_text=_('A brief, descriptive subtitle for the Program.'), max_length=255, blank=True) - type = models.ForeignKey(ProgramType, null=True, blank=True) + marketing_hook = models.CharField( + help_text=_('A brief hook for the marketing website'), max_length=255, blank=True) + type = models.ForeignKey(ProgramType, models.CASCADE, null=True, blank=True) status = models.CharField( help_text=_('The lifecycle status of this Program.'), max_length=24, null=False, blank=False, db_index=True, choices=ProgramStatus.choices, validators=[ProgramStatus.validator] ) marketing_slug = models.CharField( help_text=_('Slug used to generate links to the marketing site'), unique=True, max_length=255, db_index=True) - courses = SortedManyToManyField(Course, related_name='programs') + # Normally you don't need this limit_choices_to line, because Course.objects will return official rows by default. + # But our Django admin form for this field does more low level querying than that and needs to be limited. + courses = SortedManyToManyField(Course, related_name='programs', limit_choices_to={'draft': False}) order_courses_by_start_date = models.BooleanField( default=True, verbose_name='Order Courses By Start Date', help_text=_('If this box is not checked, courses will be ordered as in the courses select box above.') @@ -1049,7 +2072,7 @@ class Program(PkSearchableMixin, TimeStampedModel): # NOTE (CCB): Editors of this field should validate the values to ensure only CourseRuns associated # with related Courses are stored. excluded_course_runs = models.ManyToManyField(CourseRun, blank=True) - partner = models.ForeignKey(Partner, null=True, blank=False) + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) overview = models.TextField(null=True, blank=True) total_hours_of_effort = models.PositiveSmallIntegerField( null=True, blank=True, @@ -1076,8 +2099,16 @@ class Program(PkSearchableMixin, TimeStampedModel): render_variations=custom_render_variations ) banner_image_url = models.URLField(null=True, blank=True, help_text='DEPRECATED: Use the banner image field.') - card_image_url = models.URLField(null=True, blank=True, help_text=_('Image used for discovery cards')) - video = models.ForeignKey(Video, default=None, null=True, blank=True) + card_image = StdImageField( + upload_to=UploadToFieldNamePath(populate_from='uuid', path='media/programs/card_images'), + blank=True, + null=True, + variations={ + 'card': (378, 225), + } + ) + card_image_url = models.URLField(null=True, blank=True, help_text=_('DEPRECATED: Use the card image field')) + video = models.ForeignKey(Video, models.CASCADE, default=None, null=True, blank=True) expected_learning_items = SortedManyToManyField(ExpectedLearningItem, blank=True) faq = SortedManyToManyField(FAQ, blank=True) instructor_ordering = SortedManyToManyField( @@ -1087,7 +2118,6 @@ class Program(PkSearchableMixin, TimeStampedModel): 'displayed on program pages. Instructors in this list should appear before all others associated ' 'with this programs courses runs.') ) - credit_backing_organizations = SortedManyToManyField( Organization, blank=True, related_name='credit_backed_programs' ) @@ -1115,8 +2145,19 @@ class Program(PkSearchableMixin, TimeStampedModel): 'Total number of learners who have enrolled in courses in this program in the last 6 months' ) ) + credit_value = models.PositiveSmallIntegerField( + blank=True, default=0, help_text=_( + 'Number of credits a learner will earn upon successful completion of the program') + ) objects = ProgramQuerySet.as_manager() + history = HistoricalRecords() + + class Meta: + unique_together = ( + ('partner', 'title'), + ) + def __str__(self): return self.title @@ -1140,15 +2181,20 @@ def is_program_eligible_for_one_click_purchase(self): return False excluded_course_runs = set(self.excluded_course_runs.all()) - applicable_seat_types = [seat_type.name.lower() for seat_type in self.type.applicable_seat_types.all()] + applicable_seat_types = self.type.applicable_seat_types.all() for course in self.courses.all(): - entitlement_products = set(course.entitlements.filter(mode__name__in=applicable_seat_types).exclude( - expires__lte=datetime.datetime.now(pytz.UTC))) + # Filter the entitlements in python, to avoid duplicate queries for entitlements after prefetching + all_entitlements = course.entitlements.all() + entitlement_products = {entitlement for entitlement in all_entitlements + if entitlement.mode in applicable_seat_types} if len(entitlement_products) == 1: continue - course_runs = set(course.course_runs.filter(status=CourseRunStatus.Published)) - excluded_course_runs + # Filter the course_runs in python, to avoid duplicate queries for course_runs after prefetching + all_course_runs = course.course_runs.all() + course_runs = {course_run for course_run in all_course_runs + if course_run.status == CourseRunStatus.Published} - excluded_course_runs if len(course_runs) != 1: return False @@ -1239,7 +2285,7 @@ def topics(self): @property def seats(self): - applicable_seat_types = set(seat_type.slug for seat_type in self.type.applicable_seat_types.all()) + applicable_seat_types = set(self.type.applicable_seat_types.all()) for run in self.course_runs: for seat in run.seats.all(): @@ -1248,7 +2294,7 @@ def seats(self): @property def canonical_seats(self): - applicable_seat_types = set(seat_type.slug for seat_type in self.type.applicable_seat_types.all()) + applicable_seat_types = set(self.type.applicable_seat_types.all()) for run in self.canonical_course_runs: for seat in run.seats.all(): @@ -1257,8 +2303,18 @@ def canonical_seats(self): @property def entitlements(self): - applicable_seat_types = set(seat_type.slug for seat_type in self.type.applicable_seat_types.all()) - return CourseEntitlement.objects.filter(mode__name__in=applicable_seat_types, course__in=self.courses.all()) + """ + Property to retrieve all of the entitlements in a Program. + """ + # Warning: The choice to not use a filter method on the queryset here was deliberate. The filter + # method resulted in a new queryset being made which results in the prefetch_related cache being + # ignored. + return [ + entitlement + for course in self.courses.all() + for entitlement in course.entitlements.all() + if entitlement.mode in set(self.type.applicable_seat_types.all()) + ] @property def seat_types(self): @@ -1270,8 +2326,7 @@ def _select_for_total_price(self, selected_seat, candidate_seat): the program total price. A seat is most suitable if the related course_run is now enrollable, has not ended, and the enrollment_start date is most recent """ - end_valid = candidate_seat.course_run.end is None or \ - candidate_seat.course_run.end >= datetime.datetime.now(pytz.UTC) + end_valid = not candidate_seat.course_run.has_ended() selected_enrollment_start = selected_seat.course_run.enrollment_start or \ pytz.utc.localize(datetime.datetime.min) @@ -1374,7 +2429,7 @@ def price_ranges(self): @property def start(self): """ Start datetime, calculated by determining the earliest start datetime of all related course runs. """ - if self.course_runs: + if self.course_runs: # pylint: disable=using-constant-test start_dates = [course_run.start for course_run in self.course_runs if course_run.start] if start_dates: @@ -1384,7 +2439,10 @@ def start(self): @property def staff(self): - staff = [course_run.staff.all() for course_run in self.course_runs] + advertised_course_runs = [course.advertised_course_run for + course in self.courses.all() if + course.advertised_course_run] + staff = [advertised_course_run.staff.all() for advertised_course_run in advertised_course_runs] staff = itertools.chain.from_iterable(staff) return set(staff) @@ -1486,6 +2544,12 @@ class Degree(Program): }, help_text=_('Please provide an image file for the lead capture banner.'), ) + hubspot_lead_capture_form_id = models.CharField( + help_text=_('The Hubspot form ID for the lead capture form'), + null=True, + blank=True, + max_length=128, + ) micromasters_url = models.URLField( help_text=_('URL to micromasters landing page'), @@ -1515,26 +2579,34 @@ class Degree(Program): }, help_text=_('Customized background image for the MicroMasters section.'), ) + micromasters_org_name_override = models.CharField( + help_text=_( + 'Override org name if micromasters program comes from different organization than Masters program' + ), + max_length=50, + blank=True, + null=True, + ) search_card_ranking = models.CharField( help_text=_('Ranking display for search card (e.g. "#1 in the U.S."'), max_length=50, blank=True, - null=True + null=True, ) search_card_cost = models.CharField( help_text=_('Cost display for search card (e.g. "$9,999"'), max_length=50, blank=True, - null=True + null=True, ) search_card_courses = models.CharField( help_text=_('Number of courses for search card (e.g. "11 Courses"'), max_length=50, blank=True, - null=True + null=True, ) - class Meta(object): + class Meta: verbose_name_plural = "Degrees" def __str__(self): @@ -1575,11 +2647,11 @@ class IconTextPairing(TimeStampedModel): (TROPHY, _('Trophy')), ) - degree = models.ForeignKey(Degree, related_name='quick_facts', on_delete=models.CASCADE) + degree = models.ForeignKey(Degree, models.CASCADE, related_name='quick_facts') icon = models.CharField(max_length=100, verbose_name=_('Icon FA class'), choices=ICON_CHOICES) text = models.CharField(max_length=255, verbose_name=_('Paired text')) - class Meta(object): + class Meta: verbose_name_plural = "IconTextPairings" def __str__(self): @@ -1594,7 +2666,7 @@ class DegreeDeadline(TimeStampedModel): class Meta: ordering = ['created'] - degree = models.ForeignKey(Degree, on_delete=models.CASCADE, related_name='deadlines', null=True) + degree = models.ForeignKey(Degree, models.CASCADE, related_name='deadlines', null=True) semester = models.CharField( help_text=_('Deadline applies for this semester (e.g. Spring 2019'), max_length=255, @@ -1610,8 +2682,11 @@ class Meta: time = models.CharField( help_text=_('The time after which the deadline expires (e.g. 11:59 PM EST).'), max_length=255, + blank=True, ) + history = HistoricalRecords() + def __str__(self): return "{} {}".format(self.name, self.date) @@ -1624,7 +2699,7 @@ class DegreeCost(TimeStampedModel): class Meta: ordering = ['created'] - degree = models.ForeignKey(Degree, on_delete=models.CASCADE, related_name='costs', null=True) + degree = models.ForeignKey(Degree, models.CASCADE, related_name='costs', null=True) description = models.CharField( help_text=_('Describes what the cost is for (e.g. Tuition)'), max_length=255, @@ -1634,20 +2709,28 @@ class Meta: max_length=255, ) + history = HistoricalRecords() + def __str__(self): return str('{}, {}'.format(self.description, self.amount)) class Curriculum(TimeStampedModel): """ - This model links a degree to the curriculum associated with that degree, that is, the - courses and programs that compose the degree. + This model links a program to the curriculum associated with that program, that is, the + courses and programs that compose the program. """ uuid = models.UUIDField(blank=True, default=uuid4, editable=False, unique=True, verbose_name=_('UUID')) - degree = models.OneToOneField(Degree, on_delete=models.CASCADE, related_name='curriculum') - marketing_text_brief = models.TextField( + program = models.ForeignKey( + Program, + models.CASCADE, + related_name='curricula', null=True, - blank=True, + default=None, + ) + name = models.CharField(blank=True, max_length=255) + is_active = models.BooleanField(default=True) + marketing_text_brief = NullHtmlField( max_length=750, help_text=_( """A high-level overview of the degree\'s courseware. The "brief" @@ -1655,36 +2738,74 @@ class Curriculum(TimeStampedModel): valid HTML.""" ), ) - marketing_text = models.TextField( + marketing_text = HtmlField( null=True, blank=False, help_text=_('A high-level overview of the degree\'s courseware.'), ) program_curriculum = models.ManyToManyField( - Program, through='course_metadata.DegreeProgramCurriculum', related_name='degree_program_curricula' + Program, through='course_metadata.CurriculumProgramMembership', related_name='degree_program_curricula' ) course_curriculum = models.ManyToManyField( - Course, through='course_metadata.DegreeCourseCurriculum', related_name='degree_course_curricula' + Course, through='course_metadata.CurriculumCourseMembership', related_name='degree_course_curricula' ) + history = HistoricalRecords() + def __str__(self): - return str(self.uuid) + return str(self.name) if self.name else str(self.uuid) -class DegreeProgramCurriculum(TimeStampedModel): +class CurriculumProgramMembership(TimeStampedModel): """ Represents the Programs that compose the curriculum of a degree. """ - program = models.ForeignKey(Program, on_delete=models.CASCADE) - curriculum = models.ForeignKey(Curriculum, on_delete=models.CASCADE) + program = models.ForeignKey(Program, models.CASCADE) + curriculum = models.ForeignKey(Curriculum, models.CASCADE) + is_active = models.BooleanField(default=True) + + history = HistoricalRecords() + + class Meta(TimeStampedModel.Meta): + unique_together = ( + ('curriculum', 'program') + ) -class DegreeCourseCurriculum(TimeStampedModel): +class CurriculumCourseMembership(TimeStampedModel): """ Represents the Courses that compose the curriculum of a degree. """ - curriculum = models.ForeignKey(Curriculum, on_delete=models.CASCADE) - course = models.ForeignKey(Course, on_delete=models.CASCADE) + curriculum = models.ForeignKey(Curriculum, models.CASCADE) + course = models.ForeignKey(Course, models.CASCADE, related_name='curriculum_course_membership') + course_run_exclusions = models.ManyToManyField( + CourseRun, through='course_metadata.CurriculumCourseRunExclusion', related_name='curriculum_course_membership' + ) + is_active = models.BooleanField(default=True) + + history = HistoricalRecords() + + class Meta(TimeStampedModel.Meta): + unique_together = ( + ('curriculum', 'course') + ) + + @property + def course_runs(self): + return set(self.course.course_runs.all()) - set(self.course_run_exclusions.all()) + + def __str__(self): + return str(self.curriculum) + " : " + str(self.course) + + +class CurriculumCourseRunExclusion(TimeStampedModel): + """ + Represents the CourseRuns that are excluded from a course curriculum. + """ + course_membership = models.ForeignKey(CurriculumCourseMembership, models.CASCADE) + course_run = models.ForeignKey(CourseRun, models.CASCADE) + + history = HistoricalRecords() class Pathway(TimeStampedModel): @@ -1692,7 +2813,7 @@ class Pathway(TimeStampedModel): Pathway model """ uuid = models.UUIDField(default=uuid4, editable=False, unique=True, verbose_name=_('UUID')) - partner = models.ForeignKey(Partner, null=True, blank=False) + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) name = models.CharField(max_length=255) # this field doesn't necessarily map to our normal org models, it's just a convenience field for pathways # while we figure them out @@ -1719,7 +2840,7 @@ def validate_partner_programs(cls, partner, programs): bad_programs = [str(x) for x in programs if x.partner != partner] if bad_programs: msg = _('These programs are for a different partner than the pathway itself: {}') - raise ValidationError(msg.format(', '.join(bad_programs))) # pylint: disable=no-member + raise ValidationError(msg.format(', '.join(bad_programs))) class PersonSocialNetwork(TimeStampedModel): @@ -1727,10 +2848,26 @@ class PersonSocialNetwork(TimeStampedModel): FACEBOOK = 'facebook' TWITTER = 'twitter' BLOG = 'blog' + LINKEDIN = 'linkedin' + DRIBBBLE = 'dribbble' + YOUTUBE = 'youtube' + SKYPE = 'skype' + INSTAGRAM = 'instagram' + GITHUB = 'github' + STACKOVERFLOW = 'stackoverflow' + MEDIUM = 'medium' OTHERS = 'others' SOCIAL_NETWORK_CHOICES = { FACEBOOK: _('Facebook'), + LINKEDIN: _('LinkedIn'), + DRIBBBLE: _('Dribbble'), + YOUTUBE: _('Youtube'), + SKYPE: _('Skype'), + INSTAGRAM: _('Instagram'), + GITHUB: _('github'), + STACKOVERFLOW: _('stackoverflow'), + MEDIUM: _('medium'), TWITTER: _('Twitter'), BLOG: _('Blog'), OTHERS: _('Others'), @@ -1739,9 +2876,9 @@ class PersonSocialNetwork(TimeStampedModel): type = models.CharField(max_length=15, choices=sorted(list(SOCIAL_NETWORK_CHOICES.items())), db_index=True) url = models.CharField(max_length=500) title = models.CharField(max_length=255, blank=True) - person = models.ForeignKey(Person, related_name='person_networks') + person = models.ForeignKey(Person, models.CASCADE, related_name='person_networks') - class Meta(object): + class Meta: verbose_name_plural = 'Person SocialNetwork' unique_together = ( @@ -1764,12 +2901,77 @@ def display_title(self): class PersonAreaOfExpertise(AbstractValueModel): """ Person Area of Expertise model. """ - person = models.ForeignKey(Person, related_name='areas_of_expertise') + person = models.ForeignKey(Person, models.CASCADE, related_name='areas_of_expertise') - class Meta(object): + class Meta: verbose_name_plural = 'Person Areas of Expertise' +class CourseUrlSlug(TimeStampedModel): + course = models.ForeignKey(Course, models.CASCADE, related_name='url_slug_history') + # need to have these on the model separately for unique_together to work, but it should always match course.partner + partner = models.ForeignKey(Partner, models.CASCADE) + url_slug = AutoSlugField(populate_from='course__title', editable=True, slugify_function=uslugify, + overwrite_on_add=False, max_length=255) + is_active = models.BooleanField(default=False) + + # useful if a course editor decides to edit a draft and provide a url_slug that has already been associated + # with the course + is_active_on_draft = models.BooleanField(default=False) + + # ensure partner matches course + def save(self, **kwargs): + if self.partner != self.course.partner: + msg = _('Partner {partner_key} and course partner {course_partner_key} do not match when attempting' + ' to save url slug {url_slug}') + raise ValidationError({'partner': [msg.format(partner_key=self.partner.name, + course_partner_key=self.course.partner.name, + url_slug=self.url_slug), ]}) + super().save(**kwargs) + + class Meta: + unique_together = ( + ('partner', 'url_slug') + ) + + +class CourseUrlRedirect(AbstractValueModel): + course = models.ForeignKey(Course, models.CASCADE, related_name='url_redirects') + # need to have these on the model separately for unique_together to work, but it should always match course.partner + partner = models.ForeignKey(Partner, models.CASCADE) + + def save(self, **kwargs): + if self.partner != self.course.partner: + msg = _('Partner {partner_key} and course partner {course_partner_key} do not match when attempting' + ' to save url redirect {url_path}') + raise ValidationError({'partner': [msg.format(partner_key=self.partner.name, + course_partner_key=self.course.partner.name, + url_slug=self.value), ]}) + super().save(**kwargs) + + class Meta: + unique_together = ( + ('partner', 'value') + ) + + +class BackpopulateCourseTypeConfig(SingletonModel): + """ + Configuration for the backpopulate_course_type management command. + """ + class Meta: + verbose_name = 'backpopulate_course_type argument' + + arguments = models.TextField( + blank=True, + help_text='Useful for manually running a Jenkins job. Specify like "--org=key1 --org=key2".', + default='', + ) + + def __str__(self): + return self.arguments + + class DataLoaderConfig(SingletonModel): """ Configuration for data loaders used in the refresh_course_metadata command. @@ -1781,7 +2983,7 @@ class DeletePersonDupsConfig(SingletonModel): """ Configuration for the delete_person_dups management command. """ - class Meta(object): + class Meta: verbose_name = 'delete_person_dups argument' arguments = models.TextField( @@ -1802,8 +3004,49 @@ class DrupalPublishUuidConfig(SingletonModel): push_people = models.BooleanField(default=False) +class MigratePublisherToCourseMetadataConfig(SingletonModel): + """ + Configuration for the migrate_publisher_to_course_metadata command. + """ + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) + orgs = SortedManyToManyField(Organization, blank=True) + + class ProfileImageDownloadConfig(SingletonModel): """ Configuration for management command to Download Profile Images from Drupal. """ person_uuids = models.TextField(default=None, null=False, blank=False, verbose_name=_('Profile Image UUIDs')) + + +class TagCourseUuidsConfig(SingletonModel): + """ + Configuration for management command add_tag_to_courses. + """ + tag = models.TextField(default=None, null=True, blank=False, verbose_name=_('Tag')) + course_uuids = models.TextField(default=None, null=True, blank=False, verbose_name=_('Course UUIDs')) + + +class MigrateCommentsToSalesforce(SingletonModel): + """ + Configuration for the migrate_comments_to_salesforce command. + """ + partner = models.ForeignKey(Partner, models.CASCADE, null=True, blank=False) + orgs = SortedManyToManyField(Organization, blank=True) + + +class RemoveRedirectsConfig(SingletonModel): + """ + Configuration for management command remove_redirects_from_courses. + """ + remove_all = models.BooleanField(default=False, verbose_name=_('Remove All Redirects')) + url_paths = models.TextField(default='', null=False, blank=True, verbose_name=_('Url Paths')) + + +class BulkModifyProgramHookConfig(SingletonModel): + program_hooks = models.TextField(blank=True, null=True) + + +class BackfillCourseRunSlugsConfig(SingletonModel): + all = models.BooleanField(default=False, verbose_name=_('Add redirects from all published course url slugs')) + uuids = models.TextField(default='', null=False, blank=True, verbose_name=_('Course uuids')) diff --git a/course_discovery/apps/course_metadata/people.py b/course_discovery/apps/course_metadata/people.py index 76469bc255..252551372c 100644 --- a/course_discovery/apps/course_metadata/people.py +++ b/course_discovery/apps/course_metadata/people.py @@ -7,7 +7,7 @@ logger = logging.getLogger(__name__) -class MarketingSitePeople(object): +class MarketingSitePeople: """ This will add the object data to marketing site """ @@ -57,6 +57,7 @@ def update_or_publish_person(self, person): else: node_data['uuid'] = str(person.uuid) return self._create_node(api_client, node_data) + return None def delete_person(self, partner, node_id): api_client = self._get_api_client(partner) diff --git a/course_discovery/apps/course_metadata/publishers.py b/course_discovery/apps/course_metadata/publishers.py index 1e2d146679..dcc21795f5 100644 --- a/course_discovery/apps/course_metadata/publishers.py +++ b/course_discovery/apps/course_metadata/publishers.py @@ -8,7 +8,7 @@ from course_discovery.apps.course_metadata.choices import CourseRunStatus from course_discovery.apps.course_metadata.exceptions import ( AliasCreateError, AliasDeleteError, FormRetrievalError, NodeCreateError, NodeDeleteError, NodeEditError, - NodeLookupError, RedirectCreateError + NodeLookupError ) from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClient, uslugify @@ -64,8 +64,8 @@ def delete_obj(self, obj): obj (django.db.models.Model): Model instance to be deleted. """ node_id = self.node_id(obj) - - self.delete_node(node_id) + if node_id: + self.delete_node(node_id) def serialize_obj(self, obj): """ @@ -304,36 +304,6 @@ def update_node_alias(self, obj, node_id, previous_obj): if response.status_code != 200: raise AliasCreateError - def add_url_redirect(self, obj, previous_obj): - """ - Add a url redirect from the previous object to the new node_id - - Arguments: - obj (CourseRun): string of the node id - previous_obj (CourseRun): the old course run to redirect to - """ - logger.info('Setting redirect from [%s] to [%s].', previous_obj.slug, obj.slug) - - node_id = self.node_id(obj) - previous_node_id = self.node_id(previous_obj) - - headers = { - 'content-type': 'application/x-www-form-urlencoded' - } - - data = { - **self.form_inputs(self.redirect_add_url), - 'form_id': 'redirect_edit_form', - 'op': 'Save', - 'source': 'node/{}'.format(previous_node_id), - 'redirect': 'node/{}'.format(node_id), - } - - response = self.client.api_session.post(self.redirect_add_url, headers=headers, data=data) - - if response.status_code != 200: - raise RedirectCreateError - class CourseRunMarketingSitePublisher(BaseMarketingSitePublisher): """ @@ -342,7 +312,7 @@ class CourseRunMarketingSitePublisher(BaseMarketingSitePublisher): unique_field = 'key' node_lookup_field = 'field_course_id' - def publish_obj(self, obj, previous_obj=None, include_uuid=False): + def publish_obj(self, obj, previous_obj=None, include_uuid=False): # pylint: disable=arguments-differ """ Publish a CourseRun to the marketing site. @@ -382,7 +352,6 @@ def publish_obj(self, obj, previous_obj=None, include_uuid=False): logger.info('Created new marketing site node [%s] for course run [%s].', node_id, obj.key) if node_id and (not previous_obj or obj.slug != previous_obj.slug): - logger.info('Setting marketing alias of [%s] for course [%s].', obj.slug, obj.key) # Don't pass previous_obj to update_node_alias, because we don't want to delete the old alias. # Not deleting it means that Drupal will automatically have old alias point to new alias. @@ -444,9 +413,13 @@ def publish_obj(self, obj, previous_obj=None): if obj.type.name in types_to_publish: node_data = self.serialize_obj(obj) + changed = False node_id = None - if not previous_obj: + if previous_obj: + node_id = self.node_id(obj) # confirm that it already exists on marketing side + if not node_id: node_id = self.create_node(node_data) + changed = True else: trigger_fields = ( 'marketing_slug', @@ -456,13 +429,13 @@ def publish_obj(self, obj, previous_obj=None): ) if any(getattr(obj, field) != getattr(previous_obj, field) for field in trigger_fields): - node_id = self.node_id(obj) # Drupal does not allow modification of the UUID field. node_data.pop('uuid', None) self.edit_node(node_id, node_data) + changed = True - if node_id: + if changed: self.get_and_delete_alias(uslugify(obj.title)) self.update_node_alias(obj, node_id, previous_obj) diff --git a/course_discovery/apps/course_metadata/query.py b/course_discovery/apps/course_metadata/query.py index f7957786b2..99a970eea7 100644 --- a/course_discovery/apps/course_metadata/query.py +++ b/course_discovery/apps/course_metadata/query.py @@ -39,6 +39,8 @@ def available(self): marketable = ( ~Q(course_runs__slug='') & Q(course_runs__seats__isnull=False) & + Q(course_runs__draft=False) & + ~Q(course_runs__type__is_marketable=False) & Q(course_runs__status=CourseRunStatus.Published) ) @@ -48,14 +50,14 @@ def available(self): # runs is published while the other is not. If you used exclude(), the Course # would be dropped from the queryset even though it has one run which matches # our availability criteria. - query = self.filter(enrollable & not_ended & marketable) - # By itself, the query above performs a join across several tables and would return - # a copy of the same course multiple times (a separate copy for each available + # By itself, the query performs a join across several tables and would return + # the id of the same course multiple times (a separate copy for each available # seat in each available run). - # We use distinct() to make sure it only returns a single copy of each available - # course. - return query.distinct() + ids = self.filter(enrollable & not_ended & marketable).values('id').distinct() + + # Now return the full object for each of the selected ids + return self.filter(id__in=ids) class CourseRunQuerySet(models.QuerySet): @@ -114,6 +116,10 @@ def marketable(self): ).exclude( # This will exclude any course run without seats (e.g., CCX runs). seats__isnull=True + ).filter( + draft=False + ).exclude( + type__is_marketable=False ).filter( status=CourseRunStatus.Published ) diff --git a/course_discovery/apps/course_metadata/salesforce.py b/course_discovery/apps/course_metadata/salesforce.py new file mode 100644 index 0000000000..30f8075152 --- /dev/null +++ b/course_discovery/apps/course_metadata/salesforce.py @@ -0,0 +1,438 @@ +import logging +import re +from datetime import datetime, timezone + +import requests +from django.utils.translation import ugettext as _ +from requests.adapters import HTTPAdapter +from simple_salesforce import Salesforce, SalesforceExpiredSession + +from course_discovery.apps.core.models import User +from course_discovery.apps.course_metadata.choices import CourseRunStatus + +ORGANIZATION_SALESFORCE_FIELDS = { + 'organization': ('name', 'key') +} +COURSE_SALESFORCE_FIELDS = { + 'course': ('title', 'key'), +} +COURSE_RUN_SALESFORCE_FIELDS = { + 'course_run': ('start', 'end', 'status', 'title', 'go_live_date', 'key', 'has_ofac_restrictions'), +} + +logger = logging.getLogger(__name__) + + +def requires_salesforce_update(source_of_edit, instance): + from course_discovery.apps.course_metadata.models import Course, CourseRun # pylint: disable=import-outside-toplevel + relative_fields = ORGANIZATION_SALESFORCE_FIELDS + if isinstance(instance, Course): + relative_fields = COURSE_SALESFORCE_FIELDS + elif isinstance(instance, CourseRun): + relative_fields = COURSE_RUN_SALESFORCE_FIELDS + return any(attr in relative_fields[source_of_edit] and + instance.did_change(attr) for + attr in instance.__dict__) + + +def populate_official_with_existing_draft(instance, util): + from course_discovery.apps.course_metadata.models import Course, CourseRun # pylint: disable=import-outside-toplevel + created = False + if not instance.draft_version.salesforce_id: + if isinstance(instance, Course): + util.create_course(instance.draft_version) + created = True + elif isinstance(instance, CourseRun): + util.create_course_run(instance.draft_version) + created = True + + if instance.draft_version.salesforce_id: + instance.salesforce_id = instance.draft_version.salesforce_id + instance.save() + return created + + +def salesforce_request_wrapper(method): + """ + Annotation for querying against Salesforce. Will handle re-authorization if + the session is logged out, and raise exceptions for unsupported cases. + """ + def inner(self, *args, **kwargs): + if self.enabled: + if self.client: + try: + return method(self, *args, **kwargs) + except SalesforceExpiredSession: + self.login() + return method(self, *args, **kwargs) + # Need to catch OSError for the 'Connection aborted.' error when Salesforce reaps a connection + except OSError: + logger.warning('An OSError occurred while attempting to call {}'.format(method.__name__)) + self.login() + return method(self, *args, **kwargs) + raise SalesforceNotConfiguredException( + _('Attempted to query Salesforce with no client for partner={}').format(self.partner.name) + ) + return None + return inner + + +class SalesforceNotConfiguredException(Exception): + """ + Exception to be raised if the configuration of Salesforce does not exist, + but an attempt is still made to query for data from within Salesforce + """ + + +class SalesforceMissingCaseException(Exception): + """ + Exception to be raised if the Course does not have an associated + salesforce_case_id despite having called out to create_case_for_course + """ + def __init__(self, message): + self.message = message + super(SalesforceMissingCaseException, self).__init__(message) + + +class SalesforceUtil: + """ + Singleton utility class to instantiate only a single Salesforce session based on the partner. + Any and all queries against Salesforce should be wrapped with the salesforce_request_wrapper + annotation to handle misconfigurations and session timeouts. Any attribute gets fall down to an + underlying child object which wraps a simple-salesforce connection. + """ + + class __SalesforceUtil: + client = None + + def __init__(self, partner): + self.partner = partner + if self.salesforce_is_enabled(): + self.login() + + def salesforce_is_enabled(self): + return self.partner.salesforce is not None + + def login(self): + # Need to instantiate a session with multiple retries to avoid OSError + session = requests.Session() + adapter = HTTPAdapter(max_retries=2) + session.mount('https://', adapter) + + salesforce_config = self.partner.salesforce + sf_kwargs = { + 'username': salesforce_config.username, + 'password': salesforce_config.password, + 'organizationId': salesforce_config.organization_id, + # security_token must be an empty string if organizationId is set + 'security_token': '' if salesforce_config.organization_id else salesforce_config.token, + 'domain': 'test' if salesforce_config.is_sandbox else None + } + self.client = Salesforce(session=session, **sf_kwargs) + + instances = {} + + def __init__(self, partner): + self.partner = partner + if partner not in SalesforceUtil.instances: + SalesforceUtil.instances[partner] = SalesforceUtil.__SalesforceUtil(partner) + + def __getattr__(self, name): + return getattr(SalesforceUtil.instances.get(self.partner), name) + + @property + def enabled(self): + return self.salesforce_is_enabled() + + def _query(self, soql, *soql_args): + return self.client.query(soql.format(*[self.soql_escape(arg) for arg in soql_args])) + + def soql_escape(self, soql): + """ + Escapes a soql string against injection + + The single quote and backlash characters are reserved in SOQL + queries and must be preceded by a backslash to be properly interpreted. + """ + return soql.replace('\\', r'\\').replace("'", r"\'") + + @salesforce_request_wrapper + def create_publisher_organization(self, organization): + if not organization.salesforce_id: + sf_organization = self.client.Publisher_Organization__c.create( + self._build_publisher_organization_payload(organization) + ) + organization.salesforce_id = sf_organization.get('id') + organization.save() + + @salesforce_request_wrapper + def create_course(self, course): + if not course.salesforce_id: + organization = course.authoring_organizations.first() + if organization: + if not organization.salesforce_id: + self.create_publisher_organization(organization) + if organization.salesforce_id: + sf_course = self.client.Course__c.create( + self._build_course_payload(course, organization) + ) + course.salesforce_id = sf_course.get('id') + course.save() + + @salesforce_request_wrapper + def create_course_run(self, course_run): + if not course_run.salesforce_id: + if not course_run.course.salesforce_id: + self.create_course(course_run.course) + if course_run.course.salesforce_id: + sf_course_run = self.client.Course_Run__c.create( + self._build_course_run_payload(course_run) + ) + course_run.salesforce_id = sf_course_run.get('id') + course_run.save() + + @salesforce_request_wrapper + def create_case_for_course(self, course): + if not course.salesforce_case_id: + if not course.salesforce_id: + self.create_course(course) + if course.salesforce_id: + case = { + 'Course__c': course.salesforce_id, + 'Status': 'Open', + 'Origin': 'Publisher', + 'Subject': '{} Comments'.format(course.title), + 'Description': 'This case is required to be Open for the Publisher comment service.' + } + case_record_type_id = self.partner.salesforce.case_record_type_id + # Only add the record type ID if it's configured, this is not a required field + if case_record_type_id: + case['RecordTypeId'] = case_record_type_id + + sf_case = self.client.Case.create(case) + course.salesforce_case_id = sf_case.get('id') + course.save() + if course.official_version and not course.official_version.salesforce_case_id: + official_version = course.official_version + official_version.salesforce_case_id = sf_case.get('id') + official_version.save() + + @salesforce_request_wrapper + def create_comment_for_course_case(self, course, user, body, course_run_key=None): + if not course.salesforce_case_id: + self.create_case_for_course(course) + if course.salesforce_case_id: + user_comment_body = self.format_user_comment_body(user, body, course_run_key=course_run_key) + self.client.FeedItem.create({ + 'ParentId': course.salesforce_case_id, + 'Body': user_comment_body, + }) + return self._create_comment_return_body(user, body, course_run_key) + else: + raise SalesforceMissingCaseException( + _('Unable to associate a case for comments for {}').format(course.key) + ) + + @salesforce_request_wrapper + def update_publisher_organization(self, organization): + """Triggered by the update_salesforce_organization signal receiver""" + if organization.salesforce_id: + self.client.Publisher_Organization__c.update( + organization.salesforce_id, self._build_publisher_organization_payload(organization) + ) + + @salesforce_request_wrapper + def update_course(self, course): + """Triggered by the update_salesforce_course signal receiver""" + if course.salesforce_id: + organization = course.authoring_organizations.first() + self.client.Course__c.update( + course.salesforce_id, self._build_course_payload(course, organization) + ) + else: + self.create_course(course) + + @salesforce_request_wrapper + def update_course_run(self, course_run): + """Triggered by the update_salesforce_course_run signal receiver""" + if course_run.salesforce_id: + self.client.Course_Run__c.update( + course_run.salesforce_id, self._build_course_run_payload(course_run) + ) + else: + self.create_course_run(course_run) + + @staticmethod + def format_user_comment_body(user, body, course_run_key=None): + if user.first_name and user.last_name: + user_message = '[User]\n{first_name} {last_name} ({username})'.format( + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + ) + else: + user_message = '[User]\n{username}'.format(username=user.username) + course_run_message = '[Course Run]\n{course_run_key}\n\n'.format( + course_run_key=course_run_key + ) if course_run_key else '' + return '{user_message}\n\n{course_run_message}[Body]\n{body}'.format( + user_message=user_message, + course_run_message=course_run_message, + body=body, + ) + + @salesforce_request_wrapper + def get_comments_for_course(self, course): + if course.salesforce_case_id: + fields = [ + 'CreatedDate', + 'Body', + 'CreatedBy.Username', + 'CreatedBy.Email', + 'CreatedBy.FirstName', + 'CreatedBy.LastName', + ] + comments = self._query( + "SELECT {} FROM FeedItem WHERE ParentId='{}' AND IsDeleted=FALSE ORDER BY CreatedDate ASC".format( + ','.join(fields), + course.salesforce_case_id, + ) + ) + # FeedItems.Body cannot be part of a WHERE clause because it is a TextArea type. + # When Cases are created empty Body FeedItems are as well (Case Created, Owner Assigned) + # so we filter these empty Body results out so as to not display them as "Comments" + filtered_comment_records = [comment for comment in comments.get('records') if comment.get('Body')] + parsed_comments = [self._parse_user_comment_body(comment) for comment in filtered_comment_records] + comments = self._add_user_info_to_comments(parsed_comments) + return comments + return [] + + @staticmethod + def _parse_user_comment_body(comment): + match = re.match( + r"\[User\]\n(?:.*?\()?(.*?)\)?\n\n(?:\[Course Run\]\n^(.+)$\n\n)?\[Body\]\n^(.+)", + str(comment.get('Body')), + flags=re.MULTILINE | re.DOTALL + ) + if match: + return { + 'user': { + 'username': match.groups()[0], + 'email': None, + 'first_name': None, + 'last_name': None, + }, + 'course_run_key': match.groups()[1], + 'comment': match.groups()[2], + 'created': comment.get('CreatedDate'), + } + created_by = comment.get('CreatedBy') + return { + 'user': { + 'username': created_by.get('Username'), + 'email': created_by.get('Email') or None, + 'first_name': created_by.get('FirstName') or None, + 'last_name': created_by.get('LastName') or None, + }, + 'course_run_key': None, + 'comment': comment.get('Body'), + 'created': comment.get('CreatedDate'), + } + + @staticmethod + def _add_user_info_to_comments(comments): + usernames = set() + for comment in comments: + user = comment.get('user') + if user: + username = user.get('username') + if username: + usernames.add(username) + users = User.objects.filter(username__in=usernames) + users = {user.username: user for user in users} + for comment in comments: + # Treat not having a first_name as a trigger to get the User from Publisher + comment_user = comment.get('user') + username = comment_user.get('username') + if comment_user and username and not comment_user.get('first_name'): + user = users.get(username) + if user: + comment['user']['email'] = user.email or None + comment['user']['first_name'] = user.first_name or None + comment['user']['last_name'] = user.last_name or None + return comments + + @staticmethod + def _create_comment_return_body(user, body, course_run_key=None): + """ + Salesforce does not return the fully created Object, this method + creates the equivalent of what we would expect to return from our API + """ + return { + 'user': { + 'username': user.username, + 'email': user.email or None, + 'first_name': user.first_name or None, + 'last_name': user.last_name or None, + }, + 'course_run_key': course_run_key, + 'comment': body, + 'created': datetime.now(timezone.utc).isoformat(), + } + + @staticmethod + def _build_publisher_organization_payload(organization): + return { + 'Organization_Name__c': organization.name, + 'Organization_Key__c': organization.key, + } + + def _build_course_payload(self, course, organization): + return { + 'Course_Name__c': course.title, + 'Link_to_Publisher__c': '{url}/courses/{uuid}'.format( + url=self.partner.publisher_url.strip('/') if self.partner.publisher_url else '', uuid=course.uuid + ), + 'Link_to_Admin_Portal__c': '{url}/admin/course_metadata/course/{id}/change/'.format( + url=self.partner.site.domain.strip('/') if self.partner.site.domain else '', id=course.id + ), + 'Course_Key__c': course.key, + 'Publisher_Organization__c': organization.salesforce_id if organization else None, + } + + def _build_course_run_payload(self, course_run): + return { + 'Course__c': course_run.course.salesforce_id, + 'Link_to_Admin_Portal__c': '{url}/admin/course_metadata/courserun/{id}/change/'.format( + url=self.partner.site.domain.strip('/') if self.partner.site.domain else '', id=course_run.id + ), + 'Course_Start_Date__c': course_run.start.isoformat() if course_run.start else None, + 'Course_End_Date__c': course_run.end.isoformat() if course_run.end else None, + 'Publisher_Status__c': self._get_equivalent_status(course_run.status), + 'Course_Run_Name__c': course_run.title, + 'Expected_Go_Live_Date__c': course_run.go_live_date.isoformat() if course_run.go_live_date else None, + 'Course_Number__c': course_run.key, + 'OFAC_Review_Decision__c': self._get_equivalent_ofac_review_decision(course_run.has_ofac_restrictions), + } + + @staticmethod + def _get_equivalent_ofac_review_decision(has_ofac_restrictions): + # Note: these must match the equivalent 'picklistValues' for Salesforce's Course_Run__c.OFAC_Review_Decision__c + salesforce_ofac_restrictions = { + None: 'Not Reviewed', + False: 'OFAC Disabled', + True: 'OFAC Enabled', + } + return salesforce_ofac_restrictions.get(has_ofac_restrictions) + + @staticmethod + def _get_equivalent_status(status): + # Note: these must match the equivalent 'picklistValues' for Salesforce's Course_Run__c.Publisher_Status__c + salesforce_statuses = { + CourseRunStatus.Unpublished: 'New/Unsubmitted Edits', + CourseRunStatus.LegalReview: 'In Legal Review', + CourseRunStatus.InternalReview: 'In PC Review', + CourseRunStatus.Reviewed: 'Scheduled', + CourseRunStatus.Published: 'Live', + } + return salesforce_statuses.get(status) diff --git a/course_discovery/apps/course_metadata/search_indexes.py b/course_discovery/apps/course_metadata/search_indexes.py index 4edf34e98f..b9e5c83ee8 100644 --- a/course_discovery/apps/course_metadata/search_indexes.py +++ b/course_discovery/apps/course_metadata/search_indexes.py @@ -40,6 +40,10 @@ ORG_FIELD_BOOST = TITLE_FIELD_BOOST +def filter_visible_runs(course_runs): + return course_runs.exclude(type__is_marketable=False) + + class OrganizationsMixin: def format_organization(self, organization): return '{key}: {name}'.format(key=organization.key, name=organization.name) @@ -47,7 +51,7 @@ def format_organization(self, organization): def format_organization_body(self, organization): # Deferred to prevent a circular import: # course_discovery.apps.api.serializers -> course_discovery.apps.course_metadata.search_indexes - from course_discovery.apps.api.serializers import OrganizationSerializer + from course_discovery.apps.api.serializers import OrganizationSerializer # pylint: disable=import-outside-toplevel return json.dumps(OrganizationSerializer(organization).data) @@ -90,12 +94,7 @@ def prepare_authoring_organization_uuids(self, obj): def _prepare_language(self, language): if language: - # ECOM-5466: Render the macro language for all languages except Chinese - if language.code.startswith('zh'): - return language.name - else: - return language.macrolanguage - + return language.get_search_facet_display() return None @@ -113,17 +112,18 @@ class BaseCourseIndex(OrganizationsMixin, BaseIndex): logo_image_urls = indexes.MultiValueField() sponsoring_organizations = indexes.MultiValueField(faceted=True) level_type = indexes.CharField(null=True, faceted=True) + outcome = indexes.CharField(model_attr='outcome', null=True) partner = indexes.CharField(model_attr='partner__short_code', null=True, faceted=True) def prepare_logo_image_urls(self, obj): orgs = obj.authoring_organizations.all() - return [org.logo_image_url for org in orgs] + return [org.logo_image.url for org in orgs if org.logo_image] def prepare_subjects(self, obj): return [subject.name for subject in obj.subjects.all()] def prepare_organizations(self, obj): - return self.prepare_authoring_organizations(obj) + self.prepare_sponsoring_organizations(obj) + return set(self.prepare_authoring_organizations(obj) + self.prepare_sponsoring_organizations(obj)) def prepare_authoring_organizations(self, obj): return self._prepare_organizations(obj.authoring_organizations.all()) @@ -140,11 +140,13 @@ class CourseIndex(BaseCourseIndex, indexes.Indexable): uuid = indexes.CharField(model_attr='uuid') card_image_url = indexes.CharField(model_attr='card_image_url', null=True) + image_url = indexes.CharField(model_attr='image_url', null=True) org = indexes.CharField() status = indexes.CharField(model_attr='course_runs__status') start = indexes.DateTimeField(model_attr='course_runs__start', null=True) end = indexes.DateTimeField(model_attr='course_runs__end', null=True) + modified = indexes.DateTimeField(model_attr='modified', null=True) enrollment_start = indexes.DateTimeField(model_attr='course_runs__enrollment_start', null=True) enrollment_end = indexes.DateTimeField(model_attr='course_runs__enrollment_end', null=True) availability = indexes.CharField(model_attr='course_runs__availability') @@ -155,12 +157,23 @@ class CourseIndex(BaseCourseIndex, indexes.Indexable): expected_learning_items = indexes.MultiValueField() prerequisites = indexes.MultiValueField(faceted=True) + languages = indexes.MultiValueField() + seat_types = indexes.MultiValueField() + + def read_queryset(self, using=None): + # Pre-fetch all fields required by the CourseSearchSerializer. Unfortunately, there's + # no way to specify at query time which queryset to use during loading in order to customize + # it for the serializer being used + qset = super(CourseIndex, self).read_queryset(using=using) + return qset.prefetch_related( + 'course_runs__seats__type' + ) def prepare_aggregation_key(self, obj): return 'course:{}'.format(obj.key) def prepare_course_runs(self, obj): - return [course_run.key for course_run in obj.course_runs.all()] + return [course_run.key for course_run in filter_visible_runs(obj.course_runs)] def prepare_expected_learning_items(self, obj): return [item.value for item in obj.expected_learning_items.all()] @@ -169,7 +182,7 @@ def prepare_prerequisites(self, obj): return [prerequisite.name for prerequisite in obj.prerequisites.all()] def prepare_org(self, obj): - course_run = obj.course_runs.all().first() + course_run = filter_visible_runs(obj.course_runs).first() if course_run: return CourseKey.from_string(course_run.key).org return None @@ -177,9 +190,19 @@ def prepare_org(self, obj): def prepare_first_enrollable_paid_seat_price(self, obj): return obj.first_enrollable_paid_seat_price + def prepare_seat_types(self, obj): + seat_types = [seat.slug for run in filter_visible_runs(obj.course_runs) for seat in run.seat_types] + return list(set(seat_types)) + def prepare_subject_uuids(self, obj): return [str(subject.uuid) for subject in obj.subjects.all()] + def prepare_languages(self, obj): + return { + self._prepare_language(course_run.language) for course_run in filter_visible_runs(obj.course_runs) + if course_run.language + } + class CourseRunIndex(BaseCourseIndex, indexes.Indexable): model = CourseRun @@ -190,8 +213,10 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): status = indexes.CharField(model_attr='status', faceted=True) start = indexes.DateTimeField(model_attr='start', null=True, faceted=True) end = indexes.DateTimeField(model_attr='end', null=True) + go_live_date = indexes.DateTimeField(model_attr='go_live_date', null=True) enrollment_start = indexes.DateTimeField(model_attr='enrollment_start', null=True) enrollment_end = indexes.DateTimeField(model_attr='enrollment_end', null=True) + availability = indexes.CharField(model_attr='availability') announcement = indexes.DateTimeField(model_attr='announcement', null=True) min_effort = indexes.IntegerField(model_attr='min_effort', null=True) max_effort = indexes.IntegerField(model_attr='max_effort', null=True) @@ -201,8 +226,8 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): pacing_type = indexes.CharField(model_attr='pacing_type', null=True, faceted=True) marketing_url = indexes.CharField(null=True) slug = indexes.CharField(model_attr='slug', null=True) - seat_types = indexes.MultiValueField(model_attr='seat_types', null=True, faceted=True) - type = indexes.CharField(model_attr='type', null=True, faceted=True) + seat_types = indexes.MultiValueField(model_attr='seat_types__slug', null=True, faceted=True) + type = indexes.CharField(model_attr='type_legacy', null=True, faceted=True) image_url = indexes.CharField(model_attr='image_url', null=True) partner = indexes.CharField(null=True, faceted=True) program_types = indexes.MultiValueField() @@ -219,6 +244,35 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): license = indexes.MultiValueField(model_attr='license', faceted=True) has_enrollable_seats = indexes.BooleanField(model_attr='has_enrollable_seats', null=False) is_current_and_still_upgradeable = indexes.BooleanField(null=False) + title_override = indexes.CharField(indexed=False, stored=True, null=True) + featured = indexes.BooleanField(model_attr='featured', null=True) + is_marketing_price_set = indexes.BooleanField(model_attr='is_marketing_price_set', null=True) + marketing_price_value = indexes.CharField(model_attr='marketing_price_value', null=True) + is_marketing_price_hidden = indexes.BooleanField(model_attr='is_marketing_price_hidden', null=True) + card_image_url = indexes.CharField(model_attr='card_image_url', null=True) + average_rating = indexes.DecimalField(model_attr='average_rating', null=True) + total_raters = indexes.IntegerField(model_attr='total_raters', null=True) + yt_video_url = indexes.CharField(model_attr='yt_video_url', null=True) + course_duration_override = indexes.IntegerField(model_attr='course_duration_override', null=True) + course_training_packages = indexes.CharField(model_attr='course_training_packages', null=True) + course_department = indexes.CharField(model_attr='course_department', null=True) + course_certifications = indexes.CharField(model_attr='course_certifications', null=True) + course_format = indexes.CharField(model_attr='course_format', null=True) + course_difficulty_level = indexes.CharField(model_attr='course_difficulty_level', null=True) + course_language = indexes.CharField(model_attr='course_language', null=True) + + def read_queryset(self, using=None): + # Pre-fetch all fields required by the CourseRunSearchSerializer. Unfortunately, there's + # no way to specify at query time which queryset to use during loading in order to customize + # it for the serializer being used + qset = super(CourseRunIndex, self).read_queryset(using=using) + + return qset.prefetch_related( + 'seats__type', + ) + + def index_queryset(self, using=None): + return super().index_queryset(using=using) def prepare_aggregation_key(self, obj): # Aggregate CourseRuns by Course key since that is how we plan to dedup CourseRuns on the marketing site. @@ -271,6 +325,9 @@ def prepare_staff_uuids(self, obj): def prepare_subject_uuids(self, obj): return [str(subject.uuid) for subject in obj.subjects.all()] + def prepare_title_override(self, obj): + return obj.title_override.title() + class ProgramIndex(BaseIndex, indexes.Indexable, OrganizationsMixin): model = Program @@ -279,7 +336,7 @@ class ProgramIndex(BaseIndex, indexes.Indexable, OrganizationsMixin): title = indexes.CharField(model_attr='title', boost=TITLE_FIELD_BOOST) title_autocomplete = indexes.NgramField(model_attr='title', boost=TITLE_FIELD_BOOST) subtitle = indexes.CharField(model_attr='subtitle') - type = indexes.CharField(model_attr='type__name', faceted=True) + type = indexes.CharField(model_attr='type__name_t', faceted=True) marketing_url = indexes.CharField(null=True) search_card_display = indexes.MultiValueField() organizations = indexes.MultiValueField(faceted=True) @@ -294,7 +351,7 @@ class ProgramIndex(BaseIndex, indexes.Indexable, OrganizationsMixin): status = indexes.CharField(model_attr='status', faceted=True) partner = indexes.CharField(model_attr='partner__short_code', null=True, faceted=True) start = indexes.DateTimeField(model_attr='start', null=True, faceted=True) - seat_types = indexes.MultiValueField(model_attr='seat_types', null=True, faceted=True) + seat_types = indexes.MultiValueField(model_attr='seat_types__slug', null=True, faceted=True) published = indexes.BooleanField(null=False, faceted=True) min_hours_effort_per_week = indexes.IntegerField(model_attr='min_hours_effort_per_week', null=True) max_hours_effort_per_week = indexes.IntegerField(model_attr='max_hours_effort_per_week', null=True) @@ -319,7 +376,7 @@ def prepare_subject_uuids(self, obj): return [str(subject.uuid) for subject in obj.subjects] def prepare_staff_uuids(self, obj): - return set([str(staff.uuid) for course_run in obj.course_runs for staff in course_run.staff.all()]) + return {str(staff.uuid) for course_run in obj.course_runs for staff in course_run.staff.all()} def prepare_credit_backing_organizations(self, obj): return self._prepare_organizations(obj.credit_backing_organizations.all()) @@ -343,16 +400,27 @@ class PersonIndex(BaseIndex, indexes.Indexable): model = Person uuid = indexes.CharField(model_attr='uuid') salutation = indexes.CharField(model_attr='salutation', null=True) - full_name = indexes.CharField(model_attr='full_name') + full_name = indexes.CharField(model_attr='full_name', stored=False, indexed=False) partner = indexes.CharField(null=True) bio = indexes.CharField(model_attr='bio', null=True) bio_language = indexes.CharField(model_attr='bio_language', null=True) get_profile_image_url = indexes.CharField(model_attr='get_profile_image_url', null=True) position = indexes.MultiValueField() + organizations = indexes.MultiValueField(faceted=True) + marketing_id = indexes.IntegerField(model_attr='marketing_id', null=True) + marketing_url = indexes.CharField(model_attr='marketing_url', null=True) + designation = indexes.CharField(model_attr='designation', null=True) + created = indexes.DateTimeField(model_attr='created') def prepare_aggregation_key(self, obj): return 'person:{}'.format(obj.uuid) + def prepare_organizations(self, obj): + course_runs = obj.courses_staffed.all() + all_organizations = [course_run.course.authoring_organizations.all() for course_run in course_runs] + formatted_organizations = [org.key for orgs in all_organizations for org in orgs] + return formatted_organizations + def prepare_position(self, obj): try: position = Position.objects.get(person=obj) @@ -363,4 +431,4 @@ def prepare_position(self, obj): def prepare_bio_language(self, obj): if obj.bio_language: return obj.bio_language.name - return + return None diff --git a/course_discovery/apps/course_metadata/signals.py b/course_discovery/apps/course_metadata/signals.py index eca59e9ab8..3807debd2e 100644 --- a/course_discovery/apps/course_metadata/signals.py +++ b/course_discovery/apps/course_metadata/signals.py @@ -1,11 +1,25 @@ +import logging + import waffle from django.apps import apps -from django.db.models.signals import post_delete, post_save, pre_delete +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete, pre_save from django.dispatch import receiver from course_discovery.apps.api.cache import api_change_receiver -from course_discovery.apps.course_metadata.models import Program +from course_discovery.apps.core.models import Partner +from course_discovery.apps.course_metadata.constants import MASTERS_PROGRAM_TYPE_SLUG +from course_discovery.apps.course_metadata.models import ( + Course, CourseRun, Curriculum, CurriculumCourseMembership, CurriculumProgramMembership, Organization, Program +) from course_discovery.apps.course_metadata.publishers import ProgramMarketingSitePublisher +from course_discovery.apps.course_metadata.salesforce import ( + populate_official_with_existing_draft, requires_salesforce_update +) +from course_discovery.apps.course_metadata.utils import get_salesforce_util + +logger = logging.getLogger(__name__) @receiver(pre_delete, sender=Program) @@ -20,6 +34,62 @@ def delete_program(sender, instance, **kwargs): # pylint: disable=unused-argume publisher.delete_obj(instance) +def is_program_masters(program): + return program and program.type.slug == MASTERS_PROGRAM_TYPE_SLUG + + +@receiver(pre_save, sender=Curriculum) +def check_curriculum_for_cycles(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Check for circular references in program structure before saving. + Short circuits on: + - newly created Curriculum since it cannot have member programs yet + - Curriculum with a 'None' program since there cannot be a loop + """ + curriculum = instance + if not curriculum.id or not curriculum.program: + return + + if _find_in_programs(curriculum.program_curriculum.all(), target_program=curriculum.program): + raise ValidationError('Circular ref error. Curriculum already contains program {}'.format(curriculum.program)) + + +@receiver(pre_save, sender=CurriculumProgramMembership) +def check_curriculum_program_membership_for_cycles(sender, instance, **kwargs): + """ + Check for circular references in program structure before saving. + """ + curriculum = instance.curriculum + program = instance.program + if _find_in_programs([program], target_curriculum=curriculum): + msg = 'Circular ref error. Program [{}] already contains Curriculum [{}]'.format( + program, + curriculum, + ) + raise ValidationError(msg) + + +def _find_in_programs(existing_programs, target_curriculum=None, target_program=None): + """ + Travese the stucture of a given list of programs for a target curriculm or program node. + Returns True if an instance is found + """ + if target_curriculum is None and target_program is None: + raise TypeError('_find_in_programs takes at least one of (target_curriculum, target_program)') + + if not existing_programs: + return False + if target_program in existing_programs: + return True + + curricula = Curriculum.objects.filter(program__in=existing_programs).prefetch_related('program_curriculum') + if target_curriculum in curricula: + return True + + child_programs = [program for curriculum in curricula for program in curriculum.program_curriculum.all()] + return _find_in_programs(child_programs, target_curriculum=target_curriculum, target_program=target_program) + + # Invalidate API cache when any model in the course_metadata app is saved or # deleted. Given how interconnected our data is and how infrequently our models # change (data loading aside), this is a clean and simple way to ensure correctness @@ -27,3 +97,215 @@ def delete_program(sender, instance, **kwargs): # pylint: disable=unused-argume for model in apps.get_app_config('course_metadata').get_models(): for signal in (post_save, post_delete): signal.connect(api_change_receiver, sender=model) + + +@receiver(pre_save, sender=CourseRun) +def ensure_external_key_uniqueness__course_run(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Pre-save hook to validate that when a course run is saved, that its external_key is + unique. + + If the course is associated with a program through a Curriculum, we will verify that + the external course key is unique across all programs it is assocaited with. + + If the course is not associated with a program, we will still verify that the external_key + is unique within course runs in the course + """ + if not instance.external_key: + return + # This is for the intermediate time between the official course run being created through + # utils.py set_official_state and before the Course reference is updated to the official course. + # See course_metadata/models.py under the CourseRun model inside of the update_or_create_official_version + # function for when the official run is created and when several lines later, the official course + # is added to it. + if not instance.draft and instance.course.draft: + return + if instance.id: + old_course_run = CourseRun.everything.get(pk=instance.pk) + if instance.external_key == old_course_run.external_key and instance.course == old_course_run.course: + return + + course = instance.course + curricula = course.degree_course_curricula.select_related('program').all() + if not curricula: + check_course_runs_within_course_for_duplicate_external_key(course, instance) + else: + check_curricula_and_related_programs_for_duplicate_external_key(curricula, [instance]) + + +@receiver(pre_save, sender=CurriculumCourseMembership) +def ensure_external_key_uniqueness__curriculum_course_membership(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Pre-save hook to validate that if a curriculum_course_membership is created or modified, the + external_keys for the course are unique within the linked curriculum/program + """ + course_runs = instance.course.course_runs.filter(external_key__isnull=False) + check_curricula_and_related_programs_for_duplicate_external_key([instance.curriculum], course_runs) + + +@receiver(pre_save, sender=Curriculum) +def ensure_external_key_uniqueness__curriculum(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Pre-save hook to validate that if a curriculum is created or becomes associated with a different + program, the curriculum's external_keys are/remain unique + """ + if not instance.id: + return # If not instance.id, we can't access course_curriculum, so we can't do anything + if instance.program: + old_curriculum = Curriculum.objects.get(pk=instance.pk) + if old_curriculum.program and instance.program.id == old_curriculum.program.id: + return + + course_runs = CourseRun.objects.filter( + course__degree_course_curricula=instance, + external_key__isnull=False + ).iterator() + check_curricula_and_related_programs_for_duplicate_external_key([instance], course_runs) + + +@receiver(post_save, sender=Organization) +def update_or_create_salesforce_organization(instance, created, **kwargs): # pylint: disable=unused-argument + partner = instance.partner + util = get_salesforce_util(partner) + if util: + if not instance.salesforce_id: + util.create_publisher_organization(instance) + if not created and requires_salesforce_update('organization', instance): + util.update_publisher_organization(instance) + + +@receiver(post_save, sender=Course) +def update_or_create_salesforce_course(instance, created, **kwargs): # pylint: disable=unused-argument + partner = instance.partner + util = get_salesforce_util(partner) + # Only bother to create the course if there's a util, and the auth orgs are already set up + if util and instance.authoring_organizations.first(): + if not created and not instance.draft: + created_in_salesforce = False + # Only populate the Official instance if the draft information is ready to go (has auth orgs set) + if (not instance.salesforce_id and + instance.draft_version and + instance.draft_version.authoring_organizations.first()): + created_in_salesforce = populate_official_with_existing_draft(instance, util) + if not created_in_salesforce and requires_salesforce_update('course', instance): + util.update_course(instance) + + +@receiver(m2m_changed, sender=Course.authoring_organizations.through) +def authoring_organizations_changed(sender, instance, action, **kwargs): # pylint: disable=unused-argument + # Only do this after an auth org has been added, the salesforce_id isn't set and it's a draft (new) + if action == 'post_add' and not instance.salesforce_id and instance.draft: + partner = instance.partner + util = get_salesforce_util(partner) + if util: + util.create_course(instance) + + +@receiver(post_save, sender=CourseRun) +def update_or_create_salesforce_course_run(instance, created, **kwargs): # pylint: disable=unused-argument + try: + partner = instance.course.partner + except (Course.DoesNotExist, Partner.DoesNotExist): + # exit early in the unusual event that we can't look up the appropriate partner + return + util = get_salesforce_util(partner) + if util: + if instance.draft: + util.create_course_run(instance) + elif not created and not instance.draft: + created_in_salesforce = False + if not instance.salesforce_id and instance.draft_version: + created_in_salesforce = populate_official_with_existing_draft(instance, util) + if not created_in_salesforce and requires_salesforce_update('course_run', instance): + util.update_course_run(instance) + + +def _build_external_key_sets(course_runs): + """ + Helper function to extract two sets of ids from a list of course runs for use in filtering + However, the external_keys with null or empty string values are not included in the + returned external_key_set. + + Parameters: + - course runs: a collection of course runs + Returns: + - external_key_set: a set of all external_keys in `course_runs` + - course_run_ids: a set of all ids in `course_runs` + """ + external_key_set = set() + course_run_ids = set() + for course_run in course_runs: + if course_run.external_key: + external_key_set.add(course_run.external_key) + if course_run.id: + course_run_ids.add(course_run.id) + + return external_key_set, course_run_ids + + +def _duplicate_external_key_message(course_runs): + message = 'Duplicate external_key{} found: '.format('s' if len(course_runs) > 1 else '') + for course_run in course_runs: + message += ' [ external_key={} course_run={} course={} ]'.format( + course_run.external_key, + course_run, + course_run.course + ) + return message + + +def check_curricula_and_related_programs_for_duplicate_external_key(curricula, course_runs): + """ + Helper function for verifying the uniqueness of external course keys within a collection + of curricula. + + Parameters: + - curricula: The curricula in which we are searching for duplicate external course keys + - course runs: The course runs whose external course keys of which we are looking for duplicates + + Raises: + If a course run is found under a curriculum in `curriculums` or under a program associated with + a curriculum in `curricula`, a ValidationError is raised + """ + external_key_set, course_run_ids = _build_external_key_sets(course_runs) + programs = set() + programless_curricula = set() + for curriculum in curricula: + if curriculum.program: + programs.add(curriculum.program) + else: + programless_curricula.add(curriculum) + + # Get the first course run in the curricula or programs that have a duplicate external key + # but aren't the course runs we're given + course_runs = CourseRun.objects.filter( + ~Q(id__in=course_run_ids), + Q(external_key__in=external_key_set), + ( + Q(course__degree_course_curricula__program__in=programs) | + Q(course__degree_course_curricula__in=programless_curricula) + ), + ).select_related('course').distinct().all() + if course_runs: + message = _duplicate_external_key_message(course_runs) + raise ValidationError(message) + + +def check_course_runs_within_course_for_duplicate_external_key(course, specific_course_run): + """ + Helper function for verifying the uniqueness of external course keys within a course + + Parameters: + - course: course in which we are searching for potential duplicate course keys + - specific_course_run: The course run that we are looking for a duplicate of + + Raises: + If a course run is found under `course` that has the same external + course key as `specific_course_run` (but isn't `specific_course_run`), + this function will raise a ValidationError + """ + for course_run in course.course_runs.all(): + external_key = course_run.external_key + if external_key == specific_course_run.external_key and course_run != specific_course_run: + message = _duplicate_external_key_message([course_run]) + raise ValidationError(message) diff --git a/course_discovery/apps/course_metadata/templates/admin/course_metadata/change_form.html b/course_discovery/apps/course_metadata/templates/admin/course_metadata/change_form.html deleted file mode 100644 index df15a58052..0000000000 --- a/course_discovery/apps/course_metadata/templates/admin/course_metadata/change_form.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "admin/change_form.html" %} -{% block extrahead %} - {{ block.super }} - -{% endblock %} diff --git a/course_discovery/apps/course_metadata/templates/admin/course_metadata/course_run.html b/course_discovery/apps/course_metadata/templates/admin/course_metadata/course_run.html index 3bab7cc411..18a21451a9 100644 --- a/course_discovery/apps/course_metadata/templates/admin/course_metadata/course_run.html +++ b/course_discovery/apps/course_metadata/templates/admin/course_metadata/course_run.html @@ -21,12 +21,12 @@ {% block submit_buttons_bottom %} {% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/comment.html b/course_discovery/apps/course_metadata/templates/course_metadata/email/comment.html similarity index 59% rename from course_discovery/apps/publisher/templates/publisher/email/comment.html rename to course_discovery/apps/course_metadata/templates/course_metadata/email/comment.html index 99c2778166..f8dedfb919 100644 --- a/course_discovery/apps/publisher/templates/publisher/email/comment.html +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/comment.html @@ -1,19 +1,23 @@ -{% extends "publisher/email/email_base.html" %} +{% extends "course_metadata/email/email_base.html" %} {% load i18n %} {% block body %}

+ {% filter force_escape %} {% blocktrans with date=comment_date|date:'m/d/y' time=comment_date.time trimmed %} {{ user_name }} made the following comment on {{ course_name }} on {{ date }} at {{ time }} {% endblocktrans %} + {% endfilter %}

- "{{ comment_message }}" + {% filter force_escape %} + "{{ comment_message }}" + {% endfilter %}

- {% trans "View comment in Publisher" %} + {% trans "View comment in Publisher" as tmsg %} {{ tmsg | force_escape }}

-

{% trans "The edX team" %}

+

{% trans "The edX team" as tmsg %}{{ tmsg | force_escape }}

{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/comment.txt b/course_discovery/apps/course_metadata/templates/course_metadata/email/comment.txt similarity index 74% rename from course_discovery/apps/publisher/templates/publisher/email/comment.txt rename to course_discovery/apps/course_metadata/templates/course_metadata/email/comment.txt index 7ccec393ee..f7239b7cb6 100644 --- a/course_discovery/apps/publisher/templates/publisher/email/comment.txt +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/comment.txt @@ -1,11 +1,11 @@ {% load i18n %} {% blocktrans with date=comment_date|date:'m/d/y' time=comment_date.time trimmed %} -{{ user_name }} made the following comment on {{ course_name }} {{ date }} at {{ time }}. +{{ user_name }} made the following comment on {{ course_name }} {{ date }} at {{ time }} {% endblocktrans %} {{ comment_message }} -{% trans "View comment in Publisher" %}{{ page_url }} +{% trans "View comment in Publisher" %} {{ page_url }} {% trans "The edX team" %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/email_base.html b/course_discovery/apps/course_metadata/templates/course_metadata/email/email_base.html similarity index 100% rename from course_discovery/apps/publisher/templates/publisher/email/email_base.html rename to course_discovery/apps/course_metadata/templates/course_metadata/email/email_base.html diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/go_live.html b/course_discovery/apps/course_metadata/templates/course_metadata/email/go_live.html new file mode 100644 index 0000000000..d003596842 --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/go_live.html @@ -0,0 +1,39 @@ +{% extends "course_metadata/email/email_base.html" %} +{% load i18n %} +{% load django_markup %} +{% block body %} + + +

+ {% filter force_escape %} + {% blocktrans trimmed %} + Dear {{ recipient_name }}, + {% endblocktrans %} + {% endfilter %} +

+ +

+ {% filter force_escape %} + {% blocktrans trimmed %} + The About page for the {{ course_run_number }} course run of {{ course_name }} has been published. No further action is necessary. + {% endblocktrans %} + {% endfilter %} +

+ +

+ {% blocktrans trimmed asvar tmsg %} + {link_start}{preview_url}{link_middle}View this About page.{link_end} + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe preview_url=preview_url|safe %} +

+ +

+ {% blocktrans trimmed asvar tmsg %} + Note: This email address is unable to receive replies. For questions or comments, please contact {link_start}{contact_us_email}{link_middle}your Project Coordinator{link_end}. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe contact_us_email=contact_us_email|safe %} +

+ + +{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/published.txt b/course_discovery/apps/course_metadata/templates/course_metadata/email/go_live.txt similarity index 71% rename from course_discovery/apps/publisher/templates/publisher/email/course_run/published.txt rename to course_discovery/apps/course_metadata/templates/course_metadata/email/go_live.txt index 82cb3f7c51..ca1cdbf541 100644 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/published.txt +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/go_live.txt @@ -3,18 +3,15 @@ {% blocktrans trimmed %} Dear {{ recipient_name }}, {% endblocktrans %} + {% blocktrans trimmed %} The About page for the {{ course_run_number }} course run of {{ course_name }} has been published. No further action is necessary. {% endblocktrans %} {% blocktrans trimmed %} - View this About page on edx.org. {{ preview_url }} + View this About page. {{ preview_url }} {% endblocktrans %} - -{% trans "Thanks," %} -{{ platform_name}} {{ sender_role }} - {% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. + Note: This email address is unable to receive replies. For questions or comments, please contact your Project Coordinator at {{ contact_us_email }}. {% endblocktrans %} diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/internal_review.html b/course_discovery/apps/course_metadata/templates/course_metadata/email/internal_review.html new file mode 100644 index 0000000000..f03b825010 --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/internal_review.html @@ -0,0 +1,34 @@ +{% extends "course_metadata/email/email_base.html" %} +{% load i18n %} +{% load django_markup %} +{% block body %} + + +

+ {% filter force_escape %} + {% blocktrans trimmed %} + Dear {{ recipient_name }}, + {% endblocktrans %} + {% endfilter %} +

+ {% blocktrans trimmed asvar tmsg %} + {org_name} has submitted {course_key} for review. {link_start}{course_page_url}{link_middle}View this course run in Publisher{link_end} to review the changes and mark it as reviewed. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe course_key=course_key|safe org_name=org_name|safe course_page_url=course_page_url|safe %} +

+

+ {% blocktrans trimmed asvar tmsg %} + This is a good time to {link_start}{studio_url}{link_middle}review this course run in Studio{link_end}. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe studio_url=studio_url|safe %} +

+{% if restricted_admin_url %} +

+ {% blocktrans trimmed asvar tmsg %} + Visit the {link_start}{restricted_admin_url}{link_middle}restricted course admin page{link_end} to set embargo rules for this course, as needed. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe restricted_admin_url=restricted_admin_url|safe %} +

+{% endif %} + +{% endblock body %} diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/internal_review.txt b/course_discovery/apps/course_metadata/templates/course_metadata/email/internal_review.txt new file mode 100644 index 0000000000..0720061be7 --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/internal_review.txt @@ -0,0 +1,22 @@ +{% load i18n %} + +{% blocktrans trimmed %} + Dear {{ recipient_name }}, +{% endblocktrans %} +{% blocktrans trimmed %} + {{ org_name }} has submitted {{ course_key }} for review. +{% endblocktrans %} + +{% blocktrans trimmed %} + Publisher page: {{ course_page_url }} +{% endblocktrans %} + +{% blocktrans trimmed %} + Studio page: {{ studio_url }} +{% endblocktrans %} + +{% if restricted_admin_url %} +{% blocktrans trimmed %} + Restricted Course admin: {{ restricted_admin_url }} +{% endblocktrans %} +{% endif %} \ No newline at end of file diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/legal_review.html b/course_discovery/apps/course_metadata/templates/course_metadata/email/legal_review.html new file mode 100644 index 0000000000..6622229e8a --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/legal_review.html @@ -0,0 +1,28 @@ +{% extends "course_metadata/email/email_base.html" %} +{% load i18n %} +{% load django_markup %} +{% block body %} + + +

+ {% filter force_escape %} + {% blocktrans trimmed %} + Dear {{ recipient_name }}, + {% endblocktrans %} + {% endfilter %} +

+

+ {% blocktrans trimmed asvar tmsg %} + {org_name} has submitted {course_name} for review. {link_start}{course_page_url}{link_middle}View this course run in Publisher{link_end} to determine OFAC status. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe org_name=org_name|safe course_name=course_name|safe course_page_url=course_page_url|safe %} +

+

+ {% blocktrans trimmed asvar tmsg %} + Note: This email address is unable to receive replies. For questions or comments, please contact {link_start}{contact_us_email}{link_middle}the Project Coordinator{link_end}. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe contact_us_email=contact_us_email|safe %} +

+ + +{% endblock body %} diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/legal_review.txt b/course_discovery/apps/course_metadata/templates/course_metadata/email/legal_review.txt new file mode 100644 index 0000000000..edd4e38aad --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/legal_review.txt @@ -0,0 +1,17 @@ +{% load i18n %} + +{% blocktrans trimmed %} + Dear {{ recipient_name }}, +{% endblocktrans %} +{% blocktrans trimmed %} + {{ org_name }} has submitted {{ course_name }} for review. +{% endblocktrans %} + +{{ course_page_url }} +{% blocktrans trimmed %} + View this course run in Publisher above to determine OFAC status. +{% endblocktrans %} + +{% blocktrans trimmed %} + Note: This email address is unable to receive replies. For questions or comments, please contact the Project Coordinator at {{ contact_us_email }}. +{% endblocktrans %} diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/reviewed.html b/course_discovery/apps/course_metadata/templates/course_metadata/email/reviewed.html new file mode 100644 index 0000000000..c88f3d369a --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/reviewed.html @@ -0,0 +1,43 @@ +{% extends "course_metadata/email/email_base.html" %} +{% load i18n %} +{% load django_markup %} +{% block body %} + + +

+ {% filter force_escape %} + {% blocktrans trimmed %} + Dear {{ recipient_name }}, + {% endblocktrans %} + {% endfilter %} +

+

+ {% blocktrans trimmed asvar tmsg %} + The {link_start}{course_page_url}{link_middle}{course_run_number} course run{link_end} of {course_name} has been reviewed and approved by {platform_name}. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe course_run_number=course_run_number|safe course_name=course_name|safe course_page_url=course_page_url|safe platform_name=platform_name %} + + {% if go_live_date %} + {% filter force_escape %} + {% blocktrans trimmed %} + The course run about page will be published on {{ go_live_date }}, pending no further edits. + {% endblocktrans %} + {% endfilter %} + {% else %} + {% filter force_escape %} + {% blocktrans trimmed %} + The course run about page is now published. + {% endblocktrans %} + {% endfilter %} + {% endif %} +

+ +

+ {% blocktrans trimmed asvar tmsg %} + Note: This email address is unable to receive replies. For questions or comments, please contact {link_start}{contact_us_email}{link_middle}your Project Coordinator{link_end}. + {% endblocktrans %} + {% interpolate_html tmsg link_start=''|safe link_end=''|safe contact_us_email=contact_us_email|safe %} +

+ + +{% endblock body %} diff --git a/course_discovery/apps/course_metadata/templates/course_metadata/email/reviewed.txt b/course_discovery/apps/course_metadata/templates/course_metadata/email/reviewed.txt new file mode 100644 index 0000000000..bcda109e34 --- /dev/null +++ b/course_discovery/apps/course_metadata/templates/course_metadata/email/reviewed.txt @@ -0,0 +1,27 @@ +{% load i18n %} + +{% blocktrans trimmed %} + Dear {{ recipient_name }}, +{% endblocktrans %} +{% blocktrans trimmed %} + The {{ course_run_number }} course run of {{ course_name }} has been reviewed and approved by {{ platform_name }}. +{% endblocktrans %} + + +{% if go_live_date %} +{% blocktrans trimmed %} + The course run about page will be published on {{ go_live_date }}, pending no further edits. +{% endblocktrans %} +{% else %} +{% blocktrans trimmed %} + The course run about page is now published. +{% endblocktrans %} +{% endif %} + +{% blocktrans trimmed %} + View the course run in Publisher: {{ course_page_url }} +{% endblocktrans %} + +{% blocktrans trimmed %} + Note: This email address is unable to receive replies. For questions or comments, please contact your Project Coordinator at {{ contact_us_email }}. +{% endblocktrans %} diff --git a/course_discovery/apps/course_metadata/tests/__init__.py b/course_discovery/apps/course_metadata/tests/__init__.py index 4307bff4a7..e69de29bb2 100644 --- a/course_discovery/apps/course_metadata/tests/__init__.py +++ b/course_discovery/apps/course_metadata/tests/__init__.py @@ -1,21 +0,0 @@ -from waffle.models import Switch - - -def toggle_switch(name, active=True): - """ - Activate or deactivate a feature switch. The switch is created if it does not exist. - - Arguments: - name (str): name of the switch to be toggled. - - Keyword Arguments: - active (bool): Whether the switch should be on or off. - - Returns: - Switch: Waffle Switch - """ - switch, __ = Switch.objects.get_or_create(name=name, defaults={'active': active}) - switch.active = active - switch.save() - - return switch diff --git a/course_discovery/apps/course_metadata/tests/factories.py b/course_discovery/apps/course_metadata/tests/factories.py index 3fbdd99a35..616caa692f 100644 --- a/course_discovery/apps/course_metadata/tests/factories.py +++ b/course_discovery/apps/course_metadata/tests/factories.py @@ -1,30 +1,28 @@ - from datetime import datetime import factory +from django.db.models.signals import post_save from factory.fuzzy import FuzzyChoice, FuzzyDateTime, FuzzyDecimal, FuzzyInteger, FuzzyText from pytz import UTC -from course_discovery.apps.core.tests.factories import PartnerFactory, add_m2m_data +from course_discovery.apps.core.tests.factories import PartnerFactory, UserFactory, add_m2m_data from course_discovery.apps.core.tests.utils import FuzzyURL -from course_discovery.apps.course_metadata.constants import PathwayType from course_discovery.apps.course_metadata.models import * # pylint: disable=wildcard-import from course_discovery.apps.ietf_language_tags.models import LanguageTag - # pylint: disable=unused-argument -class AbstractMediaModelFactory(factory.DjangoModelFactory): +class AbstractMediaModelFactory(factory.django.DjangoModelFactory): src = FuzzyURL() description = FuzzyText() -class AbstractNamedModelFactory(factory.DjangoModelFactory): +class AbstractNamedModelFactory(factory.django.DjangoModelFactory): name = FuzzyText() -class AbstractTitleDescriptionFactory(factory.DjangoModelFactory): +class AbstractTitleDescriptionFactory(factory.django.DjangoModelFactory): title = FuzzyText(length=255) description = FuzzyText() @@ -44,7 +42,7 @@ class Meta: model = Video -class SubjectFactory(factory.DjangoModelFactory): +class SubjectFactory(factory.django.DjangoModelFactory): class Meta: model = Subject @@ -56,7 +54,7 @@ class Meta: uuid = factory.LazyFunction(uuid4) -class TopicFactory(factory.DjangoModelFactory): +class TopicFactory(factory.django.DjangoModelFactory): class Meta: model = Topic @@ -69,6 +67,8 @@ class Meta: class LevelTypeFactory(AbstractNamedModelFactory): + name_t = FuzzyText() + class Meta: model = LevelType @@ -83,9 +83,102 @@ class Meta: model = AdditionalPromoArea -class CourseFactory(factory.DjangoModelFactory): +class SalesforceRecordFactory(factory.django.DjangoModelFactory): + @classmethod + def _create(cls, model_class, *args, **kwargs): + from course_discovery.apps.course_metadata.tests.utils import build_salesforce_exception # pylint: disable=import-outside-toplevel + try: + return super()._create(model_class, *args, **kwargs) + except requests.ConnectionError: + # raise user friendly suggestion to use factory with muted signals + raise ConnectionError(build_salesforce_exception(model_class.__name__)) + + +class SeatTypeFactory(factory.django.DjangoModelFactory): + name = FuzzyText() + + class Meta: + model = SeatType + + @staticmethod + def audit(): + return SeatType.objects.get(slug=Seat.AUDIT) + + @staticmethod + def credit(): + return SeatType.objects.get(slug=Seat.CREDIT) + + @classmethod + def honor(cls): + return SeatType.objects.get_or_create(name=Seat.HONOR.capitalize())[0] # name will create slug + + @classmethod + def masters(cls): + return SeatType.objects.get_or_create(name=Seat.MASTERS.capitalize())[0] # name will create slug + + @staticmethod + def professional(): + return SeatType.objects.get(slug=Seat.PROFESSIONAL) + + @staticmethod + def verified(): + return SeatType.objects.get(slug=Seat.VERIFIED) + + +class ModeFactory(factory.django.DjangoModelFactory): + name = FuzzyText() + slug = FuzzyText() + + class Meta: + model = Mode + + +class TrackFactory(factory.django.DjangoModelFactory): + mode = factory.SubFactory(ModeFactory) + seat_type = factory.SubFactory(SeatTypeFactory) + + class Meta: + model = Track + + +class CourseRunTypeFactory(factory.django.DjangoModelFactory): + uuid = factory.LazyFunction(uuid4) + name = FuzzyText() + slug = FuzzyText() + is_marketable = True + + class Meta: + model = CourseRunType + + @factory.post_generation + def tracks(self, create, extracted, **kwargs): + if create: # pragma: no cover + add_m2m_data(self.tracks, extracted) + + +class CourseTypeFactory(factory.django.DjangoModelFactory): + uuid = factory.LazyFunction(uuid4) + name = FuzzyText() + slug = FuzzyText() + + class Meta: + model = CourseType + + @factory.post_generation + def entitlement_types(self, create, extracted, **kwargs): + if create: # pragma: no cover + add_m2m_data(self.entitlement_types, extracted) + + @factory.post_generation + def course_run_types(self, create, extracted, **kwargs): + if create: # pragma: no cover + add_m2m_data(self.course_run_types, extracted) + + +class CourseFactory(SalesforceRecordFactory): uuid = factory.LazyFunction(uuid4) key = FuzzyText(prefix='course-id/') + key_for_reruns = FuzzyText(prefix='OrgX+') title = FuzzyText(prefix="Test çօմɾʂҽ ") short_description = FuzzyText(prefix="Test çօմɾʂҽ short description") full_description = FuzzyText(prefix="Test çօմɾʂҽ FULL description") @@ -102,6 +195,7 @@ class CourseFactory(factory.DjangoModelFactory): additional_information = FuzzyText() faq = FuzzyText() learner_testimonials = FuzzyText() + type = factory.SubFactory(CourseTypeFactory) class Meta: model = Course @@ -121,24 +215,65 @@ def sponsoring_organizations(self, create, extracted, **kwargs): if create: add_m2m_data(self.sponsoring_organizations, extracted) + @factory.post_generation + def url_slug_history(self, create, extracted, **kwargs): + if create: + data = {'is_active': True, 'is_active_on_draft': True, 'course': self, 'partner': self.partner} + if extracted: + data.update(extracted) + CourseUrlSlugFactory(**data) + + +class CourseUrlSlugFactory(factory.django.DjangoModelFactory): + course = factory.SubFactory(CourseFactory) + partner = factory.SelfAttribute('course.partner') + url_slug = FuzzyText() -class CourseRunFactory(factory.DjangoModelFactory): + class Meta: + model = CourseUrlSlug + + +class CourseUrlRedirectFactory(factory.django.DjangoModelFactory): + course = factory.SubFactory(CourseFactory) + partner = factory.SelfAttribute('course.partner') + value = FuzzyText() + + class Meta: + model = CourseUrlRedirect + + +@factory.django.mute_signals(post_save) +class CourseFactoryNoSignals(CourseFactory): + pass + + +class CourseEditorFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + course = factory.SubFactory(CourseFactory) + + class Meta: + model = CourseEditor + + +class CourseRunFactory(SalesforceRecordFactory): status = CourseRunStatus.Published uuid = factory.LazyFunction(uuid4) key = FuzzyText(prefix='course-run-id/', suffix='/fake') + external_key = None course = factory.SubFactory(CourseFactory) title_override = None short_description_override = None full_description_override = None language = factory.Iterator(LanguageTag.objects.all()) start = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - end = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)).end_dt + end = FuzzyDateTime(datetime.datetime.now(tz=UTC), datetime.datetime(2030, 1, 1, tzinfo=UTC)) + go_live_date = None enrollment_start = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - enrollment_end = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)).end_dt + enrollment_end = FuzzyDateTime(datetime.datetime.now(tz=UTC), datetime.datetime(2029, 1, 1, tzinfo=UTC)) announcement = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) card_image_url = FuzzyURL() video = factory.SubFactory(VideoFactory) - min_effort = FuzzyInteger(1, 10) + min_effort = FuzzyInteger(1, 9) max_effort = FuzzyInteger(10, 20) pacing_type = FuzzyChoice([name for name, __ in CourseRunPacing.choices]) reporting_type = FuzzyChoice([name for name, __ in ReportingType.choices]) @@ -146,6 +281,9 @@ class CourseRunFactory(factory.DjangoModelFactory): weeks_to_complete = FuzzyInteger(1) license = 'all-rights-reserved' has_ofac_restrictions = True + type = factory.SubFactory(CourseRunTypeFactory) + average_rating = FuzzyInteger(1, 5) + total_raters = FuzzyInteger(1, 5) @factory.post_generation def staff(self, create, extracted, **kwargs): @@ -166,8 +304,13 @@ def authoring_organizations(self, create, extracted, **kwargs): add_m2m_data(self.authoring_organizations, extracted) -class SeatFactory(factory.DjangoModelFactory): - type = FuzzyChoice([name for name, __ in Seat.SEAT_TYPE_CHOICES]) +@factory.django.mute_signals(post_save) +class CourseRunFactoryNoSignals(CourseRunFactory): + pass + + +class SeatFactory(factory.django.DjangoModelFactory): + type = factory.SubFactory(SeatTypeFactory) price = FuzzyDecimal(0.0, 650.0) currency = factory.Iterator(Currency.objects.all()) upgrade_deadline = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) @@ -179,23 +322,38 @@ class Meta: model = Seat -class OrganizationFactory(factory.DjangoModelFactory): +class CourseEntitlementFactory(factory.django.DjangoModelFactory): + mode = factory.SubFactory(SeatTypeFactory) + price = FuzzyDecimal(0.0, 650.0) + currency = factory.Iterator(Currency.objects.all()) + sku = FuzzyText(length=8) + course = factory.SubFactory(CourseFactory) + + class Meta: + model = CourseEntitlement + + +class OrganizationFactory(SalesforceRecordFactory): uuid = factory.LazyFunction(uuid4) key = FuzzyText() name = FuzzyText() description = FuzzyText() homepage_url = FuzzyURL() - logo_image_url = FuzzyURL() - banner_image_url = FuzzyURL() - certificate_logo_image_url = FuzzyURL() + logo_image = FuzzyText() + banner_image = FuzzyText() + certificate_logo_image = FuzzyText() partner = factory.SubFactory(PartnerFactory) - marketing_url_path = FuzzyText() class Meta: model = Organization -class PersonFactory(factory.DjangoModelFactory): +@factory.django.mute_signals(post_save) +class OrganizationFactoryNoSignals(OrganizationFactory): + pass + + +class PersonFactory(factory.django.DjangoModelFactory): uuid = factory.LazyFunction(uuid4) partner = factory.SubFactory(PartnerFactory) given_name = factory.Faker('first_name') @@ -209,7 +367,7 @@ class Meta: model = Person -class PositionFactory(factory.DjangoModelFactory): +class PositionFactory(factory.django.DjangoModelFactory): person = factory.SubFactory(PersonFactory) title = FuzzyText() organization = factory.SubFactory(OrganizationFactory) @@ -219,11 +377,14 @@ class Meta: class ProgramTypeFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = ProgramType + uuid = factory.LazyFunction(uuid4) name = FuzzyText() + name_t = FuzzyText() logo_image = FuzzyText(prefix='https://example.com/program/logo') + slug = FuzzyText() @factory.post_generation def applicable_seat_types(self, create, extracted, **kwargs): @@ -232,7 +393,7 @@ def applicable_seat_types(self, create, extracted, **kwargs): class EndorsementFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = Endorsement endorser = factory.SubFactory(PersonFactory) @@ -240,7 +401,7 @@ class Meta(object): class CorporateEndorsementFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = CorporateEndorsement corporation_name = FuzzyText() @@ -254,14 +415,14 @@ def individual_endorsements(self, create, extracted, **kwargs): class JobOutlookItemFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = JobOutlookItem value = FuzzyText() class FAQFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = FAQ question = FuzzyText() @@ -269,14 +430,14 @@ class Meta(object): class ExpectedLearningItemFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = ExpectedLearningItem value = FuzzyText() class RankingFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = Ranking rank = FuzzyText(length=9) @@ -285,16 +446,16 @@ class Meta(object): class ProgramFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = Program title = factory.Sequence(lambda n: 'test-program-{}'.format(n)) # pylint: disable=unnecessary-lambda uuid = factory.LazyFunction(uuid4) subtitle = FuzzyText() + marketing_hook = FuzzyText() type = factory.SubFactory(ProgramTypeFactory) status = ProgramStatus.Active marketing_slug = factory.Sequence(lambda n: 'test-slug-{}'.format(n)) # pylint: disable=unnecessary-lambda - banner_image_url = FuzzyText(prefix='https://example.com/program/banner') card_image_url = FuzzyText(prefix='https://example.com/program/card') partner = factory.SubFactory(PartnerFactory) video = factory.SubFactory(VideoFactory) @@ -357,9 +518,14 @@ def instructor_ordering(self, create, extracted, **kwargs): if create: # pragma: no cover add_m2m_data(self.instructor_ordering, extracted) + @factory.post_generation + def curricula(self, create, extracted, **kwargs): + if create: # pragma: no cover + add_m2m_data(self.curricula, extracted) + class DegreeFactory(ProgramFactory): - class Meta(object): + class Meta: model = Degree apply_url = FuzzyURL() @@ -370,6 +536,7 @@ class Meta(object): micromasters_url = FuzzyText() micromasters_long_title = FuzzyText() micromasters_long_description = FuzzyText() + micromasters_org_name_override = FuzzyText() search_card_ranking = FuzzyText() search_card_cost = FuzzyText() search_card_courses = FuzzyText() @@ -382,7 +549,7 @@ def rankings(self, create, extracted, **kwargs): class IconTextPairingFactory(factory.django.DjangoModelFactory): - class Meta(object): + class Meta: model = IconTextPairing degree = factory.SubFactory(DegreeFactory) @@ -390,14 +557,15 @@ class Meta(object): text = FuzzyText(length=255) -class CurriculumFactory(factory.DjangoModelFactory): - class Meta(object): +class CurriculumFactory(factory.django.DjangoModelFactory): + class Meta: model = Curriculum + name = FuzzyText() uuid = factory.LazyFunction(uuid4) marketing_text_brief = FuzzyText() marketing_text = FuzzyText() - degree = factory.SubFactory(DegreeFactory) + program = factory.SubFactory(ProgramFactory) @factory.post_generation def program_curriculum(self, create, extracted, **kwargs): @@ -410,8 +578,8 @@ def course_curriculum(self, create, extracted, **kwargs): add_m2m_data(self.course_curriculum, extracted) -class DegreeDeadlineFactory(factory.DjangoModelFactory): - class Meta(object): +class DegreeDeadlineFactory(factory.django.DjangoModelFactory): + class Meta: model = DegreeDeadline degree = factory.SubFactory(DegreeFactory) @@ -421,8 +589,8 @@ class Meta(object): time = FuzzyText() -class DegreeCostFactory(factory.DjangoModelFactory): - class Meta(object): +class DegreeCostFactory(factory.django.DjangoModelFactory): + class Meta: model = DegreeCost degree = factory.SubFactory(DegreeFactory) @@ -430,23 +598,36 @@ class Meta(object): amount = FuzzyText() -class DegreeProgramCurriculumFactory(factory.DjangoModelFactory): - class Meta(object): - model = DegreeProgramCurriculum +class CurriculumProgramMembershipFactory(factory.django.DjangoModelFactory): + class Meta: + model = CurriculumProgramMembership program = factory.SubFactory(ProgramFactory) curriculum = factory.SubFactory(CurriculumFactory) -class DegreeCourseCurriculumFactory(factory.DjangoModelFactory): - class Meta(object): - model = DegreeCourseCurriculum +class CurriculumCourseMembershipFactory(factory.django.DjangoModelFactory): + class Meta: + model = CurriculumCourseMembership course = factory.SubFactory(CourseFactory) curriculum = factory.SubFactory(CurriculumFactory) + @factory.post_generation + def course_curriculum(self, create, extracted, **kwargs): + if create: # pragma: no cover + add_m2m_data(self.course_run_exclusions, extracted) + + +class CurriculumCourseRunExclusionFactory(factory.django.DjangoModelFactory): + class Meta: + model = CurriculumCourseRunExclusion + + course_membership = factory.SubFactory(CurriculumCourseMembershipFactory) + course_run = factory.SubFactory(CourseRunFactory) + -class PathwayFactory(factory.DjangoModelFactory): +class PathwayFactory(factory.django.DjangoModelFactory): uuid = factory.LazyFunction(uuid4) partner = factory.SubFactory(PartnerFactory) name = FuzzyText() @@ -460,7 +641,7 @@ class Meta: model = Pathway -class PersonSocialNetworkFactory(factory.DjangoModelFactory): +class PersonSocialNetworkFactory(factory.django.DjangoModelFactory): type = FuzzyChoice(PersonSocialNetwork.SOCIAL_NETWORK_CHOICES.keys()) url = FuzzyText() title = FuzzyText() @@ -470,7 +651,7 @@ class Meta: model = PersonSocialNetwork -class PersonAreaOfExpertiseFactory(factory.DjangoModelFactory): +class PersonAreaOfExpertiseFactory(factory.django.DjangoModelFactory): value = FuzzyText() person = factory.SubFactory(PersonFactory) @@ -478,35 +659,35 @@ class Meta: model = PersonAreaOfExpertise -class SeatTypeFactory(factory.django.DjangoModelFactory): - class Meta(object): - model = SeatType - - name = FuzzyText() - - class SyllabusItemFactory(factory.django.DjangoModelFactory): class Meta: model = SyllabusItem -class CourseEntitlementFactory(factory.DjangoModelFactory): - mode = factory.SubFactory(SeatTypeFactory) - price = FuzzyDecimal(0.0, 650.0) - currency = factory.Iterator(Currency.objects.all()) - sku = FuzzyText(length=8) - expires = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - course = factory.SubFactory(CourseFactory) +class DrupalPublishUuidConfigFactory(factory.django.DjangoModelFactory): + class Meta: + model = DrupalPublishUuidConfig + +class ProfileImageDownloadConfigFactory(factory.django.DjangoModelFactory): class Meta: - model = CourseEntitlement + model = ProfileImageDownloadConfig -class DrupalPublishUuidConfigFactory(factory.DjangoModelFactory): +class MigratePublisherToCourseMetadataConfigFactory(factory.django.DjangoModelFactory): class Meta: - model = DrupalPublishUuidConfig + model = MigratePublisherToCourseMetadataConfig -class ProfileImageDownloadConfigFactory(factory.DjangoModelFactory): +class MigrateCommentsToSalesforceFactory(factory.django.DjangoModelFactory): class Meta: - model = ProfileImageDownloadConfig + model = MigrateCommentsToSalesforce + + +class CollaboratorFactory(factory.django.DjangoModelFactory): + class Meta: + model = Collaborator + + name = FuzzyText() + image = factory.django.ImageField() + uuid = factory.LazyFunction(uuid4) diff --git a/course_discovery/apps/course_metadata/tests/mixins.py b/course_discovery/apps/course_metadata/tests/mixins.py index ded8b9c27a..1eb09a072d 100644 --- a/course_discovery/apps/course_metadata/tests/mixins.py +++ b/course_discovery/apps/course_metadata/tests/mixins.py @@ -27,7 +27,7 @@ def mock_login_response(self, status): root=self.api_root ) - def request_callback(request): # pylint: disable=unused-argument + def request_callback(request): headers = { 'location': response_url } diff --git a/course_discovery/apps/course_metadata/tests/test_admin.py b/course_discovery/apps/course_metadata/tests/test_admin.py index 5e46024005..726bd10fd7 100644 --- a/course_discovery/apps/course_metadata/tests/test_admin.py +++ b/course_discovery/apps/course_metadata/tests/test_admin.py @@ -9,11 +9,13 @@ from django.urls import reverse from selenium import webdriver from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select from selenium.webdriver.support.wait import WebDriverWait from course_discovery.apps.api.tests.mixins import SiteMixin +from course_discovery.apps.api.v1.tests.test_views.mixins import FuzzyInt from course_discovery.apps.core.models import Partner from course_discovery.apps.core.tests.factories import USER_PASSWORD, PartnerFactory, UserFactory from course_discovery.apps.core.tests.helpers import make_image_file @@ -21,7 +23,7 @@ from course_discovery.apps.course_metadata.choices import ProgramStatus from course_discovery.apps.course_metadata.constants import PathwayType from course_discovery.apps.course_metadata.forms import PathwayAdminForm, ProgramAdminForm -from course_discovery.apps.course_metadata.models import Person, Position, Program, ProgramType, Seat, SeatType +from course_discovery.apps.course_metadata.models import Person, Position, Program, ProgramType from course_discovery.apps.course_metadata.tests import factories @@ -30,17 +32,23 @@ class AdminTests(SiteMixin, TestCase): """ Tests Admin page.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = UserFactory(is_staff=True, is_superuser=True) + cls.course_runs = factories.CourseRunFactory.create_batch(3) + cls.courses = [course_run.course for course_run in cls.course_runs] + + cls.excluded_course_run = factories.CourseRunFactory(course=cls.courses[0]) + cls.program = factories.ProgramFactory( + courses=cls.courses, + excluded_course_runs=[cls.excluded_course_run], + partner=cls.partner, # cls.partner provided by SiteMixin.setUpClass() + ) + def setUp(self): - super(AdminTests, self).setUp() - self.user = UserFactory(is_staff=True, is_superuser=True) + super().setUp() self.client.login(username=self.user.username, password=USER_PASSWORD) - self.course_runs = factories.CourseRunFactory.create_batch(3) - self.courses = [course_run.course for course_run in self.course_runs] - - self.excluded_course_run = factories.CourseRunFactory(course=self.courses[0]) - self.program = factories.ProgramFactory( - courses=self.courses, excluded_course_runs=[self.excluded_course_run] - ) def _post_data(self, status=ProgramStatus.Unpublished, marketing_slug='/foo'): return { @@ -112,6 +120,27 @@ def test_page_loads_only_course_related_runs(self): for run in self.course_runs: self.assertContains(response, run.key) + def test_updating_order_of_authoring_orgs(self): + org1 = factories.OrganizationFactory(key='org1') + org2 = factories.OrganizationFactory(key='org2') + org3 = factories.OrganizationFactory(key='org3') + + course = factories.CourseFactory(authoring_organizations=[org1, org2, org3]) + + new_ordering = (',').join(map(lambda org: str(org.id), [org2, org3, org1])) + params = {'authoring_organizations': new_ordering} + + post_url = reverse('admin:course_metadata_course_change', args=(course.id,)) + response = self.client.post(post_url, params) + self.assertEqual(response.status_code, 200) + + html = BeautifulSoup(response.content) + + orgs_dropdown_text = html.find(class_='field-authoring_organizations').get_text() + + self.assertLess(orgs_dropdown_text.index('org2'), orgs_dropdown_text.index('org3')) + self.assertLess(orgs_dropdown_text.index('org3'), orgs_dropdown_text.index('org1')) + def test_page_with_post_new_course_run(self): """ Verify that course selection page with posting the data. """ @@ -205,7 +234,9 @@ class ProgramAdminFunctionalTests(SiteMixin, LiveServerTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.browser = webdriver.Firefox() + opts = Options() + opts.set_headless() + cls.browser = webdriver.Firefox(options=opts) cls.browser.set_window_size(1024, 768) @classmethod @@ -289,15 +320,16 @@ def assert_form_fields_present(self): actual += [_class for _class in element.get_attribute('class').split(' ') if _class.startswith('field-')] expected = [ - 'field-uuid', 'field-title', 'field-subtitle', 'field-status', 'field-type', 'field-partner', - 'field-banner_image', 'field-banner_image_url', 'field-card_image_url', 'field-marketing_slug', - 'field-overview', 'field-credit_redemption_overview', 'field-video', 'field-total_hours_of_effort', - 'field-weeks_to_complete', 'field-min_hours_effort_per_week', 'field-max_hours_effort_per_week', - 'field-courses', 'field-order_courses_by_start_date', 'field-custom_course_runs_display', - 'field-excluded_course_runs', 'field-authoring_organizations', 'field-credit_backing_organizations', - 'field-one_click_purchase_enabled', 'field-hidden', 'field-corporate_endorsements', 'field-faq', - 'field-individual_endorsements', 'field-job_outlook_items', 'field-expected_learning_items', - 'field-instructor_ordering', 'field-enrollment_count', 'field-recent_enrollment_count', + 'field-uuid', 'field-title', 'field-subtitle', 'field-marketing_hook', + 'field-status', 'field-type', 'field-partner', 'field-banner_image', + 'field-banner_image_url', 'field-card_image', 'field-marketing_slug', 'field-overview', + 'field-credit_redemption_overview', 'field-video', 'field-total_hours_of_effort', 'field-weeks_to_complete', + 'field-min_hours_effort_per_week', 'field-max_hours_effort_per_week', 'field-courses', + 'field-order_courses_by_start_date', 'field-custom_course_runs_display', 'field-excluded_course_runs', + 'field-authoring_organizations', 'field-credit_backing_organizations', 'field-one_click_purchase_enabled', + 'field-hidden', 'field-corporate_endorsements', 'field-faq', 'field-individual_endorsements', + 'field-job_outlook_items', 'field-expected_learning_items', 'field-instructor_ordering', + 'field-enrollment_count', 'field-recent_enrollment_count', 'field-credit_value', ] self.assertEqual(actual, expected) @@ -361,11 +393,11 @@ class ProgramEligibilityFilterTests(SiteMixin, TestCase): def test_queryset_method_returns_all_programs(self): """ Verify that all programs pass the filter. """ - verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) + verified_seat_type = factories.SeatTypeFactory.verified() program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) program_filter = ProgramEligibilityFilter(None, {}, None, None) course_run = factories.CourseRunFactory() - factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None) + factories.SeatFactory(course_run=course_run, type=verified_seat_type, upgrade_deadline=None) one_click_purchase_eligible_program = factories.ProgramFactory( type=program_type, courses=[course_run.course], @@ -375,22 +407,22 @@ def test_queryset_method_returns_all_programs(self): with self.assertNumQueries(1): self.assertEqual( list(program_filter.queryset({}, Program.objects.all())), - [one_click_purchase_ineligible_program, one_click_purchase_eligible_program] + [one_click_purchase_eligible_program, one_click_purchase_ineligible_program] ) def test_queryset_method_returns_eligible_programs(self): """ Verify that one click purchase eligible programs pass the filter. """ - verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) + verified_seat_type = factories.SeatTypeFactory.verified() program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) program_filter = ProgramEligibilityFilter(None, {self.parameter_name: 1}, None, None) course_run = factories.CourseRunFactory(end=None, enrollment_end=None,) - factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None) + factories.SeatFactory(course_run=course_run, type=verified_seat_type, upgrade_deadline=None) one_click_purchase_eligible_program = factories.ProgramFactory( type=program_type, courses=[course_run.course], one_click_purchase_enabled=True, ) - with self.assertNumQueries(12): + with self.assertNumQueries(FuzzyInt(11, 2)): self.assertEqual( list(program_filter.queryset({}, Program.objects.all())), [one_click_purchase_eligible_program] diff --git a/course_discovery/apps/course_metadata/tests/test_algolia_models.py b/course_discovery/apps/course_metadata/tests/test_algolia_models.py new file mode 100644 index 0000000000..de70a56b84 --- /dev/null +++ b/course_discovery/apps/course_metadata/tests/test_algolia_models.py @@ -0,0 +1,466 @@ +import datetime +from collections import ChainMap + +import pytest +from django.conf import settings +from django.contrib.sites.models import Site +from django.test import TestCase, override_settings +from pytz import UTC + +from conftest import TEST_DOMAIN +from course_discovery.apps.core.models import Partner +from course_discovery.apps.core.tests.factories import PartnerFactory, SiteFactory +from course_discovery.apps.course_metadata.algolia_models import AlgoliaProxyCourse, AlgoliaProxyProgram +from course_discovery.apps.course_metadata.choices import ProgramStatus +from course_discovery.apps.course_metadata.models import CourseRunStatus +from course_discovery.apps.course_metadata.tests.factories import ( + CourseFactory, CourseRunFactory, OrganizationFactory, ProgramFactory, ProgramTypeFactory, SeatFactory, + SeatTypeFactory +) +from course_discovery.apps.ietf_language_tags.models import LanguageTag + + +class AlgoliaProxyCourseFactory(CourseFactory): + class Meta: + model = AlgoliaProxyCourse + + +class AlgoliaProxyProgramFactory(ProgramFactory): + class Meta: + model = AlgoliaProxyProgram + + +class TestAlgoliaDataMixin(): + ONE_MONTH_AGO = datetime.datetime.now(UTC) - datetime.timedelta(days=30) + YESTERDAY = datetime.datetime.now(UTC) - datetime.timedelta(days=1) + TOMORROW = datetime.datetime.now(UTC) + datetime.timedelta(days=1) + IN_THREE_DAYS = datetime.datetime.now(UTC) + datetime.timedelta(days=3) + IN_FIFTEEN_DAYS = datetime.datetime.now(UTC) + datetime.timedelta(days=15) + IN_TWO_MONTHS = datetime.datetime.now(UTC) + datetime.timedelta(days=60) + + def create_current_upgradeable_course(self, **kwargs): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + current_upgradeable_course_run = CourseRunFactory( + course=course, + start=self.YESTERDAY, + end=self.IN_FIFTEEN_DAYS, + enrollment_end=self.IN_FIFTEEN_DAYS, + status=CourseRunStatus.Published, + **kwargs + ) + SeatFactory( + course_run=current_upgradeable_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.TOMORROW, + price=10 + ) + return course + + def create_upgradeable_course_ending_soon(self, **kwargs): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + upgradeable_course_run = CourseRunFactory( + course=course, + start=self.YESTERDAY, + end=self.IN_THREE_DAYS, + enrollment_end=self.IN_THREE_DAYS, + status=CourseRunStatus.Published, + **kwargs + ) + + SeatFactory( + course_run=upgradeable_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.TOMORROW, + price=10 + ) + return course + + def create_upgradeable_course_starting_soon(self, **kwargs): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + upgradeable_course_run = CourseRunFactory( + course=course, + start=self.TOMORROW, + end=self.IN_FIFTEEN_DAYS, + enrollment_end=self.IN_FIFTEEN_DAYS, + status=CourseRunStatus.Published, + **kwargs + ) + + SeatFactory( + course_run=upgradeable_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.IN_FIFTEEN_DAYS, + price=10 + ) + return course + + def create_current_non_upgradeable_course(self, **kwargs): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + + non_upgradeable_course_run = CourseRunFactory( + course=course, + start=self.YESTERDAY, + end=self.IN_FIFTEEN_DAYS, + enrollment_end=self.IN_FIFTEEN_DAYS, + status=CourseRunStatus.Published, + **kwargs + ) + # not upgradeable because upgrade_deadline has passed + SeatFactory( + course_run=non_upgradeable_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.YESTERDAY, + price=10 + ) + return course + + def create_upcoming_non_upgradeable_course(self, additional_days=0, **kwargs): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + future_course_run = CourseRunFactory( + course=course, + start=self.IN_THREE_DAYS + datetime.timedelta(days=additional_days), + end=self.IN_FIFTEEN_DAYS + datetime.timedelta(days=additional_days), + enrollment_end=self.IN_THREE_DAYS + datetime.timedelta(days=additional_days), + status=CourseRunStatus.Published, + **kwargs + ) + SeatFactory( + course_run=future_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.YESTERDAY, + price=10 + ) + return course + + def create_course_with_basic_active_course_run(self, **kwargs): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + + course_run = CourseRunFactory( + course=course, + start=self.YESTERDAY, + end=self.YESTERDAY, + status=CourseRunStatus.Published, + **kwargs + ) + SeatFactory( + course_run=course_run, + type=SeatTypeFactory.audit(), + ) + return course + + def attach_published_course_run(self, course, run_type="archived", **kwargs): + if run_type == 'current and ends within two weeks': + course_start = self.ONE_MONTH_AGO + course_end = self.TOMORROW + elif run_type == 'current and ends after two weeks': + course_start = self.ONE_MONTH_AGO + course_end = self.IN_TWO_MONTHS + elif run_type == 'upcoming': + course_start = self.TOMORROW + course_end = self.IN_TWO_MONTHS + elif run_type == 'archived': + course_start = self.ONE_MONTH_AGO + course_end = self.YESTERDAY + + return CourseRunFactory( + course=course, + start=course_start, + end=course_end, + status=CourseRunStatus.Published, + **kwargs + ) + + +@override_settings(SETTING_DICT=ChainMap({'AUTO_INDEXING': 'False'}, settings.ALGOLIA)) +class TestAlgoliaProxyWithEdxPartner(TestCase, TestAlgoliaDataMixin): + @classmethod + def setUpClass(cls): + super(TestAlgoliaProxyWithEdxPartner, cls).setUpClass() + Partner.objects.all().delete() + Site.objects.all().delete() + cls.site = SiteFactory(id=settings.SITE_ID, domain=TEST_DOMAIN) + cls.edxPartner = PartnerFactory(site=cls.site) + cls.edxPartner.name = 'edX' + + +@pytest.mark.django_db +class TestAlgoliaProxyCourse(TestAlgoliaProxyWithEdxPartner): + + def test_should_index(self): + course = self.create_course_with_basic_active_course_run() + course.authoring_organizations.add(OrganizationFactory()) + assert course.should_index + + def test_do_not_index_if_no_owners(self): + course = self.create_course_with_basic_active_course_run() + assert not course.should_index + + def test_do_not_index_if_owner_missing_logo(self): + course = self.create_course_with_basic_active_course_run() + course.authoring_organizations.add(OrganizationFactory(logo_image=None)) + assert not course.should_index + + def test_do_not_index_if_no_url_slug(self): + course = self.create_course_with_basic_active_course_run() + course.authoring_organizations.add(OrganizationFactory()) + for url_slug in course.url_slug_history.all(): + url_slug.is_active = False + url_slug.save() + assert not course.should_index + + def test_do_not_index_if_partner_not_edx(self): + course = self.create_course_with_basic_active_course_run() + course.partner = PartnerFactory() + course.authoring_organizations.add(OrganizationFactory()) + assert not course.should_index + + def test_do_not_index_if_no_active_course_run(self): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + course.authoring_organizations.add(OrganizationFactory()) + assert not course.should_index + + def test_do_not_index_if_active_course_run_is_hidden(self): + course = self.create_course_with_basic_active_course_run() + course.authoring_organizations.add(OrganizationFactory()) + for course_run in course.course_runs.all(): + course_run.hidden = True + course_run.save() + assert not course.should_index + + def test_index_if_non_active_course_run_is_hidden(self): + course = self.create_course_with_basic_active_course_run() + course.authoring_organizations.add(OrganizationFactory()) + non_upgradeable_course_run = CourseRunFactory( + course=course, + start=self.YESTERDAY, + end=self.IN_FIFTEEN_DAYS, + enrollment_end=self.IN_FIFTEEN_DAYS, + status=CourseRunStatus.Published, + hidden=True + ) + # not upgradeable because upgrade_deadline has passed + SeatFactory( + course_run=non_upgradeable_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=self.YESTERDAY, + price=10 + ) + assert course.should_index + + def test_current_and_upgradeable_beats_just_upgradeable(self): + course_1 = self.create_current_upgradeable_course() + course_2 = self.create_upgradeable_course_ending_soon() + course_3 = self.create_upgradeable_course_starting_soon() + assert course_1.availability_rank < course_2.availability_rank + assert course_1.availability_rank < course_3.availability_rank + assert course_2.availability_rank == course_3.availability_rank + + def test_upgradeable_beats_just_current(self): + course_1 = self.create_upgradeable_course_ending_soon() + course_2 = self.create_current_non_upgradeable_course() + assert course_1.availability_rank < course_2.availability_rank + + def test_current_non_upgradeable_beats_upcoming_non_upgradeable(self): + course_1 = self.create_current_non_upgradeable_course() + course_2 = self.create_upcoming_non_upgradeable_course() + assert course_1.availability_rank < course_2.availability_rank + + def test_earliest_upcoming_wins(self): + course_1 = self.create_upcoming_non_upgradeable_course() + course_2 = self.create_upcoming_non_upgradeable_course(additional_days=1) + assert course_1.availability_rank < course_2.availability_rank + + def test_active_course_run_beats_no_active_course_run(self): + course_1 = self.create_course_with_basic_active_course_run() + course_2 = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + CourseRunFactory( + course=course_2, + start=self.YESTERDAY, + end=self.YESTERDAY, + enrollment_end=self.YESTERDAY, + status=CourseRunStatus.Published + ) + assert course_1.availability_rank + assert not course_2.availability_rank + + def test_course_availability_reflects_all_course_runs(self): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + + self.attach_published_course_run(course=course, run_type="current and ends after two weeks") + self.attach_published_course_run(course=course, run_type='upcoming') + self.attach_published_course_run(course=course, run_type='archived') + + assert len(course.availability_level) == 3 + assert 'Available now' in course.availability_level + assert 'Upcoming' in course.availability_level + assert 'Archived' in course.availability_level + + def test_course_not_available_now_if_end_date_too_soon(self): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + + self.attach_published_course_run(course=course, run_type="current and ends within two weeks") + + assert course.availability_level == ['Archived'] + + def test_course_availability_empty_if_no_published_runs(self): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + CourseRunFactory( + course=course, + status=CourseRunStatus.Unpublished, + ) + + assert course.availability_level == [] + + def test_spanish_courses_promoted_in_spanish_index(self): + colombian_spanish = LanguageTag.objects.get(code='es-co') + american_english = LanguageTag.objects.get(code='en-us') + spanish_course = self.create_course_with_basic_active_course_run(language=colombian_spanish) + english_course = self.create_course_with_basic_active_course_run(language=american_english) + assert spanish_course.promoted_in_spanish_index + assert not english_course.promoted_in_spanish_index + + +@pytest.mark.django_db +class TestAlgoliaProxyProgram(TestAlgoliaProxyWithEdxPartner): + + ONE_MONTH_AGO = datetime.datetime.now(UTC) - datetime.timedelta(days=30) + YESTERDAY = datetime.datetime.now(UTC) - datetime.timedelta(days=1) + TOMORROW = datetime.datetime.now(UTC) + datetime.timedelta(days=1) + IN_FIFTEEN_DAYS = datetime.datetime.now(UTC) + datetime.timedelta(days=15) + IN_TWO_MONTHS = datetime.datetime.now(UTC) + datetime.timedelta(days=60) + + def attach_course_run(self, course, availability="Archived"): + if availability == 'none': + return CourseRunFactory( + course=course, + start=self.TOMORROW, + end=self.IN_TWO_MONTHS, + status=CourseRunStatus.Unpublished + ) + elif availability == 'Available now': + course_start = self.ONE_MONTH_AGO + course_end = self.IN_FIFTEEN_DAYS + elif availability == 'Upcoming': + course_start = self.TOMORROW + course_end = self.IN_TWO_MONTHS + elif availability == 'Archived': + course_start = self.ONE_MONTH_AGO + course_end = self.YESTERDAY + + return CourseRunFactory( + course=course, + start=course_start, + end=course_end, + status=CourseRunStatus.Published + ) + + def attach_archived_course(self, program): + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + CourseRunFactory( + course=course, + start=self.ONE_MONTH_AGO, + end=self.YESTERDAY, + status=CourseRunStatus.Published + ) + return program.courses.add(course) + + def test_program_availability_reflects_all_unique_course_statuses(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + + course_1 = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + self.attach_course_run(course=course_1, availability="Available now") + self.attach_course_run(course=course_1, availability="Upcoming") + program.courses.add(course_1) + + course_2 = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + self.attach_course_run(course=course_2, availability="Upcoming") + self.attach_course_run(course=course_2, availability="Archived") + program.courses.add(course_2) + + assert len(program.availability_level) == 3 + assert 'Available now' in program.availability_level + assert 'Upcoming' in program.availability_level + assert 'Archived' in program.availability_level + + def test_program_available_now_if_program_type_is_masters(self): + program_type = ProgramTypeFactory() + program_type.slug = 'masters' + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, type=program_type) + + assert program.availability_level == 'Available now' + + def test_program_not_available_if_no_published_runs(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner) + self.attach_course_run(course=course, availability="none") + program.courses.add(course) + + assert program.availability_level == [] + + def test_only_programs_with_spanish_courses_promoted_in_spanish_index(self): + all_spanish_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + mixed_language_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + all_english_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + + colombian_spanish = LanguageTag.objects.get(code='es-co') + american_english = LanguageTag.objects.get(code='en-us') + + spanish_course = self.create_course_with_basic_active_course_run(language=colombian_spanish) + english_course = self.create_course_with_basic_active_course_run(language=american_english) + + all_spanish_program.courses.add(spanish_course) + mixed_language_program.courses.add(spanish_course, english_course) + all_english_program.courses.add(english_course) + + assert all_spanish_program.promoted_in_spanish_index + assert mixed_language_program.promoted_in_spanish_index + assert not all_english_program.promoted_in_spanish_index + + def test_should_index(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + program.authoring_organizations.add(OrganizationFactory()) + self.attach_archived_course(program=program) + assert program.should_index + + def test_do_not_index_if_no_owners(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + self.attach_archived_course(program=program) + assert not program.should_index + + def test_do_not_index_if_owner_missing_logo(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + program.authoring_organizations.add(OrganizationFactory(logo_image=None)) + self.attach_archived_course(program=program) + assert not program.should_index + + def test_do_not_index_if_partner_not_edx(self): + program = AlgoliaProxyProgramFactory(partner=PartnerFactory()) + program.authoring_organizations.add(OrganizationFactory()) + self.attach_archived_course(program=program) + assert not program.should_index + + def test_do_not_index_if_not_active(self): + unpublished_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, + status=ProgramStatus.Unpublished) + unpublished_program.authoring_organizations.add(OrganizationFactory()) + self.attach_archived_course(program=unpublished_program) + + retired_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, + status=ProgramStatus.Retired) + retired_program.authoring_organizations.add(OrganizationFactory()) + self.attach_archived_course(program=retired_program) + + deleted_program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, + status=ProgramStatus.Deleted) + deleted_program.authoring_organizations.add(OrganizationFactory()) + self.attach_archived_course(program=deleted_program) + + assert not unpublished_program.should_index + assert not retired_program.should_index + assert not deleted_program.should_index + + def test_do_not_index_if_hidden(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, hidden=True) + program.authoring_organizations.add(OrganizationFactory()) + self.attach_archived_course(program=program) + assert not program.should_index diff --git a/course_discovery/apps/course_metadata/tests/test_emails.py b/course_discovery/apps/course_metadata/tests/test_emails.py new file mode 100644 index 0000000000..5dafafbbea --- /dev/null +++ b/course_discovery/apps/course_metadata/tests/test_emails.py @@ -0,0 +1,356 @@ +import datetime +import re + +from django.conf import settings +from django.contrib.auth.models import Group +from django.core import mail +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey +from testfixtures import LogCapture, StringComparison + +from course_discovery.apps.core.tests.factories import UserFactory +from course_discovery.apps.course_metadata import emails +from course_discovery.apps.course_metadata.models import CourseEditor +from course_discovery.apps.course_metadata.tests.factories import ( + CourseEditorFactory, CourseRunFactory, OrganizationFactory +) +from course_discovery.apps.publisher.choices import InternalUserRole +from course_discovery.apps.publisher.constants import LEGAL_TEAM_GROUP_NAME +from course_discovery.apps.publisher.tests.factories import ( + GroupFactory, OrganizationExtensionFactory, OrganizationUserRoleFactory, UserAttributeFactory +) + + +class EmailTests(TestCase): + def setUp(self): + super().setUp() + self.org = OrganizationFactory(name='MyOrg', key='myorg') + self.course_run = CourseRunFactory(draft=True, title_override='MyCourse') + self.course = self.course_run.course + self.course.authoring_organizations.add(self.org) + self.partner = self.course.partner + self.group = GroupFactory() + self.pc = self.make_user(email='pc@example.com') + self.editor = self.make_user(groups=[self.group]) + self.editor2 = self.make_user(groups=[self.group]) + self.non_editor = self.make_user(groups=[self.group]) + self.legal = self.make_user(groups=[Group.objects.get(name=LEGAL_TEAM_GROUP_NAME)]) + + CourseEditorFactory(user=self.editor, course=self.course) + CourseEditorFactory(user=self.editor2, course=self.course) + OrganizationExtensionFactory(group=self.group, organization=self.org) + OrganizationUserRoleFactory(user=self.pc, organization=self.org, role=InternalUserRole.ProjectCoordinator) + + self.publisher_url = '{}courses/{}'.format(self.partner.publisher_url, self.course_run.course.uuid) + self.studio_url = '{}course/{}'.format(self.partner.studio_url, self.course_run.key) + self.admin_url = 'https://{}/admin/course_metadata/courserun/{}/change/'.format( + self.partner.site.domain, self.course_run.id + ) + self.run_num = CourseKey.from_string(self.course_run.key).run + + @staticmethod + def make_user(groups=None, **kwargs): + user = UserFactory(**kwargs) + UserAttributeFactory(user=user, enable_email_notification=True) + if groups: + user.groups.set(groups) + return user + + def assertEmailContains(self, subject=None, to_users=None, both_regexes=None, text_regexes=None, + html_regexes=None, index=0): + email = mail.outbox[index] + if to_users is not None: + self.assertEqual(set(email.to), {u.email for u in to_users}) + if subject is not None: + self.assertRegex(str(email.subject), subject) + self.assertEqual(len(email.alternatives), 1) + self.assertEqual(email.alternatives[0][1], 'text/html') + + text = email.body + html = email.alternatives[0][0] + + for regex in both_regexes or []: + self.assertRegex(text, regex) + self.assertRegex(html, regex) + + for regex in text_regexes or []: + self.assertRegex(text, regex) + + for regex in html_regexes or []: + self.assertRegex(html, regex) + + def assertEmailDoesNotContain(self, both_regexes=None, text_regexes=None, html_regexes=None, index=0): + email = mail.outbox[index] + text = email.body + html = email.alternatives[0][0] + + for regex in both_regexes or []: + self.assertNotRegex(text, regex) + self.assertNotRegex(html, regex) + + for regex in text_regexes or []: + self.assertNotRegex(text, regex) + + for regex in html_regexes or []: + self.assertNotRegex(html, regex) + + def assertEmailSent(self, function, subject=None, to_users=None, both_regexes=None, text_regexes=None, + html_regexes=None, index=0, total=1): + function(self.course_run) + + self.assertEqual(len(mail.outbox), total) + self.assertEmailContains(subject=subject, to_users=to_users, both_regexes=both_regexes, + text_regexes=text_regexes, html_regexes=html_regexes, index=index) + + def assertEmailNotSent(self, function, reason): + with LogCapture(emails.logger.name) as log_capture: + function(self.course_run) + + self.assertEqual(len(mail.outbox), 0) + + if reason: + log_capture.check( + ( + emails.logger.name, + 'INFO', + StringComparison('Not sending notification email for template course_metadata/email/.* because ' + + reason), + ) + ) + + def test_send_email_for_legal_review(self): + """ + Verify that send_email_for_legal_review's happy path works as expected + """ + self.assertEmailSent( + emails.send_email_for_legal_review, + '^Legal review requested: {}$'.format(self.course_run.title), + [self.legal], + both_regexes=[ + 'Dear legal team,', + 'MyOrg has submitted MyCourse for review.', + 'Note: This email address is unable to receive replies.', + ], + html_regexes=[ + 'View this course run in Publisher to determine OFAC status.' % self.publisher_url, + 'For questions or comments, please contact ' + 'the Project Coordinator.', + ], + text_regexes=[ + '%s\nView this course run in Publisher above to determine OFAC status.' % self.publisher_url, + 'For questions or comments, please contact the Project Coordinator at pc@example.com.', + ], + ) + + def test_send_email_for_internal_review(self): + """ + Verify that send_email_for_internal_review's happy path works as expected + """ + restricted_url = self.partner.lms_admin_url.rstrip('/') + '/embargo/restrictedcourse/' + self.assertEmailSent( + emails.send_email_for_internal_review, + '^Review requested: {} - {}$'.format(self.course_run.key, self.course_run.title), + [self.pc], + both_regexes=[ + 'Dear %s,' % self.pc.full_name, + 'MyOrg has submitted %s for review.' % self.course_run.key, + ], + html_regexes=[ + 'View this course run in Publisher to review the changes and mark it as reviewed.' % + self.publisher_url, + 'This is a good time to review this course run in Studio.' % self.studio_url, + 'Visit the restricted course admin page to set embargo rules for this course, ' + 'as needed.' % restricted_url, + ], + text_regexes=[ + '\n\nPublisher page: %s\n' % self.publisher_url, + '\n\nStudio page: %s\n' % self.studio_url, + '\n\nRestricted Course admin: %s\n' % restricted_url, + ], + ) + + def test_send_email_for_reviewed(self): + """ + Verify that send_email_for_reviewed's happy path works as expected + """ + self.assertEmailSent( + emails.send_email_for_reviewed, + '^Review complete: {}$'.format(self.course_run.title), + [self.editor, self.editor2], + both_regexes=[ + 'Dear course team,', + 'The course run about page is now published.', + 'Note: This email address is unable to receive replies.', + ], + html_regexes=[ + 'The %s course run of %s has been reviewed and approved by %s.' % + (self.publisher_url, self.run_num, self.course_run.title, settings.PLATFORM_NAME), + 'For questions or comments, please contact ' + 'your Project Coordinator.', + ], + text_regexes=[ + 'The %s course run of %s has been reviewed and approved by %s.' % + (self.run_num, self.course_run.title, settings.PLATFORM_NAME), + '\n\nView the course run in Publisher: %s\n' % self.publisher_url, + 'For questions or comments, please contact your Project Coordinator at pc@example.com.', + ], + ) + + def test_send_email_for_go_live(self): + """ + Verify that send_email_for_go_live's happy path works as expected + """ + kwargs = { + 'both_regexes': [ + 'The About page for the %s course run of %s has been published.' % + (self.run_num, self.course_run.title), + 'No further action is necessary.', + ], + 'html_regexes': [ + 'View this About page.' % self.course_run.marketing_url, + 'For questions or comments, please contact ' + 'your Project Coordinator.', + ], + 'text_regexes': [ + '\n\nView this About page. %s\n' % self.course_run.marketing_url, + 'For questions or comments, please contact your Project Coordinator at pc@example.com.', + ], + } + + self.assertEmailSent( + emails.send_email_for_go_live, + '^Published: {}$'.format(self.course_run.title), + [self.editor, self.editor2], + total=2, + **kwargs, + ) + self.assertEmailContains( + subject='^Published: {} - {}$'.format(self.course_run.key, self.course_run.title), + to_users=[self.pc], + index=1, + **kwargs, + ) + + def test_no_project_coordinator(self): + """ + Verify that no email is sent and a message is logged if no PC is defined + """ + self.pc.delete() + self.assertEmailNotSent( + emails.send_email_for_internal_review, + 'no project coordinator is defined for organization myorg' + ) + + def test_no_organization(self): + """ + Verify that no email is sent and a message is logged if no org is defined + """ + self.org.delete() + self.assertEmailNotSent( + emails.send_email_for_internal_review, + 'no organization is defined for course %s' % self.course_run.course.key + ) + + def test_no_publisher_url(self): + """ + Verify that no email is sent and a message is logged if the publisher_url is missing + """ + self.partner.publisher_url = None + self.partner.save() + self.assertEmailNotSent( + emails.send_email_for_internal_review, + 'no publisher URL is defined for partner %s' % self.partner.short_code + ) + + def test_no_studio_url(self): + """ + Verify that no email is sent and a message is logged if the studio_url is missing + """ + self.partner.studio_url = None + self.partner.save() + self.assertEmailNotSent( + emails.send_email_for_internal_review, + 'no studio URL is defined for partner %s' % self.partner.short_code + ) + + def test_no_lms_admin_url(self): + """ + Verify that no link is provided to the restricted course admin if we don't have lms_admin_url + """ + self.partner.lms_admin_url = None + self.partner.save() + self.assertEmailSent(emails.send_email_for_internal_review) + self.assertEmailDoesNotContain( + both_regexes=[ + re.compile('restricted', re.IGNORECASE), + ], + ) + + def test_no_editors(self): + """ + Verify that no reviewed email is sent if no editors exist + """ + self.editor.delete() + self.editor2.delete() + self.non_editor.delete() + self.assertEmailNotSent(emails.send_email_for_reviewed, None) + + def test_respect_for_no_email_flag(self): + """ + Verify that no email is sent if the user requests it + """ + self.editor.attributes.enable_email_notification = False + self.editor.attributes.save() + self.assertEmailSent(emails.send_email_for_reviewed, to_users=[self.editor2]) + + def test_emails_all_org_users_if_no_editors(self): + """ + Verify that we send email to all org users if no editors exist + """ + CourseEditor.objects.all().delete() + self.assertEmailSent(emails.send_email_for_reviewed, to_users=[self.editor, self.editor2, self.non_editor]) + + def test_reviewed_go_live_date_in_future(self): + """ + Verify that we mention when the course run will go live, if it's in the future + """ + self.course_run.go_live_date = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=10) + self.assertEmailSent( + emails.send_email_for_reviewed, + both_regexes=[ + 'The course run about page will be published on %s' % self.course_run.go_live_date.strftime('%x'), + ], + ) + + def test_reviewed_go_live_date_in_past(self): + """ + Verify that we mention when the course run is now live, if we missed the go live date + """ + self.course_run.go_live_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=10) + self.assertEmailSent( + emails.send_email_for_reviewed, + both_regexes=[ + 'The course run about page is now published.', + ], + ) + + def test_comment_email_sent(self): + comment = 'This is a test comment' + emails.send_email_for_comment({ + 'user': { + 'username': self.editor.username, + 'email': self.editor.email, + 'first_name': self.editor.first_name, + 'last_name': self.editor.last_name, + }, + 'comment': comment, + 'created': datetime.datetime.now(datetime.timezone.utc).isoformat(), + }, self.course, self.editor) + + self.assertEqual(len(mail.outbox), 1) + self.assertEmailContains( + both_regexes=[ + '{} made the following comment on'.format(self.editor.username), + comment + ], + ) diff --git a/course_discovery/apps/course_metadata/tests/test_lookups.py b/course_discovery/apps/course_metadata/tests/test_lookups.py index ef9d69660f..af16767226 100644 --- a/course_discovery/apps/course_metadata/tests/test_lookups.py +++ b/course_discovery/apps/course_metadata/tests/test_lookups.py @@ -8,9 +8,9 @@ from course_discovery.apps.api.tests.mixins import SiteMixin from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory from course_discovery.apps.course_metadata.tests.factories import ( - CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, PositionFactory + CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, PositionFactory, ProgramFactory ) -from course_discovery.apps.publisher.tests import factories +from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory @pytest.mark.django_db @@ -49,6 +49,34 @@ def test_course_run_autocomplete(self, admin_client): self.assert_valid_query_result(admin_client, path, course_run.key[14:], course_run) self.assert_valid_query_result(admin_client, path, course_run.title[12:], course_run) + course = course_run.course + CourseRunFactory.create_batch(3, course=course) + response = admin_client.get(path + '?forward={f}'.format(f=json.dumps({'course': course.pk}))) + data = json.loads(response.content.decode('utf-8')) + assert response.status_code == 200 + assert len(data['results']) == 4 + + def test_program_autocomplete(self, admin_client): + """ Verify Program autocomplete returns the data. """ + programs = ProgramFactory.create_batch(3) + path = reverse('admin_metadata:program-autocomplete') + response = admin_client.get(path) + data = json.loads(response.content.decode('utf-8')) + assert response.status_code == 200 + assert len(data['results']) == 3 + + # Search for substrings of program titles + program = programs[0] + self.assert_valid_query_result(admin_client, path, program.title[5:], program) + program = programs[1] + self.assert_valid_query_result(admin_client, path, program.title[5:], program) + + admin_client.logout() + response = admin_client.get(path) + data = json.loads(response.content.decode('utf-8')) + assert response.status_code == 200 + assert not data['results'] + def test_organization_autocomplete(self, admin_client): """ Verify Organization autocomplete returns the data. """ organizations = OrganizationFactory.create_batch(3) @@ -80,32 +108,40 @@ class AutoCompletePersonTests(SiteMixin, TestCase): Tests for person autocomplete lookups """ - def setUp(self): - super(AutoCompletePersonTests, self).setUp() - self.user = UserFactory(is_staff=True) - self.client.login(username=self.user.username, password=USER_PASSWORD) - self.courses = factories.CourseFactory.create_batch(3, title='Some random course title') + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = UserFactory(is_staff=True) + + first_instructor = PersonFactory(given_name="First", family_name="Instructor") + second_instructor = PersonFactory(given_name="Second", family_name="Instructor") + cls.instructors = [first_instructor, second_instructor] - for course in self.courses: - factories.CourseRunFactory(course=course) + cls.organizations = OrganizationFactory.create_batch(3) + cls.organization_extensions = [] - self.organizations = OrganizationFactory.create_batch(3) - self.organization_extensions = [] + for instructor in cls.instructors: + PositionFactory(organization=cls.organizations[0], title="professor", person=instructor) - for organization in self.organizations: - self.organization_extensions.append(factories.OrganizationExtensionFactory(organization=organization)) + for organization in cls.organizations: + cls.organization_extensions.append(OrganizationExtensionFactory(organization=organization)) - self.user.groups.add(self.organization_extensions[0].group) - first_instructor = PersonFactory(given_name="First", family_name="Instructor") - second_instructor = PersonFactory(given_name="Second", family_name="Instructor") - self.instructors = [first_instructor, second_instructor] + disco_course = CourseFactory(authoring_organizations=[cls.organizations[0]]) + disco_course2 = CourseFactory(authoring_organizations=[cls.organizations[1]]) + CourseRunFactory(course=disco_course, staff=[first_instructor]) + CourseRunFactory(course=disco_course2, staff=[second_instructor]) - for instructor in self.instructors: - PositionFactory(organization=self.organizations[0], title="professor", person=instructor) + cls.user.groups.add(cls.organization_extensions[0].group) + + def setUp(self): + super().setUp() + self._set_user_is_staff_and_login(True) def query(self, q): + query_params = '?q={q}'.format(q=q) + return self.client.get( - reverse('admin_metadata:person-autocomplete') + '?q={q}'.format(q=q) + reverse('admin_metadata:person-autocomplete') + query_params ) def test_instructor_autocomplete(self): @@ -113,16 +149,13 @@ def test_instructor_autocomplete(self): response = self.query('ins') self._assert_response(response, 2) - # update first instructor's name - self.instructors[0].given_name = 'dummy_name' - self.instructors[0].save() - - response = self.query('dummy') + # look for the name of the first instructor + response = self.query('First') self._assert_response(response, 1) def test_instructor_autocomplete_un_authorize_user(self): """ Verify instructor autocomplete returns empty list for un-authorized users. """ - self._make_user_non_staff() + self._set_user_is_staff_and_login(False) response = self.client.get(reverse('admin_metadata:person-autocomplete')) self._assert_response(response, 0) @@ -141,22 +174,6 @@ def test_instructor_autocomplete_last_name_first_name(self): response = self.query('instructor first') self._assert_response(response, 1) - def test_instructor_position_in_label(self): - """ Verify that instructor label contains position of instructor if it exists.""" - position_title = 'professor' - - response = self.query('ins') - - self.assertContains(response, '

{position} at {organization}

'.format( - position=position_title, - organization=self.organizations[0].name)) - - def test_instructor_image_in_label(self): - """ Verify that instructor label contains profile image url.""" - response = self.query('ins') - self.assertContains(response, self.instructors[0].get_profile_image_url) - self.assertContains(response, self.instructors[1].get_profile_image_url) - def _assert_response(self, response, expected_length): """ Assert autocomplete response. """ assert response.status_code == 200 @@ -207,17 +224,17 @@ def test_instructor_autocomplete_from_django_admin(self): self.client.logout() self.client.login(username=admin_user.username, password=USER_PASSWORD) - response = self.client.get( - reverse('admin_metadata:person-autocomplete') + '?q={q}'.format(q='ins'), - HTTP_REFERER=reverse('admin:publisher_courserun_add') - ) + response = self.client.get(reverse('admin_metadata:person-autocomplete') + '?q={q}'.format(q='ins')) assert response.status_code == 200 data = json.loads(response.content.decode('utf-8')) - expected_results = [{'id': instructor.id, 'text': str(instructor)} for instructor in self.instructors] - assert data.get('results') == expected_results + expected_results = [{'id': str(instructor.id), 'text': str(instructor), 'selected_text': str(instructor)} + for instructor in self.instructors] + + assert (sorted(data.get('results'), key=lambda x: sorted(x.keys())) == + sorted(expected_results, key=lambda x: sorted(x.keys()))) - def _make_user_non_staff(self): + def _set_user_is_staff_and_login(self, is_staff=True): self.client.logout() - self.user = UserFactory(is_staff=False) + self.user.is_staff = is_staff self.user.save() self.client.login(username=self.user.username, password=USER_PASSWORD) diff --git a/course_discovery/apps/course_metadata/tests/test_managers.py b/course_discovery/apps/course_metadata/tests/test_managers.py new file mode 100644 index 0000000000..d09d954d25 --- /dev/null +++ b/course_discovery/apps/course_metadata/tests/test_managers.py @@ -0,0 +1,54 @@ +from django.db import models +from django.test import TestCase + +from course_discovery.apps.course_metadata.models import CourseRun +from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory + + +class DraftManagerTests(TestCase): + def setUp(self): + super().setUp() + self.draft = CourseRunFactory(draft=True) + self.nondraft = CourseRunFactory(draft=False, uuid=self.draft.uuid, key=self.draft.key, + course=self.draft.course, draft_version=self.draft) + + def test_base_filter(self): + """ + Verify the query set filters draft states out at a base level, not just by overriding all(). + """ + self.assertEqual(CourseRun.objects.count(), 1) + self.assertEqual(CourseRun.objects.first(), self.nondraft) + self.assertEqual(CourseRun.objects.last(), self.nondraft) + self.assertEqual(list(CourseRun.objects.all()), [self.nondraft]) + + def test_with_drafts(self): + """ + Verify the query set allows access to draft rows too. + """ + self.assertEqual(CourseRun._base_manager.count(), 2) # pylint: disable=protected-access + self.assertEqual(CourseRun.objects._with_drafts().count(), 2) # pylint: disable=protected-access + self.assertEqual(CourseRun.objects.count(), 1) # sanity check + + def test_filter_drafts(self): + extra = CourseRunFactory() + + result = CourseRun.objects.filter_drafts() + self.assertIsInstance(result, models.QuerySet) + self.assertEqual(result.count(), 2) + self.assertEqual(set(result), {extra, self.draft}) + + def test_filter_drafts_with_kwargs(self): + extra = CourseRunFactory() + + result = CourseRun.objects.filter_drafts(course=extra.course) + self.assertEqual(result.count(), 1) + self.assertEqual(result.first(), extra) + + def test_get_draft(self): + extra = CourseRunFactory(course=self.draft.course) + + with self.assertRaises(CourseRun.DoesNotExist): + CourseRun.objects.get_draft(hidden=True) + with self.assertRaises(CourseRun.MultipleObjectsReturned): + CourseRun.objects.get_draft(course=extra.course) + self.assertEqual(CourseRun.objects.get_draft(uuid=self.draft.uuid), self.draft) diff --git a/course_discovery/apps/course_metadata/tests/test_models.py b/course_discovery/apps/course_metadata/tests/test_models.py index a69a4a7dc2..dfde27346d 100644 --- a/course_discovery/apps/course_metadata/tests/test_models.py +++ b/course_discovery/apps/course_metadata/tests/test_models.py @@ -4,47 +4,60 @@ import itertools import uuid from decimal import Decimal +from functools import partial import ddt import mock import pytest import pytz +import responses from dateutil.parser import parse from dateutil.relativedelta import relativedelta from django.conf import settings from django.core.exceptions import ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.test import TestCase from freezegun import freeze_time from taggit.models import Tag +from testfixtures import LogCapture +from waffle.testutils import override_switch from course_discovery.apps.api.tests.mixins import SiteMixin +from course_discovery.apps.api.v1.tests.test_views.mixins import OAuth2Mixin from course_discovery.apps.core.models import Currency from course_discovery.apps.core.tests.helpers import make_image_file from course_discovery.apps.core.utils import SearchQuerySetWrapper from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.models import ( FAQ, AbstractMediaModel, AbstractNamedModel, AbstractTitleDescriptionModel, AbstractValueModel, - CorporateEndorsement, Course, CourseRun, Curriculum, DegreeCost, DegreeDeadline, Endorsement, + CorporateEndorsement, Course, CourseEditor, CourseRun, Curriculum, CurriculumCourseMembership, + CurriculumCourseRunExclusion, CurriculumProgramMembership, DegreeCost, DegreeDeadline, Endorsement, Organization, Program, Ranking, Seat, SeatType, Subject, Topic ) from course_discovery.apps.course_metadata.publishers import ( CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher ) -from course_discovery.apps.course_metadata.tests import factories, toggle_switch -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ImageFactory +from course_discovery.apps.course_metadata.tests import factories +from course_discovery.apps.course_metadata.tests.factories import ( + CourseRunFactory, ImageFactory, SeatFactory, SeatTypeFactory +) +from course_discovery.apps.course_metadata.tests.mixins import MarketingSitePublisherTestMixin +from course_discovery.apps.course_metadata.utils import ensure_draft_world +from course_discovery.apps.course_metadata.utils import logger as utils_logger +from course_discovery.apps.course_metadata.utils import uslugify from course_discovery.apps.ietf_language_tags.models import LanguageTag +from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory -# pylint: disable=no-member - @pytest.mark.django_db -class TestCourse: +@pytest.mark.usefixtures('haystack_default_connection') +@ddt.ddt +class TestCourse(TestCase): def test_str(self): course = factories.CourseFactory() assert str(course), '{key}: {title}'.format(key=course.key, title=course.title) - def test_search(self, haystack_default_connection): # pylint: disable=unused-argument + def test_search(self): title = 'Some random title' expected = set(factories.CourseFactory.create_batch(3, title=title)) query = 'title:' + title @@ -68,6 +81,21 @@ def test_original_image_url(self): course.image = None assert course.original_image_url is None + @ddt.data('faq', 'full_description', 'learner_testimonials', 'outcome', 'prerequisites_raw', 'short_description', + 'syllabus_raw') + def test_html_fields_are_validated(self, field_name): + course = factories.CourseFactory() + + # Happy path + setattr(course, field_name, '

') + course.clean_fields() + + # Bad HTML + setattr(course, field_name, '') + with self.assertRaises(ValidationError) as cm: + course.clean_fields() + self.assertEqual(cm.exception.message_dict[field_name], ['Invalid HTML received']) + def test_first_enrollable_paid_seat_price(self): """ Verify that `first_enrollable_paid_seat_price` property for a course @@ -84,48 +112,294 @@ def test_first_enrollable_paid_seat_price(self): end=active_course_end, enrollment_end=open_enrollment_end ) + verified_type = factories.SeatTypeFactory.verified() # Create a seat with 0 price and verify that the course field # `first_enrollable_paid_seat_price` returns None - factories.SeatFactory.create(course_run=course_run, type='verified', price=0, sku='ABCDEF') + factories.SeatFactory.create(course_run=course_run, type=verified_type, price=0, sku='ABCDEF') assert course_run.first_enrollable_paid_seat_price is None assert course.first_enrollable_paid_seat_price is None # Now create a seat with some price and verify that the course field # `first_enrollable_paid_seat_price` now returns the price of that # payable seat - factories.SeatFactory.create(course_run=course_run, type='verified', price=100, sku='ABCDEF') + factories.SeatFactory.create(course_run=course_run, type=verified_type, price=100, sku='ABCDEF') assert course_run.first_enrollable_paid_seat_price == 100 assert course.first_enrollable_paid_seat_price == 100 + def test_course_run_sort(self): + course = factories.CourseFactory.create() + now = datetime.datetime.now(pytz.UTC) + first_course_run = factories.CourseRunFactory.create( + enrollment_start=now - datetime.timedelta(days=100), + start=now + datetime.timedelta(days=100), + ) + second_course_run = factories.CourseRunFactory.create( + enrollment_start=now - datetime.timedelta(days=50), + start=None, + ) + third_course_run = factories.CourseRunFactory.create( + enrollment_start=None, + start=now + datetime.timedelta(days=50), + ) + fourth_course_run = factories.CourseRunFactory.create( + enrollment_start=None, + start=None, + ) + + out_of_order_runs = [ + third_course_run, + second_course_run, + first_course_run, + fourth_course_run, + ] + expected_order = [ + first_course_run, + second_course_run, + third_course_run, + fourth_course_run, + ] + self.assertEqual(sorted(out_of_order_runs, key=course.course_run_sort), expected_order) + + +class TestCourseUpdateMarketingUnpublish(MarketingSitePublisherTestMixin, TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = factories.CourseFactory() + cls.partner = cls.course.partner + cls.past = datetime.datetime(2010, 1, 1, tzinfo=pytz.UTC) + cls.future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + cls.base_args = {'course': cls.course, 'status': CourseRunStatus.Published} + cls.active = factories.CourseRunFactory(end=cls.future, enrollment_end=cls.future, **cls.base_args) + cls.inactive = factories.CourseRunFactory(end=cls.past, **cls.base_args) + ensure_draft_world(Course.objects.get(pk=cls.course.pk)) + cls.api_root = cls.partner.marketing_site_url_root.rstrip('/') # overwrite the mixin's version + + def assertUnpublish(self, published_runs=None, succeed=True): + """ + Args: + published_runs: Runs to pass to the unpublish call + succeed: Whether the unpublish should return True or False + """ + + self.assertEqual(self.course.unpublish_inactive_runs(published_runs=published_runs), succeed) + + if succeed: + self.active.refresh_from_db() + self.assertEqual(self.active.status, CourseRunStatus.Published) # should have stayed the same + + self.inactive.refresh_from_db() + self.assertEqual(self.inactive.status, CourseRunStatus.Unpublished) + if self.inactive.draft_version: + self.assertEqual(self.inactive.draft_version.status, CourseRunStatus.Unpublished) + + def test_simple_happy_path(self): + self.assertUnpublish() + + def test_no_marketing_site(self): + # Without + self.partner.marketing_site_url_root = None + self.partner.save() + self.assertUnpublish(succeed=False) + + # Confirm that with will work + self.partner.marketing_site_url_root = self.api_root + self.partner.save() + self.assertUnpublish() + + def test_ignores_unpublished(self): + factories.CourseRunFactory(course=self.course, status=CourseRunStatus.Unpublished, end=self.past) + factories.CourseRunFactory(course=self.course, status=CourseRunStatus.Reviewed, end=self.past) + factories.CourseRunFactory(course=self.course, status=CourseRunStatus.InternalReview, end=self.past) + factories.CourseRunFactory(course=self.course, status=CourseRunStatus.LegalReview, end=self.past) + self.assertUnpublish() + + def test_accepts_run_list(self): + self.assertUnpublish(published_runs={self.active, self.inactive}) + + def test_uses_earliest_of_end_or_enrollment_end(self): + future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) + _no_end = factories.CourseRunFactory(**self.base_args, end=None, enrollment_end=self.past) + _no_enrollment_end = factories.CourseRunFactory(**self.base_args, end=self.past, enrollment_end=None) + _earlier_end = factories.CourseRunFactory(**self.base_args, end=self.past, enrollment_end=future) + _earlier_enrollment_end = factories.CourseRunFactory(**self.base_args, end=future, enrollment_end=self.past) + _in_future = factories.CourseRunFactory(**self.base_args, end=future, enrollment_end=future, start=future) + self.assertUnpublish() + + def test_leaves_at_least_one_run_published(self): + """ Verifies that we refuse to unpublish all runs in a course if there are no marketable runs. """ + self.active.end = self.past + self.active.enrollment_end = self.past + self.active.save() + self.assertUnpublish(succeed=False) # fails if no marketable runs at all + + +class TestCourseEditor(TestCase): + """ Tests for the CourseEditor module. """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = factories.UserFactory() + cls.courses_qs = Course.objects.all() + cls.runs_qs = CourseRun.objects.all() + + cls.org_ext = OrganizationExtensionFactory() + + # *** Add a bunch of courses *** + + # Course with no editors + cls.course_no_editors = factories.CourseFactory(title="no editors") + cls.run_no_editors = factories.CourseRunFactory(course=cls.course_no_editors) + + # Course with an invalid editor (no group membership) + bad_editor = factories.UserFactory() + cls.course_bad_editor = factories.CourseFactory(title="bad editor") + cls.run_bad_editor = factories.CourseRunFactory(course=cls.course_bad_editor) + factories.CourseEditorFactory(user=bad_editor, course=cls.course_bad_editor) + + # Course with an invalid editor (but course is in our group) + cls.course_bad_editor_in_group = factories.CourseFactory(title="bad editor in group") + cls.course_bad_editor_in_group.authoring_organizations.add(cls.org_ext.organization) + cls.run_bad_editor_in_group = factories.CourseRunFactory(course=cls.course_bad_editor_in_group) + factories.CourseEditorFactory(user=bad_editor, course=cls.course_bad_editor_in_group) + + # Course with a valid other editor + cls.good_editor = factories.UserFactory() + cls.good_editor.groups.add(cls.org_ext.group) + cls.course_good_editor = factories.CourseFactory(title="good editor") + cls.course_good_editor.authoring_organizations.add(cls.org_ext.organization) + cls.run_good_editor = factories.CourseRunFactory(course=cls.course_good_editor) + factories.CourseEditorFactory(user=cls.good_editor, course=cls.course_good_editor) + + # Course with user as an invalid editor (no group membership) + cls.course_no_group = factories.CourseFactory(title="no group") + cls.run_no_group = factories.CourseRunFactory(course=cls.course_no_group) + factories.CourseEditorFactory(user=cls.user, course=cls.course_no_group) + + # Course with user as an valid editor + cls.course_editor = factories.CourseFactory(title="editor") + cls.course_editor.authoring_organizations.add(cls.org_ext.organization) + cls.run_editor = factories.CourseRunFactory(course=cls.course_editor) + factories.CourseEditorFactory(user=cls.user, course=cls.course_editor) + + # Add another authoring_org, which will cause django to return duplicates, if we don't filter them out + org_ext2 = OrganizationExtensionFactory() + cls.user.groups.add(org_ext2.group) + cls.course_editor.authoring_organizations.add(org_ext2.organization) + cls.course_bad_editor_in_group.authoring_organizations.add(org_ext2.organization) + + def setUp(self): + """ Resets self.user to not be staff and to belong to the self.org_ext group. """ + super().setUp() + self.user.groups.add(self.org_ext.group) + self.user.is_staff = False + self.user.save() + + def filter_editable_courses(self): + return CourseEditor.editable_courses(self.user, self.courses_qs) + + def filter_editable_course_runs(self): + return CourseEditor.editable_course_runs(self.user, self.runs_qs) + + def assertResultsEqual(self, method, expected_result, queries=None): + if queries is None: + result = list(method()) + else: + with self.assertNumQueries(queries): + result = list(method()) + + self.assertEqual(len(result), len(expected_result)) + self.assertEqual(set(result), set(expected_result)) + + def test_editable_is_staff(self): + """ Verify staff users can see everything. """ + self.user.is_staff = True + self.user.save() + self.assertResultsEqual(self.filter_editable_courses, self.courses_qs) + self.assertResultsEqual(self.filter_editable_course_runs, self.runs_qs) + + def test_editable_no_access(self): + """ Verify users without any editor status see nothing. """ + self.user.groups.clear() + self.assertResultsEqual(self.filter_editable_courses, [], queries=1) + self.assertResultsEqual(self.filter_editable_course_runs, [], queries=1) + + def test_editable_filter(self): + """ Verify users can see courses they can edit. """ + self.assertResultsEqual(self.filter_editable_courses, {self.course_bad_editor_in_group, self.course_editor}, + queries=1) + self.assertResultsEqual(self.filter_editable_course_runs, {self.run_bad_editor_in_group, self.run_editor}, + queries=1) + + def test_editable_without_checking_editors(self): + """ Verify the that we can get a list of *potentially editable* courses (courses in org). """ + self.assertResultsEqual( + partial(CourseEditor.editable_courses, self.user, self.courses_qs, check_editors=False), + {self.course_bad_editor_in_group, self.course_good_editor, self.course_editor}, + queries=1, + ) + + def test_course_editors_when_valid_editors(self): + self.assertResultsEqual(partial(CourseEditor.course_editors, self.course_editor), {self.user}, queries=1) + + def test_course_editors_when_no_editors(self): + # two queries: one to check for valid editors, one for everybody in group + self.assertResultsEqual( + partial(CourseEditor.course_editors, self.course_bad_editor_in_group), + {self.user, self.good_editor}, + queries=2, + ) + + def test_editors_for_user(self): + """Verify that the editors_for_user method returns editors for a give user""" + # tests number of editors against the editors established in the setUp method above + editors = CourseEditor.editors_for_user(self.user) + assert len(editors) == 5 + @ddt.ddt -class CourseRunTests(TestCase): +class CourseRunTests(OAuth2Mixin, TestCase): """ Tests for the `CourseRun` model. """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_run = factories.CourseRunFactory() + cls.partner = cls.course_run.course.partner + def setUp(self): - super(CourseRunTests, self).setUp() - self.course_run = factories.CourseRunFactory() + """ Reset self.course_run and self.partner to whatever the DB says. """ + super().setUp() + self.course_run.refresh_from_db() + self.course_run.course.refresh_from_db() + self.partner.refresh_from_db() def test_enrollable_seats(self): """ Verify the expected seats get returned. """ course_run = factories.CourseRunFactory(start=None, end=None, enrollment_start=None, enrollment_end=None) - verified_seat = factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None) - professional_seat = factories.SeatFactory(course_run=course_run, type=Seat.PROFESSIONAL, upgrade_deadline=None) - honor_seat = factories.SeatFactory(course_run=course_run, type=Seat.HONOR, upgrade_deadline=None) - assert course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]) == [verified_seat, professional_seat] + verified_seat_type = factories.SeatTypeFactory.verified() + professional_seat_type = factories.SeatTypeFactory.professional() + honor_seat_type = factories.SeatTypeFactory.honor() + verified_seat = factories.SeatFactory(course_run=course_run, type=verified_seat_type, upgrade_deadline=None) + professional_seat = factories.SeatFactory(course_run=course_run, type=professional_seat_type, + upgrade_deadline=None) + honor_seat = factories.SeatFactory(course_run=course_run, type=honor_seat_type, upgrade_deadline=None) + self.assertEqual(course_run.enrollable_seats([verified_seat_type, professional_seat_type]), + [verified_seat, professional_seat]) # The method should not care about the course run's start date. course_run.start = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) course_run.save() - assert course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]) == [verified_seat, professional_seat] + self.assertEqual(course_run.enrollable_seats([verified_seat_type, professional_seat_type]), + [verified_seat, professional_seat]) # Enrollable seats of any type should be returned when no type parameter is specified. - assert course_run.enrollable_seats() == [verified_seat, professional_seat, honor_seat] + self.assertEqual(course_run.enrollable_seats(), [verified_seat, professional_seat, honor_seat]) def test_has_enrollable_seats(self): """ Verify the expected value of has_enrollable_seats is returned. """ course_run = factories.CourseRunFactory(start=None, end=None, enrollment_start=None, enrollment_end=None) - factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None) + factories.SeatFactory(course_run=course_run, type=factories.SeatTypeFactory.verified(), upgrade_deadline=None) assert course_run.has_enrollable_seats is True course_run.end = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1) @@ -137,6 +411,18 @@ def test_str(self): course_run = self.course_run self.assertEqual(str(course_run), '{key}: {title}'.format(key=course_run.key, title=course_run.title)) + @ddt.data('full_description_override', 'outcome_override', 'short_description_override') + def test_html_fields_are_validated(self, field_name): + # Happy path + setattr(self.course_run, field_name, '

') + self.course_run.clean_fields() + + # Bad HTML + setattr(self.course_run, field_name, '') + with self.assertRaises(ValidationError) as cm: + self.course_run.clean_fields() + self.assertEqual(cm.exception.message_dict[field_name], ['Invalid HTML received']) + @ddt.data('title', 'short_description', 'full_description') def test_override_fields(self, field_name): """ Verify the `CourseRun`'s override field overrides the related `Course`'s field. """ @@ -178,8 +464,8 @@ def test_seat_types(self): self.assertEqual(self.course_run.seat_types, []) seats = factories.SeatFactory.create_batch(3, course_run=self.course_run) - expected = sorted([seat.type for seat in seats]) - self.assertEqual(sorted(self.course_run.seat_types), expected) + expected = sorted(seat.type.slug for seat in seats) + self.assertEqual(sorted(seat_type.slug for seat_type in self.course_run.seat_types), expected) @ddt.data( ('obviously-wrong', None,), @@ -191,11 +477,12 @@ def test_seat_types(self): (('no-id-professional',), 'professional',), ) @ddt.unpack - def test_type(self, seat_types, expected_course_run_type): + def test_type_legacy(self, seat_types, expected_course_run_type): """ Verify the property returns the appropriate type string for the CourseRun. """ for seat_type in seat_types: - factories.SeatFactory(course_run=self.course_run, type=seat_type) - self.assertEqual(self.course_run.type, expected_course_run_type) + type_obj = SeatType.objects.update_or_create(slug=seat_type, defaults={'name': seat_type})[0] + factories.SeatFactory(course_run=self.course_run, type=type_obj) + self.assertEqual(self.course_run.type_legacy, expected_course_run_type) def test_level_type(self): """ Verify the property returns the associated Course's level type. """ @@ -224,7 +511,7 @@ def test_availability(self, start, end, expected_availability): def test_marketing_url(self): """ Verify the property constructs a marketing URL based on the marketing slug. """ - expected = '{root}/course/{slug}'.format(root=self.course_run.course.partner.marketing_site_url_root.strip('/'), + expected = '{root}/course/{slug}'.format(root=self.partner.marketing_site_url_root.strip('/'), slug=self.course_run.slug) self.assertEqual(self.course_run.marketing_url, expected) @@ -234,19 +521,18 @@ def test_marketing_url_with_empty_marketing_slug(self): self.assertIsNone(self.course_run.marketing_url) def test_slug_defined_on_create(self): - """ Verify the slug is created on first save from the title. """ + """ Verify the slug is created on first save from the title and key. """ course_run = CourseRunFactory(title='Test Title') - self.assertEqual(course_run.slug, 'test-title') + slug_key = uslugify(course_run.key) + self.assertEqual(course_run.slug, 'test-title-{slug_key}'.format(slug_key=slug_key)) def test_empty_slug_defined_on_save(self): - """ Verify the slug is defined on publication if it wasn't set already. """ - toggle_switch('publish_course_runs_to_marketing_site') - - with mock.patch.object(CourseRunMarketingSitePublisher, 'publish_obj', return_value=None): - self.course_run.slug = '' - self.course_run.title = 'Test Title' - self.course_run.save() - self.assertEqual(self.course_run.slug, 'test-title') + """ Verify the slug is defined on save if it wasn't set already. """ + self.course_run.slug = '' + self.course_run.title = 'Test Title' + self.course_run.save() + slug_key = uslugify(self.course_run.key) + self.assertEqual(self.course_run.slug, 'test-title-{slug_key}'.format(slug_key=slug_key)) def test_program_types(self): """ Verify the property retrieves program types correctly based on programs. """ @@ -302,7 +588,7 @@ def test_has_enrollable_paid_seats(self, seat_config, expected_result): """ course_run = factories.CourseRunFactory.create() for seat_type, price in seat_config: - factories.SeatFactory.create(course_run=course_run, type=seat_type, price=price) + factories.SeatFactory.create(course_run=course_run, type=SeatType.objects.get(slug=seat_type), price=price) self.assertEqual(course_run.has_enrollable_paid_seats(), expected_result) def test_first_enrollable_paid_seat_sku(self): @@ -310,7 +596,8 @@ def test_first_enrollable_paid_seat_sku(self): Verify that first_enrollable_paid_seat_sku returns sku of first paid seat. """ course_run = factories.CourseRunFactory.create() - factories.SeatFactory.create(course_run=course_run, type='verified', price=10, sku='ABCDEF') + factories.SeatFactory.create(course_run=course_run, type=factories.SeatTypeFactory.verified(), price=10, + sku='ABCDEF') self.assertEqual(course_run.first_enrollable_paid_seat_sku(), 'ABCDEF') def test_first_enrollable_paid_seat_price(self): @@ -318,7 +605,8 @@ def test_first_enrollable_paid_seat_price(self): Verify that first_enrollable_paid_seat_price returns price of first paid seat. """ course_run = factories.CourseRunFactory.create() - factories.SeatFactory.create(course_run=course_run, type='verified', price=10, sku='ABCDEF') + factories.SeatFactory.create(course_run=course_run, type=factories.SeatTypeFactory.verified(), price=10, + sku='ABCDEF') self.assertEqual(course_run.first_enrollable_paid_seat_price, 10) @ddt.data( @@ -370,7 +658,8 @@ def test_get_paid_seat_enrollment_end(self, seat_config, course_end, course_enro course_run = factories.CourseRunFactory.create(end=end, enrollment_end=enrollment_end) for seat_type, price, deadline in seat_config: deadline = parse(deadline) if deadline else None - factories.SeatFactory.create(course_run=course_run, type=seat_type, price=price, upgrade_deadline=deadline) + factories.SeatFactory.create(course_run=course_run, type=SeatType.objects.get(slug=seat_type), price=price, + upgrade_deadline=deadline) expected_result = parse(expected_result) if expected_result else None self.assertEqual(course_run.get_paid_seat_enrollment_end(), expected_result) @@ -397,43 +686,313 @@ def test_is_current_and_still_upgradeable(self, start, end, deadline, is_current and false otherwise """ course_run = factories.CourseRunFactory.create(start=start, end=end, enrollment_end=end) - factories.SeatFactory.create(course_run=course_run, upgrade_deadline=deadline, type='verified', price=1) + factories.SeatFactory.create(course_run=course_run, upgrade_deadline=deadline, + type=factories.SeatTypeFactory.verified(), price=1) assert course_run.is_current_and_still_upgradeable() == is_current + def test_image_url(self): + assert self.course_run.image_url == self.course_run.course.image_url + + def test_get_video(self): + assert self.course_run.get_video == self.course_run.video + self.course_run.video = None + self.course_run.save() + assert self.course_run.get_video == self.course_run.course.video + + @ddt.data( + (None, False), + (datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10), False), + (datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10), True), + ) + @ddt.unpack + @mock.patch('course_discovery.apps.course_metadata.emails.send_email_for_reviewed') + def test_reviewed_with_go_live_date(self, when, published, mock_email): + draft = factories.CourseRunFactory( + draft=True, + go_live_date=when, + announcement=None, + ) + end = when + datetime.timedelta(days=50) if when else None + if end: # Both end and enrollment_end need to be in the future or else runs will be set to unpublished + draft.end = end + draft.enrollment_end = end + draft.course.draft = True + draft.course.save() + + # force this prop to be cached, to catch any errors if we assume .official_version is valid after creation + self.assertIsNone(draft.official_version) + + draft.status = CourseRunStatus.Reviewed + draft.save() + draft.refresh_from_db() + official_version = CourseRun.objects.get(key=draft.key) + + for run in [draft, official_version]: + if published: + self.assertEqual(run.status, CourseRunStatus.Published) + self.assertIsNotNone(run.announcement) + self.assertEqual(mock_email.call_count, 0) + else: + self.assertEqual(run.status, CourseRunStatus.Reviewed) + self.assertIsNone(run.announcement) + self.assertEqual(mock_email.call_count, 1) + + def test_publish_ignores_draft_input(self): + draft = factories.CourseRunFactory(status=CourseRunStatus.Unpublished, draft=True) + self.assertFalse(draft.publish()) + self.assertEqual(draft.status, CourseRunStatus.Unpublished) + + def test_publish_affects_draft_version_too(self): + end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + draft = factories.CourseRunFactory( + status=CourseRunStatus.Unpublished, announcement=None, + draft=True, end=end, enrollment_end=end, + ) + official = factories.CourseRunFactory( + status=CourseRunStatus.Unpublished, announcement=None, draft=False, + course=draft.course, draft_version=draft, end=end, enrollment_end=end, + ) + + self.assertTrue(official.publish()) + draft.refresh_from_db() + + self.assertEqual(draft.status, CourseRunStatus.Published) + self.assertIsNotNone(draft.announcement) + self.assertEqual(official.status, CourseRunStatus.Published) + self.assertIsNotNone(official.announcement) + + def test_publish_adds_slug_to_course(self): + to_publish = factories.CourseRunFactory(status=CourseRunStatus.Unpublished, draft=False) + current_active_course_slug = to_publish.course.active_url_slug + to_publish.publish() + all_slugs_as_list = [slug_obj.url_slug for slug_obj in to_publish.course.url_slug_history.all()] + self.assertIn(to_publish.slug, all_slugs_as_list) + self.assertEqual(to_publish.course.active_url_slug, current_active_course_slug) + + def test_publish_does_not_add_duplicate_slugs(self): + course = factories.CourseFactory(draft=False) + to_publish = factories.CourseRunFactory(status=CourseRunStatus.Unpublished, draft=False, course=course) + course.set_active_url_slug(to_publish.slug) + to_publish.publish() + self.assertEqual(course.active_url_slug, to_publish.slug) + self.assertEqual(course.url_slug_history.count(), 2) + + def test_publish_errors_if_slug_exists_on_other_course(self): + course1 = factories.CourseFactory(draft=False) + course2 = factories.CourseFactory(draft=False, partner=course1.partner) + to_publish = factories.CourseRunFactory(status=CourseRunStatus.Unpublished, draft=False, course=course1) + course2.set_active_url_slug(to_publish.slug) + with self.assertRaises(IntegrityError): + to_publish.publish() + + @ddt.data( + (None, None, True), # No enrollment start or end + ( + datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10), + None, + True, + ), # Enroll start in past, no enroll end + ( + datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10), + None, + False, + ), # Enroll start in future, no enroll end + ( + None, + datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10), + False, + ), # No enroll start, enroll end in past + ( + None, + datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10), + True, + ), # No enroll start, enroll end in future + ( + datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10), + datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10), + True, + ), # Enroll start in past, enroll end in future + ) + @ddt.unpack + def test_is_enrollable(self, enrollment_start, enrollment_end, expected): + course_run = factories.CourseRunFactory.create( + end=None, enrollment_start=enrollment_start, enrollment_end=enrollment_end, + ) + self.assertEqual(course_run.is_enrollable, expected) + + @ddt.data( + (CourseRunStatus.Unpublished, False, False, False), # Not published, no seats, no marketing url + (CourseRunStatus.Unpublished, False, True, False), # Not published, no seats, marketing url + (CourseRunStatus.Unpublished, True, False, False), # Not published, seats, no marketing url + (CourseRunStatus.Unpublished, True, False, False), # Not published, seats, marketing url + (CourseRunStatus.Published, False, False, False), # Published, no seats, no marketing url + (CourseRunStatus.Published, True, False, False), # Published, seats, no marketing url + (CourseRunStatus.Published, False, True, False), # Published, no seats, marketing url + (CourseRunStatus.Published, True, True, True), # Published, seats, marketing url + (CourseRunStatus.Published, True, True, True), # Published, seats, marketing url + ) + @ddt.unpack + def test_is_marketable(self, status, create_seats, create_marketing_url, expected): + course_run = factories.CourseRunFactory.create(status=status) + if not create_marketing_url: + course_run.slug = None + if create_seats: + factories.SeatFactory.create(course_run=course_run) + + self.assertEqual(course_run.is_marketable, expected) + + @ddt.data( + (True, False, False), # Draft, CourseRunType.is_marketable, expected + (True, True, False), + (False, False, False), + (False, True, True), + ) + @ddt.unpack + def test_could_be_marketable(self, draft, type_is_marketable, expected): + course_run = factories.CourseRunFactory(status=CourseRunStatus.Published, draft=draft, + type__is_marketable=type_is_marketable) + factories.SeatFactory.create(course_run=course_run) + self.assertEqual(course_run.is_marketable, expected) + self.assertEqual(course_run.could_be_marketable, expected) + + with mock.patch.object(CourseRunMarketingSitePublisher, 'publish_obj', return_value=None) as mock_publish_obj: + with override_switch('publish_course_runs_to_marketing_site', True): + course_run.save() + self.assertEqual(mock_publish_obj.called, expected) + + +class CourseRunTestsThatNeedSetUp(OAuth2Mixin, TestCase): + """ + Tests for the `CourseRun` model where the course_run fixture object + REALLY needs to be re-created before each test. + """ + + def setUp(self): + super().setUp() + subject = factories.SubjectFactory() + self.course_run = factories.CourseRunFactory(course__subjects=[subject]) + self.partner = self.course_run.course.partner + + def mock_ecommerce_publication(self): + url = '{root}publication/'.format(root=self.partner.ecommerce_api_url) + responses.add(responses.POST, url, json={}, status=200) + + def test_official_created(self): + self.mock_access_token() + self.mock_ecommerce_publication() + + self.course_run.draft = True + self.course_run.status = CourseRunStatus.Reviewed + self.course_run.course.draft = True + factories.SeatFactory(course_run=self.course_run, draft=True) + # We have to specify a SeatType that exists in Seat.ENTITLEMENT_MODES in order for the + # official version of the Entitlement to be created + entitlement_mode = SeatTypeFactory.verified() + factories.CourseEntitlementFactory(course=self.course_run.course, mode=entitlement_mode, draft=True) + self.course_run.course.save() + self.course_run.save() + assert CourseRun.everything.all().count() == 2 + official_run = CourseRun.everything.get(key=self.course_run.key, draft=False) + draft_run = CourseRun.everything.get(key=self.course_run.key, draft=True) + + assert official_run.draft_version == draft_run + assert official_run != draft_run + assert official_run.slug == draft_run.slug + + assert official_run.course.draft is False + assert official_run.course.draft_version == draft_run.course + assert official_run.course != draft_run.course + assert official_run.course.slug == draft_run.course.slug + + official_entitlement = official_run.course.entitlements.first() + draft_entitlement = draft_run.course.entitlements.first() + assert official_entitlement.draft_version == draft_entitlement + assert official_entitlement.draft is False + assert draft_entitlement.draft is True + assert official_entitlement != draft_entitlement + + official_seat = official_run.seats.first() + draft_seat = draft_run.seats.first() + assert official_seat.draft_version == draft_seat + assert official_seat.draft is False + assert draft_seat.draft is True + assert official_seat != draft_seat + + def test_official_canonical_updates_to_official(self): + self.course_run.draft = True + self.course_run.status = CourseRunStatus.Reviewed + self.course_run.course.draft = True + self.course_run.course.save() + self.course_run.save() + + official_run = CourseRun.everything.get(key=self.course_run.key, draft=False) + assert official_run.course.canonical_course_run == official_run + + draft_run = CourseRun.everything.get(key=self.course_run.key, draft=True) + assert draft_run.course.canonical_course_run == draft_run + + def test_canonical_becomes_first_reviewed(self): + course = factories.CourseFactory(draft=True) + (run_a, run_b) = tuple(factories.CourseRunFactory.create_batch(2, course=course, draft=True)) + course.canonical_course_run = run_a + run_b.status = CourseRunStatus.Reviewed + course.save() + run_a.save() + run_b.save() + + draft_run_b = CourseRun.everything.get(key=run_b.key, draft=True) + assert draft_run_b.course.canonical_course_run == draft_run_b + + official_run_b = CourseRun.everything.get(key=run_b.key, draft=False) + assert official_run_b.course.canonical_course_run == official_run_b + + def test_no_duplicate_official(self): + self.course_run.course.draft = True + self.course_run.course.save() + official_course = factories.CourseFactory.create(partner=self.course_run.course.partner) + official_course.draft_version = self.course_run.course + official_course.save() + + self.course_run.draft = True + official_version = factories.CourseRunFactory.create(course=official_course, status=CourseRunStatus.Unpublished) + official_version.draft_version = self.course_run + official_version.save() + + self.course_run.status = CourseRunStatus.Reviewed + self.course_run.save() + assert CourseRun.everything.all().count() == 2 + assert Course.everything.all().count() == 2 + def test_publication_disabled(self): """ Verify that the publisher is not initialized when publication is disabled. """ - toggle_switch('publish_course_runs_to_marketing_site', active=False) - with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init: self.course_run.save() self.course_run.delete() assert mock_init.call_count == 0 - toggle_switch('publish_course_runs_to_marketing_site') + with override_switch('publish_course_runs_to_marketing_site', True): + with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init: + # Make sure if the save comes from refresh_course_metadata, we don't actually publish + self.course_run.save(suppress_publication=True) + assert mock_init.call_count == 0 - with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init: - # Make sure if the save comes from refresh_course_metadata, we don't actually publish - self.course_run.save(suppress_publication=True) - assert mock_init.call_count == 0 + self.partner.marketing_site_url_root = '' + self.partner.save() - self.course_run.course.partner.marketing_site_url_root = '' - self.course_run.course.partner.save() + with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init: + self.course_run.save() + self.course_run.delete() - with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init: - self.course_run.save() - self.course_run.delete() - - assert mock_init.call_count == 0 + assert mock_init.call_count == 0 + @override_switch('publish_course_runs_to_marketing_site', True) def test_publication_enabled(self): """ Verify that the publisher is called when publication is enabled. """ - toggle_switch('publish_course_runs_to_marketing_site') - with mock.patch.object(CourseRunMarketingSitePublisher, 'publish_obj', return_value=None) as mock_publish_obj: self.course_run.save() assert mock_publish_obj.called @@ -443,23 +1002,110 @@ def test_publication_enabled(self): # We don't want to delete course run nodes when CourseRuns are deleted. assert not mock_delete_obj.called - def test_image_url(self): - assert self.course_run.image_url == self.course_run.course.image_url + def test_push_tracks_to_lms(self): + """ + Verify that we notify the LMS about tracks without seats on a save() to reviewed + """ + self.partner.lms_url = 'http://127.0.0.1:8000' + self.partner.save() + self.mock_access_token() + self.mock_ecommerce_publication() + url = '{root}courses/{key}/'.format(root=self.partner.lms_coursemode_api_url, key=self.course_run.key) + + # Mark course as draft + self.course_run.course.draft = True + self.course_run.course.save() + + # Set up and save run + self.course_run.type = factories.CourseRunTypeFactory( + tracks=[ + factories.TrackFactory(mode__slug='no-seat', seat_type=None), + factories.TrackFactory(mode__slug='has-seat'), + ], + ) + self.course_run.draft = True + self.course_run.status = CourseRunStatus.Reviewed - def test_get_video(self): - assert self.course_run.get_video == self.course_run.video - self.course_run.video = None + responses.add(responses.GET, url, json=[], status=200) + responses.add(responses.POST, url, json={}, status=201) + with LogCapture(utils_logger.name) as log_capture: + self.course_run.save() + log_capture.check_present((utils_logger.name, 'INFO', + 'Successfully published [no-seat] LMS mode for [%s].' % self.course_run.key)) + + # Test that we don't re-publish modes + self.course_run.status = CourseRunStatus.Unpublished self.course_run.save() - assert self.course_run.get_video == self.course_run.course.video + self.course_run.status = CourseRunStatus.Reviewed + responses.replace(responses.GET, url, json=[{'mode_slug': 'no-seat'}], status=200) + with LogCapture(utils_logger.name) as log_capture: + self.course_run.save() + log_capture.check() # no messages at all, we skipped sending + + # Test we report failures + self.course_run.status = CourseRunStatus.Unpublished + self.course_run.save() + self.course_run.status = CourseRunStatus.Reviewed + responses.replace(responses.GET, url, json=[], status=200) + responses.replace(responses.POST, url, body='Shrug', status=500) + with LogCapture(utils_logger.name) as log_capture: + self.course_run.save() + log_capture.check_present((utils_logger.name, 'WARNING', + 'Failed publishing [no-seat] LMS mode for [%s]: Shrug' % self.course_run.key)) + + def test_verified_seat_upgrade_deadline_override(self): + self.mock_access_token() + self.mock_ecommerce_publication() + + self.course_run.draft = True + self.course_run.status = CourseRunStatus.Reviewed + self.course_run.course.draft = True + upgrade_deadline = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10) + verified_type = SeatTypeFactory.verified() + factories.SeatFactory( + course_run=self.course_run, + draft=True, + type=verified_type, + upgrade_deadline=upgrade_deadline + ) + + factories.CourseEntitlementFactory(course=self.course_run.course, mode=verified_type, draft=True) + self.course_run.course.save() + self.course_run.save() + draft_seat = Seat.everything.get(course_run=self.course_run, draft=True, type=verified_type) + official_run = CourseRun.everything.get(key=self.course_run.key, draft=False) + official_seat = Seat.everything.get(course_run=official_run, draft=False, type=verified_type) + + assert draft_seat.upgrade_deadline == upgrade_deadline + assert official_seat.upgrade_deadline == upgrade_deadline + + # Simulate updating the draft seat in Django admin which is how we currently support changing it + new_deadline = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=15) + draft_seat.upgrade_deadline = new_deadline + draft_seat.save() + + draft_run = CourseRun.everything.get(key=self.course_run.key, draft=True) + draft_run.update_or_create_official_version() + + draft_seat = Seat.everything.get(course_run=self.course_run, draft=True, type=verified_type) + official_seat = Seat.everything.get(course_run=official_run, draft=False, type=verified_type) + assert draft_run.seats.get(type=verified_type).upgrade_deadline == new_deadline + assert official_run.seats.get(type=verified_type).upgrade_deadline == new_deadline @ddt.ddt class OrganizationTests(TestCase): """ Tests for the `Organization` model. """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.organization = factories.OrganizationFactory() + cls._original_org_key = cls.organization.key + def setUp(self): - super(OrganizationTests, self).setUp() - self.organization = factories.OrganizationFactory() + super().setUp() + self.organization.key = self._original_org_key @ddt.data( [" ", ",", "@", "(", "!", "#", "$", "%", "^", "&", "*", "+", "=", "{", "[", "ó"] @@ -490,16 +1136,28 @@ def test_str(self): def test_marketing_url(self): """ Verify the property creates a complete marketing URL. """ - expected = '{root}/{slug}'.format(root=self.organization.partner.marketing_site_url_root.strip('/'), - slug=self.organization.marketing_url_path) + expected = '{root}/school/{slug}'.format(root=self.organization.partner.marketing_site_url_root.strip('/'), + slug=self.organization.slug) self.assertEqual(self.organization.marketing_url, expected) - def test_marketing_url_without_marketing_url_path(self): - """ Verify the property returns None if the Organization has no marketing_url_path set. """ - self.organization.marketing_url_path = '' + def test_marketing_url_without_slug(self): + """ Verify the property returns None if the Organization has no slug set. """ + self.organization.slug = '' self.assertIsNone(self.organization.marketing_url) + def test_user_organizations(self): + """Verify that the user_organizations method returns organizations for a given user""" + user = factories.UserFactory() + + self.assertFalse(Organization.user_organizations(user)) + + org_ext = OrganizationExtensionFactory() + user.groups.add(org_ext.group) + assert len(Organization.user_organizations(user)) == 1 + + +@ddt.ddt class PersonTests(TestCase): """ Tests for the `Person` model. """ @@ -550,6 +1208,18 @@ def test_str(self): """ Verify casting an instance to a string returns the person's full name. """ self.assertEqual(str(self.person), self.person.full_name) + @ddt.data('bio', 'major_works') + def test_html_fields_are_validated(self, field_name): + # Happy path + setattr(self.person, field_name, '

') + self.person.clean_fields() + + # Bad HTML + setattr(self.person, field_name, '') + with self.assertRaises(ValidationError) as cm: + self.person.clean_fields() + self.assertEqual(cm.exception.message_dict[field_name], ['Invalid HTML received']) + class PositionTests(TestCase): """ Tests for the `Position` model. """ @@ -634,61 +1304,80 @@ class TestAbstractTitleDescriptionModel(AbstractTitleDescriptionModel): class ProgramTests(TestCase): """Tests of the Program model.""" - def setUp(self): - super(ProgramTests, self).setUp() + @classmethod + def setUpClass(cls): + """ + Creates fixture subjects, course_runs, courses, and programs from factories. + """ + super(ProgramTests, cls).setUpClass() transcript_languages = LanguageTag.objects.all()[:2] - self.subjects = factories.SubjectFactory.create_batch(3) - self.course_runs = factories.CourseRunFactory.create_batch( - 3, transcript_languages=transcript_languages, course__subjects=self.subjects, + cls.subjects = factories.SubjectFactory.create_batch(3) + cls.course_runs = factories.CourseRunFactory.create_batch( + 3, transcript_languages=transcript_languages, course__subjects=cls.subjects, weeks_to_complete=2) - self.courses = [course_run.course for course_run in self.course_runs] - self.excluded_course_run = factories.CourseRunFactory(course=self.courses[0]) - self.program = factories.ProgramFactory(courses=self.courses, excluded_course_runs=[self.excluded_course_run]) + cls.courses = [course_run.course for course_run in cls.course_runs] + cls.excluded_course_run = factories.CourseRunFactory(course=cls.courses[0]) + cls.program = factories.ProgramFactory(courses=cls.courses, excluded_course_runs=[cls.excluded_course_run]) + cls.other_course_run = factories.CourseRunFactory() + cls.other_program = factories.ProgramFactory(courses=[cls.other_course_run.course]) + + def tearDown(self): + """ + Resets course canonical_course_runs to initial state. + """ + super(ProgramTests, self).tearDown() + for course_run in self.course_runs[:2]: + course = course_run.course + course.canonical_course_run = None + course.save() + + # pylint: disable=access-member-before-definition, attribute-defined-outside-init def create_program_with_seats(self): + if getattr(self, '__program_with_seats', None): + return self.__program_with_seats + currency = Currency.objects.get(code='USD') course_run = factories.CourseRunFactory() course_run.course.canonical_course_run = course_run course_run.course.save() - factories.SeatFactory(type='audit', currency=currency, course_run=course_run, price=0) - factories.SeatFactory(type='credit', currency=currency, course_run=course_run, price=600) - factories.SeatFactory(type='verified', currency=currency, course_run=course_run, price=100) + audit_seat_type = factories.SeatTypeFactory.audit() + credit_seat_type = factories.SeatTypeFactory.credit() + verified_seat_type = factories.SeatTypeFactory.verified() + factories.SeatFactory(type=audit_seat_type, currency=currency, course_run=course_run, price=0) + factories.SeatFactory(type=credit_seat_type, currency=currency, course_run=course_run, price=600) + factories.SeatFactory(type=verified_seat_type, currency=currency, course_run=course_run, price=100) - applicable_seat_types = SeatType.objects.filter(slug__in=['credit', 'verified']) - program_type = factories.ProgramTypeFactory(applicable_seat_types=applicable_seat_types) + program_type = factories.ProgramTypeFactory(applicable_seat_types=[credit_seat_type, verified_seat_type]) - return factories.ProgramFactory(type=program_type, courses=[course_run.course]) + self.__program_with_seats = factories.ProgramFactory(type=program_type, courses=[course_run.course]) + return self.__program_with_seats def test_search(self): """ Verify that the program endpoint correctly handles basic elasticsearch queries """ - title = 'Some random title' - expected = set(factories.ProgramFactory.create_batch(1, title=title)) - # Create an extra program that should not show up - factories.ProgramFactory() - query = 'title:' + title - self.assertSetEqual(set(Program.search(query)), expected) + query = 'title:' + self.program.title + self.assertSetEqual(set(Program.search(query)), set([self.program])) def test_subject_search(self): """ Verify that the program endpoint correctly handles elasticsearch queries on the subject uuid """ - subject = factories.SubjectFactory() - course = factories.CourseFactory(subjects=[subject]) - expected = set(factories.ProgramFactory.create_batch(1, courses=[course])) - # Create an extra program that should not show up - factories.ProgramFactory() - query = str(subject.uuid) - self.assertSetEqual(set(Program.search(query)), expected) + query = str(self.subjects[0].uuid) + self.assertSetEqual(set(Program.search(query)), set([self.program])) + # pylint: disable=access-member-before-definition, attribute-defined-outside-init def create_program_with_entitlements_and_seats(self): - verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) + if getattr(self, '__entitlements_program_and_courses', None): + return self.__entitlements_program_and_courses + + verified_seat_type = SeatTypeFactory.verified() program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) courses = [] for __ in range(3): - entitlement = factories.CourseEntitlementFactory(mode=verified_seat_type, expires=None) + entitlement = factories.CourseEntitlementFactory(mode=verified_seat_type) for __ in range(3): factories.SeatFactory( course_run=factories.CourseRunFactory( @@ -696,7 +1385,7 @@ def create_program_with_entitlements_and_seats(self): enrollment_end=None, course=entitlement.course ), - type=Seat.VERIFIED, upgrade_deadline=None + type=verified_seat_type, upgrade_deadline=None ) courses.append(entitlement.course) @@ -705,15 +1394,17 @@ def create_program_with_entitlements_and_seats(self): one_click_purchase_enabled=True, type=program_type, ) + self.__entitlements_program_and_courses = (program, courses) return program, courses def assert_one_click_purchase_ineligible_program( - self, end=None, enrollment_start=None, enrollment_end=None, seat_type=Seat.VERIFIED, + self, end=None, enrollment_start=None, enrollment_end=None, seat_type=None, upgrade_deadline=None, one_click_purchase_enabled=True, excluded_course_runs=None, program_type=None ): course_run = factories.CourseRunFactory( end=end, enrollment_start=enrollment_start, enrollment_end=enrollment_end ) + seat_type = seat_type or SeatTypeFactory.verified() factories.SeatFactory(course_run=course_run, type=seat_type, upgrade_deadline=upgrade_deadline) program = factories.ProgramFactory( courses=[course_run.course], @@ -772,7 +1463,7 @@ def test_clean_enrollment_counts_on_save(self): def test_one_click_purchase_eligible(self): """ Verify that program is one click purchase eligible. """ - verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) + verified_seat_type = SeatTypeFactory.verified() program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) # Program has one_click_purchase_enabled set to True, @@ -784,7 +1475,7 @@ def test_one_click_purchase_eligible(self): end=None, enrollment_end=None ) - factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None) + factories.SeatFactory(course_run=course_run, type=verified_seat_type, upgrade_deadline=None) courses.append(course_run.course) program = factories.ProgramFactory( courses=courses, @@ -800,7 +1491,7 @@ def test_one_click_purchase_eligible(self): end=None, enrollment_end=None ) - factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None) + factories.SeatFactory(course_run=course_run, type=verified_seat_type, upgrade_deadline=None) course = course_run.course excluded_course_runs = [ factories.CourseRunFactory(course=course), @@ -815,72 +1506,43 @@ def test_one_click_purchase_eligible(self): self.assertTrue(program.is_program_eligible_for_one_click_purchase) def test_one_click_purchase_eligible_with_entitlements(self): - """ Verify that program is one click purchase eligible when its courses have unexpired entitlement products. """ + """ Verify that program is one click purchase eligible when its courses have entitlement products. """ # Program has one_click_purchase_enabled set to True, # all courses have a verified mode entitlement product and multiple course runs. program, __ = self.create_program_with_entitlements_and_seats() self.assertTrue(program.is_program_eligible_for_one_click_purchase) - def test_one_click_purchase_ineligible_expired_entitlement(self): - """ Verify that program is not one click purchase eligible if course entitlement product is expired. """ - program, courses = self.create_program_with_entitlements_and_seats() - expired_entitlement = courses[2].entitlements.first() - expired_entitlement.expires = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=7) - expired_entitlement.save() - self.assertFalse(program.is_program_eligible_for_one_click_purchase) - - def test_one_click_purchase_eligible_expired_entitlement_one_run(self): - """ - Verify that program is one click purchase eligible if there is only one - published course run for the course whose entitlement product is expired. - """ - program, courses = self.create_program_with_entitlements_and_seats() - expired_entitlement = courses[2].entitlements.first() - expired_entitlement.expires = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=7) - expired_entitlement.save() - CourseRun.objects.filter(course=courses[2]).delete() - factories.SeatFactory( - course_run=factories.CourseRunFactory( - end=None, - enrollment_end=None, - course=courses[2] - ), - type=Seat.VERIFIED, upgrade_deadline=None - ) - self.assertTrue(program.is_program_eligible_for_one_click_purchase) - - def test_one_click_purchase_eligible_future_expires(self): - """ Verify that program is one click purchase eligible if course entitlement product expires in the future. """ - program, courses = self.create_program_with_entitlements_and_seats() - future_expiring_entitlement = courses[1].entitlements.first() - future_expiring_entitlement.expires = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=7) - future_expiring_entitlement.save() - self.assertTrue(program.is_program_eligible_for_one_click_purchase) - def test_one_click_purchase_ineligible_wrong_mode(self): """ Verify that program is not one click purchase eligible if course entitlement product has the wrong mode. """ program, courses = self.create_program_with_entitlements_and_seats() - honor_seat_type, __ = SeatType.objects.get_or_create(name=Seat.HONOR) + honor_seat_type = SeatTypeFactory.honor() honor_mode_entitlement = courses[0].entitlements.first() + original_mode = honor_mode_entitlement.mode honor_mode_entitlement.mode = honor_seat_type honor_mode_entitlement.save() self.assertFalse(program.is_program_eligible_for_one_click_purchase) + # clean up local modifications + honor_mode_entitlement.mode = original_mode + honor_mode_entitlement.mode.save() + def test_one_click_purchase_ineligible_multiple_entitlements(self): """ Verify that program is not one click purchase eligible if course has multiple entitlement products with correct modes. """ program, courses = self.create_program_with_entitlements_and_seats() - credit_seat_type, __ = SeatType.objects.get_or_create(name=Seat.CREDIT) + credit_seat_type = SeatTypeFactory.credit() program.type.applicable_seat_types.add(credit_seat_type) - factories.CourseEntitlementFactory(mode=credit_seat_type, expires=None, course=courses[0]) - self.assertFalse(program.is_program_eligible_for_one_click_purchase) + # We are limiting each course to a single entitlement so this should raise an IntegrityError + with transaction.atomic(): + with self.assertRaises(IntegrityError): + factories.CourseEntitlementFactory(mode=credit_seat_type, course=courses[0]) def test_one_click_purchase_eligible_with_unpublished_runs(self): """ Verify that program with unpublished course runs is one click purchase eligible. """ - verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) + verified_seat_type = SeatTypeFactory.verified() program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) published_course_run = factories.CourseRunFactory( end=None, @@ -893,8 +1555,8 @@ def test_one_click_purchase_eligible_with_unpublished_runs(self): status=CourseRunStatus.Unpublished, course=published_course_run.course ) - factories.SeatFactory(course_run=published_course_run, type=Seat.VERIFIED, upgrade_deadline=None) - factories.SeatFactory(course_run=unpublished_course_run, type=Seat.VERIFIED, upgrade_deadline=None) + factories.SeatFactory(course_run=published_course_run, type=verified_seat_type, upgrade_deadline=None) + factories.SeatFactory(course_run=unpublished_course_run, type=verified_seat_type, upgrade_deadline=None) program = factories.ProgramFactory( courses=[published_course_run.course], one_click_purchase_enabled=True, @@ -906,7 +1568,7 @@ def test_one_click_purchase_ineligible(self): """ Verify that program is one click purchase ineligible. """ yesterday = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1) tomorrow = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) - verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) + verified_seat_type = SeatTypeFactory.verified() program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) # Program has one_click_purchase_enabled set to False and @@ -919,8 +1581,8 @@ def test_one_click_purchase_ineligible(self): # Program has one_click_purchase_enabled set to True and # one course has two course runs course_run = factories.CourseRunFactory(end=None, enrollment_end=None) - factories.CourseRunFactory(end=None, enrollment_end=None, course=course_run.course) - factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None) + second_course_run = factories.CourseRunFactory(end=None, enrollment_end=None, course=course_run.course) + factories.SeatFactory(course_run=course_run, type=verified_seat_type, upgrade_deadline=None) program = factories.ProgramFactory( courses=[course_run.course], one_click_purchase_enabled=True, @@ -930,14 +1592,9 @@ def test_one_click_purchase_ineligible(self): # Program has one_click_purchase_enabled set to True and # one course with one course run excluded from the program - course_run = factories.CourseRunFactory(end=None, enrollment_end=None) - factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None) - program = factories.ProgramFactory( - courses=[course_run.course], - one_click_purchase_enabled=True, - excluded_course_runs=[course_run], - type=program_type, - ) + second_course_run.delete() + program.excluded_course_runs.set([course_run]) + program.save() self.assertFalse(program.is_program_eligible_for_one_click_purchase) # Program has one_click_purchase_enabled set to True, one course @@ -971,7 +1628,7 @@ def test_one_click_purchase_ineligible(self): # Program has one_click_purchase_enabled set to True, one course # with one course run, seat type is not purchasable self.assert_one_click_purchase_ineligible_program( - seat_type='incorrect', + seat_type=SeatTypeFactory.credit(), program_type=program_type, ) @@ -1011,22 +1668,18 @@ def test_marketing_url_without_slug(self): def test_course_runs(self): """ - Verify that we only fetch course runs for the program, and not other course runs for other programs and that the - property returns the set of associated CourseRuns minus those that are explicitly excluded. + Verify that we only fetch course runs for the program, and not other course runs for other programs. + Also verify that the property returns the set of associated + CourseRuns minus those that are explicitly excluded. """ - course_run = factories.CourseRunFactory() - factories.ProgramFactory(courses=[course_run.course]) - # Verify that course run is not returned in set + # Verify that self.other_course_run is not returned in set self.assertEqual(set(self.program.course_runs), set(self.course_runs)) def test_canonical_course_runs(self): - course = self.course_runs[0].course - course.canonical_course_run = self.course_runs[0] - course.save() - - course = self.course_runs[1].course - course.canonical_course_run = self.course_runs[1] - course.save() + for course_run in self.course_runs[:2]: + course = course_run.course + course.canonical_course_run = course_run + course.save() expected_canonical_runs = [self.course_runs[0], self.course_runs[1]] # Verify only canonical course runs are returned in set @@ -1038,13 +1691,13 @@ def test_canonical_course_seats(self): course = factories.CourseFactory() course_runs_same_course = factories.CourseRunFactory.create_batch(3, course=course) + verified_seat_type = factories.SeatTypeFactory.verified() for course_run in course_runs_same_course: - factories.SeatFactory(type='verified', currency=currency, course_run=course_run, price=100) + factories.SeatFactory(type=verified_seat_type, currency=currency, course_run=course_run, price=100) course.canonical_course_run = course_runs_same_course[0] course.save() - applicable_seat_types = SeatType.objects.filter(slug__in=['verified']) - program_type = factories.ProgramTypeFactory(applicable_seat_types=applicable_seat_types) + program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) program = factories.ProgramFactory(type=program_type, courses=[course]) @@ -1052,21 +1705,22 @@ def test_canonical_course_seats(self): def test_entitlements(self): """ Test entitlements returns only applicable course entitlements. """ - course = factories.CourseFactory() - verified_mode = SeatType.objects.get(name='verified') - credit_mode = SeatType.objects.get(name='credit') - professional_mode = SeatType.objects.get(name='professional') - for mode in [verified_mode, credit_mode, professional_mode]: - factories.CourseEntitlementFactory(course=course, mode=mode) - applicable_seat_types = SeatType.objects.filter(name__in=['verified', 'professional']) + courses = factories.CourseFactory.create_batch(3) + verified_type = factories.SeatTypeFactory.verified() + credit_type = factories.SeatTypeFactory.credit() + professional_type = factories.SeatTypeFactory.professional() + for i, mode in enumerate([verified_type, credit_type, professional_type]): + factories.CourseEntitlementFactory(course=courses[i], mode=mode) + applicable_seat_types = [verified_type, professional_type] program_type = factories.ProgramTypeFactory(applicable_seat_types=applicable_seat_types) - program = factories.ProgramFactory(type=program_type, courses=[course]) - - assert set(course.entitlements.filter(mode__in=applicable_seat_types)) == set(program.entitlements) + program = factories.ProgramFactory(type=program_type, courses=courses) + expected = {c.entitlements.filter(mode__in=applicable_seat_types).first() for c in courses} + expected.remove(None) + self.assertEqual(expected, set(program.entitlements)) def test_languages(self): - expected_languages = set([course_run.language for course_run in self.course_runs]) + expected_languages = {course_run.language for course_run in self.course_runs} actual_languages = self.program.languages self.assertGreater(len(actual_languages), 0) self.assertEqual(actual_languages, expected_languages) @@ -1140,15 +1794,16 @@ def test_price_ranges_multiple_course(self): """ Verifies the price_range property of a program with multiple courses """ currency = Currency.objects.get(code='USD') test_price = 100 + audit_seat_type = factories.SeatTypeFactory.audit() + verified_seat_type = factories.SeatTypeFactory.verified() for course_run in self.course_runs: - factories.SeatFactory(type='audit', currency=currency, course_run=course_run, price=0) - factories.SeatFactory(type='verified', currency=currency, course_run=course_run, price=test_price) + factories.SeatFactory(type=audit_seat_type, currency=currency, course_run=course_run, price=0) + factories.SeatFactory(type=verified_seat_type, currency=currency, course_run=course_run, price=test_price) test_price += 100 course_run.course.canonical_course_run = course_run course_run.course.save() - applicable_seat_types = SeatType.objects.filter(slug__in=['verified']) - program_type = factories.ProgramTypeFactory(applicable_seat_types=applicable_seat_types) + program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) self.program.type = program_type @@ -1160,18 +1815,18 @@ def test_price_ranges_with_entitlements(self, create_seats): """ Verifies the price_range property of a program with course entitlement products """ currency = Currency.objects.get(code='USD') test_price = 100 - verified_mode = SeatType.objects.get(name='verified') + verified_type = SeatTypeFactory.verified() for course_run in self.course_runs: factories.CourseEntitlementFactory( - currency=currency, course=course_run.course, price=test_price, mode=verified_mode + currency=currency, course=course_run.course, price=test_price, mode=verified_type ) if create_seats: - factories.SeatFactory(type='verified', currency=currency, price=test_price, course_run=course_run) + factories.SeatFactory(type=verified_type, currency=currency, price=test_price, course_run=course_run) course_run.course.canonical_course_run = course_run course_run.course.save() test_price += 100 - applicable_seat_types = SeatType.objects.filter(name__in=['verified']) + applicable_seat_types = SeatType.objects.filter(slug__in=['verified']) program_type = factories.ProgramTypeFactory(applicable_seat_types=applicable_seat_types) self.program.type = program_type @@ -1184,9 +1839,11 @@ def create_program_with_multiple_course_runs(self, set_all_dates=True): single_course_course_runs = factories.CourseRunFactory.create_batch(3) course = factories.CourseFactory() course_runs_same_course = factories.CourseRunFactory.create_batch(3, course=course) + audit_seat_type = factories.SeatTypeFactory.audit() + verified_seat_type = factories.SeatTypeFactory.verified() for course_run in single_course_course_runs: - factories.SeatFactory(type='audit', currency=currency, course_run=course_run, price=0) - factories.SeatFactory(type='verified', currency=currency, course_run=course_run, price=10) + factories.SeatFactory(type=audit_seat_type, currency=currency, course_run=course_run, price=0) + factories.SeatFactory(type=verified_seat_type, currency=currency, course_run=course_run, price=10) course_run.course.canonical_course_run = course_run course_run.course.save() @@ -1202,17 +1859,16 @@ def create_program_with_multiple_course_runs(self, set_all_dates=True): course_run.enrollment_start = None course_run.end = None course_run.save() - factories.SeatFactory(type='audit', currency=currency, course_run=course_run, price=0) + factories.SeatFactory(type=audit_seat_type, currency=currency, course_run=course_run, price=0) factories.SeatFactory( - type='verified', + type=verified_seat_type, currency=currency, course_run=course_run, price=(day_separation * 100)) day_separation += 1 course.canonical_course_run = course_runs_same_course[2] course.save() - applicable_seat_types = SeatType.objects.filter(slug__in=['verified']) - program_type = factories.ProgramTypeFactory(applicable_seat_types=applicable_seat_types) + program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) program_courses = [course_run.course for course_run in single_course_course_runs] program_courses.append(course) @@ -1241,11 +1897,37 @@ def test_price_ranges_with_multiple_course_runs_and_none_dates(self): self.assertEqual(program.price_ranges, expected_price_ranges) def test_staff(self): + TWO_WEEKS_FROM_TODAY = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=14) + YESTERDAY = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1) + TOMORROW = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1) + expected_staff = factories.PersonFactory.create_batch(2) + unexpected_staff = factories.PersonFactory.create_batch(2) + advertised_course_run = factories.CourseRunFactory( + start=YESTERDAY, + end=TWO_WEEKS_FROM_TODAY, + status=CourseRunStatus.Published, + enrollment_end=TWO_WEEKS_FROM_TODAY, + staff=set(expected_staff) + ) + SeatFactory( + course_run=advertised_course_run, + type=SeatTypeFactory.verified(), + upgrade_deadline=TOMORROW + ) + ignored_course_run = factories.CourseRunFactory( + status=CourseRunStatus.Unpublished, + staff=set(unexpected_staff) + ) + self.program.courses.set([advertised_course_run.course, ignored_course_run.course]) + + self.assertEqual(self.program.staff, set(expected_staff)) + + def test_staff_no_advertised_course_run(self): staff = factories.PersonFactory.create_batch(2) self.course_runs[0].staff.add(staff[0]) self.course_runs[1].staff.add(staff[1]) - self.assertEqual(self.program.staff, set(staff)) + self.assertEqual(self.program.staff, set()) def test_banner_image(self): self.program.banner_image = make_image_file('test_banner.jpg') @@ -1261,7 +1943,7 @@ def test_banner_image(self): def test_seat_types(self): program = self.create_program_with_seats() - self.assertEqual(program.seat_types, set(['credit', 'verified'])) + self.assertEqual({t.slug for t in program.seat_types}, {Seat.CREDIT, Seat.VERIFIED}) @ddt.data(ProgramStatus.choices) def test_is_active(self, status): @@ -1272,38 +1954,50 @@ def test_publication_disabled(self): """ Verify that the publisher is not initialized when publication is disabled. """ - toggle_switch('publish_program_to_marketing_site', active=False) - + program = factories.ProgramFactory() with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init: - self.program.save() - self.program.delete() + program.save() + program.delete() assert mock_init.call_count == 0 - toggle_switch('publish_program_to_marketing_site') - self.program.partner.marketing_site_url_root = '' - self.program.partner.save() + with override_switch('publish_program_to_marketing_site', True): + program.partner.marketing_site_url_root = '' + program.partner.save() - with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init: - self.program.save() - self.program.delete() + with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init: + program.save() + program.delete() - assert mock_init.call_count == 0 + assert mock_init.call_count == 0 + @override_switch('publish_program_to_marketing_site', True) def test_publication_enabled(self): """ Verify that the publisher is called when publication is enabled. """ - toggle_switch('publish_program_to_marketing_site') - + program = factories.ProgramFactory() with mock.patch.object(ProgramMarketingSitePublisher, 'publish_obj', return_value=None) as mock_publish_obj: - self.program.save() + program.save() assert mock_publish_obj.called with mock.patch.object(ProgramMarketingSitePublisher, 'delete_obj', return_value=None) as mock_delete_obj: - self.program.delete() + program.delete() assert mock_delete_obj.called + def test_credit_value(self): + """ + Verify that we can set the credit_value field on a program + """ + course_run = factories.CourseRunFactory() + program = factories.ProgramFactory(courses=[course_run.course]) + program.credit_value = 1 + program.clean() + program.save() + + program_from_db = Program.objects.get(uuid=program.uuid) + self.assertEqual(1, program_from_db.credit_value) + class PathwayTests(TestCase): """ Tests of the Pathway model.""" @@ -1360,7 +2054,7 @@ class ProgramTypeTests(TestCase): def test_str(self): program_type = factories.ProgramTypeFactory() - self.assertEqual(str(program_type), program_type.name) + self.assertEqual(str(program_type), program_type.name_t) class CourseEntitlementTests(TestCase): @@ -1429,40 +2123,165 @@ def test_str(self): self.assertEqual(str(ranking), description) +@ddt.ddt class CurriculumTests(TestCase): """ Tests of the Curriculum model. """ def setUp(self): + super().setUp() self.course_run = factories.CourseRunFactory() self.courses = [self.course_run.course] self.degree = factories.DegreeFactory(courses=self.courses) + self.curriculum = Curriculum(program=self.degree) def test_str(self): - uuid_string = uuid.uuid4() - curriculum = Curriculum.objects.create(degree=self.degree, uuid=uuid_string) - self.assertEqual(str(curriculum), str(uuid_string)) + self.assertEqual(str(self.curriculum), str(self.curriculum.uuid)) + @ddt.data('marketing_text', 'marketing_text_brief') + def test_html_fields_are_validated(self, field_name): + # marketing_text is blank=False, so always provide something here + self.curriculum.marketing_text = '

' + # Happy path + setattr(self.curriculum, field_name, '

') + self.curriculum.clean_fields() + + # Bad HTML + setattr(self.curriculum, field_name, '') + with self.assertRaises(ValidationError) as cm: + self.curriculum.clean_fields() + self.assertEqual(cm.exception.message_dict[field_name], ['Invalid HTML received']) + + +class CurriculumProgramMembershipTests(TestCase): + """ Tests of the CurriculumProgramMembership model. """ + def setUp(self): + super().setUp() + self.course_run = factories.CourseRunFactory() + self.degree = factories.DegreeFactory() + self.program = factories.ProgramFactory(courses=[self.course_run.course]) + self.curriculum = Curriculum.objects.create(program=self.degree, uuid=uuid.uuid4()) + + def test_program_unique_within_same_curriculum(self): + CurriculumProgramMembership.objects.create( + program=self.program, + curriculum=self.curriculum + ) + # Add the same program curriculum relationship again. + # Make sure this throws db integrity exception + with self.assertRaises(IntegrityError): + CurriculumProgramMembership.objects.create( + program=self.program, + curriculum=self.curriculum + ) + + def test_same_program_added_to_different_curriculum(self): + CurriculumProgramMembership.objects.create( + program=self.program, + curriculum=self.curriculum + ) + new_curriculum = Curriculum.objects.create(program=self.degree, uuid=uuid.uuid4()) + CurriculumProgramMembership.objects.create( + program=self.program, + curriculum=new_curriculum + ) + self.assertEqual( + self.curriculum.program_curriculum.all()[0], + new_curriculum.program_curriculum.all()[0] + ) + + +class CurriculumCourseMembershipTests(TestCase): + """ Tests of the CurriculumCourseMembership model. """ + def setUp(self): + super().setUp() + self.course_run = factories.CourseRunFactory() + self.course = self.course_run.course + self.degree = factories.DegreeFactory(courses=[self.course]) + self.curriculum = Curriculum.objects.create(program=self.degree, uuid=uuid.uuid4()) + + def test_course_run_exclusions(self): + course_runs = factories.CourseRunFactory.create_batch(4, course=self.course) + course_runs.append(self.course_run) + course_membership = CurriculumCourseMembership.objects.create( + course=self.course, + curriculum=self.curriculum + ) + CurriculumCourseRunExclusion.objects.create( + course_membership=course_membership, + course_run=course_runs[0] + ) + CurriculumCourseRunExclusion.objects.create( + course_membership=course_membership, + course_run=course_runs[1] + ) + self.assertEqual(course_membership.course_runs, set(course_runs[2:])) + self.assertIn(str(self.curriculum), str(course_membership)) + self.assertIn(str(self.course), str(course_membership)) + + def test_course_unique_within_same_curriculum(self): + CurriculumCourseMembership.objects.create( + course=self.course, + curriculum=self.curriculum + ) + # Add the same course curriculum relationship again. + # Make sure this throws db integrity exception + with self.assertRaises(IntegrityError): + CurriculumCourseMembership.objects.create( + course=self.course, + curriculum=self.curriculum + ) + + def test_same_course_added_to_different_curriculum(self): + CurriculumCourseMembership.objects.create( + course=self.course, + curriculum=self.curriculum + ) + new_curriculum = Curriculum.objects.create(program=self.degree, uuid=uuid.uuid4()) + CurriculumCourseMembership.objects.create( + course=self.course, + curriculum=new_curriculum + ) + self.assertEqual( + self.curriculum.course_curriculum.all()[0], + new_curriculum.course_curriculum.all()[0] + ) + + +@ddt.ddt class DegreeDeadlineTests(TestCase): """ Tests the DegreeDeadline model.""" def setUp(self): + super().setUp() self.course_run = factories.CourseRunFactory() self.courses = [self.course_run.course] self.degree = factories.DegreeFactory(courses=self.courses) + self.deadline_name = 'A test deadline' + self.deadline_date = 'January 1, 2019' def test_str(self): - deadline_name = "A test deadline" - deadline_date = "January 1, 2019" degree_deadline = DegreeDeadline.objects.create( degree=self.degree, - name=deadline_name, - date=deadline_date, + name=self.deadline_name, + date=self.deadline_date, ) - self.assertEqual(str(degree_deadline), "{} {}".format(deadline_name, deadline_date)) + self.assertEqual(str(degree_deadline), "{} {}".format(self.deadline_name, self.deadline_date)) + self.assertEqual(degree_deadline.time, '') + + @ddt.data('12:30PM EST', '') + def test_time_field(self, deadline_time): + degree_deadline = DegreeDeadline.objects.create( + degree=self.degree, + name=self.deadline_name, + date=self.deadline_date, + time=deadline_time + ) + self.assertEqual(degree_deadline.time, deadline_time) class DegreeCostTests(TestCase): """ Tests the DegreeCost model.""" def setUp(self): + super().setUp() self.course_run = factories.CourseRunFactory() self.courses = [self.course_run.course] self.degree = factories.DegreeFactory(courses=self.courses) @@ -1539,10 +2358,10 @@ def setUp(self): self.course_run = factories.CourseRunFactory() self.courses = [self.course_run.course] self.degree = factories.DegreeFactory(courses=self.courses) - self.curriculum = factories.CurriculumFactory(degree=self.degree) + self.curriculum = factories.CurriculumFactory(program=self.degree) def test_basic_degree(self): - assert self.degree.curriculum is not None + assert self.degree.curricula.exists() assert self.curriculum.program_curriculum is not None assert self.curriculum.course_curriculum is not None assert self.curriculum.marketing_text is not None @@ -1552,3 +2371,20 @@ def test_basic_degree(self): assert self.degree.banner_border_color is not None assert self.degree.title_background_image is not None assert self.degree.micromasters_background_image is not None + + +class CourseUrlSlugHistoryTest(TestCase): + + def test_slug_with_partner_mismatch(self): + slug_object = factories.CourseUrlSlugFactory() + mismatch_partner = factories.PartnerFactory() + slug_object.partner = mismatch_partner + + with self.assertRaises(ValidationError) as validation_error: + slug_object.save() + self.assertEqual( + validation_error.exception.message_dict['partner'], + ['Partner {partner_key} and course partner {course_partner_key} do not match when attempting to save url ' + 'slug {url_slug}'.format(partner_key=mismatch_partner.name, + course_partner_key=slug_object.course.partner.name, + url_slug=slug_object.url_slug)]) diff --git a/course_discovery/apps/course_metadata/tests/test_publishers.py b/course_discovery/apps/course_metadata/tests/test_publishers.py index 6591e9aadc..3dcb6a1023 100644 --- a/course_discovery/apps/course_metadata/tests/test_publishers.py +++ b/course_discovery/apps/course_metadata/tests/test_publishers.py @@ -4,17 +4,17 @@ import mock import pytest import responses +from waffle.testutils import override_switch from course_discovery.apps.core.tests.factories import PartnerFactory from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.exceptions import ( AliasCreateError, AliasDeleteError, FormRetrievalError, NodeCreateError, NodeDeleteError, NodeEditError, - NodeLookupError, RedirectCreateError + NodeLookupError ) from course_discovery.apps.course_metadata.publishers import ( BaseMarketingSitePublisher, CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher ) -from course_discovery.apps.course_metadata.tests import toggle_switch from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory from course_discovery.apps.course_metadata.tests.mixins import MarketingSitePublisherTestMixin @@ -47,17 +47,23 @@ def test_publish_obj(self): with pytest.raises(NotImplementedError): self.publisher.publish_obj(self.obj) - @mock.patch.object(BaseMarketingSitePublisher, 'node_id', return_value='123') @mock.patch.object(BaseMarketingSitePublisher, 'delete_node', return_value=None) - def test_delete_obj(self, mock_delete_node, mock_node_id): + def test_delete_obj(self, mock_delete_node): """ Verify that object deletion looks up the corresponding node ID and then attempts to delete the node with that ID. """ - self.publisher.delete_obj(self.obj) + # Confirm we don't do anything if it doesn't exist + with mock.patch.object(BaseMarketingSitePublisher, 'node_id', return_value=None) as mock_node_id: + self.publisher.delete_obj(self.obj) + self.assertTrue(mock_node_id.called) + self.assertFalse(mock_delete_node.called) - mock_node_id.assert_called_with(self.obj) - mock_delete_node.assert_called_with('123') + # Now the happy path + with mock.patch.object(BaseMarketingSitePublisher, 'node_id', return_value='123') as mock_node_id: + self.publisher.delete_obj(self.obj) + mock_node_id.assert_called_with(self.obj) + mock_delete_node.assert_called_with('123') @responses.activate def test_serialize_obj(self): @@ -213,13 +219,13 @@ def test_publish_obj_create_disabled(self, mock_create_node, mock_node_id): @mock.patch.object(CourseRunMarketingSitePublisher, 'node_id', return_value=None) @mock.patch.object(CourseRunMarketingSitePublisher, 'create_node', return_value='node_id') @mock.patch.object(CourseRunMarketingSitePublisher, 'update_node_alias') + @override_switch('auto_course_about_page_creation', True) def test_publish_obj_create_successful( self, mock_update_node_alias, mock_create_node, *args ): # pylint: disable=unused-argument - toggle_switch('auto_course_about_page_creation', True) self.publisher.publish_obj(self.obj) mock_create_node.assert_called_with({'data': 'test', 'field_course_uuid': str(self.obj.uuid)}) mock_update_node_alias.assert_called_with(self.obj, 'node_id', None) @@ -228,6 +234,7 @@ def test_publish_obj_create_successful( @mock.patch.object(CourseRunMarketingSitePublisher, 'serialize_obj', return_value={'data': 'test'}) @mock.patch.object(CourseRunMarketingSitePublisher, 'create_node', return_value='node1') @mock.patch.object(CourseRunMarketingSitePublisher, 'update_node_alias') + @override_switch('auto_course_about_page_creation', True) def test_publish_obj_create_if_exists_on_discovery( self, mock_update_node_alias, @@ -236,7 +243,6 @@ def test_publish_obj_create_if_exists_on_discovery( mock_node_id, *args ): # pylint: disable=unused-argument - toggle_switch('auto_course_about_page_creation', True) self.publisher.publish_obj(self.obj) mock_node_id.assert_called_with(self.obj) mock_serialize_obj.assert_called_with(self.obj) @@ -351,33 +357,6 @@ def test_alias(self): assert actual == expected - @responses.activate - @ddt.data(200, 500) - def test_redirect_url(self, status): - """ - Verify that the publisher attempts to create a new redirect url from - an old course run to the new course run and that appropriate - exceptions are raised for non-200 status codes. - """ - previous_obj = CourseRunFactory() - self.mock_api_client() - - # Need to mock the node retrievals that happen inside of the add_url_redirect method - lookup_value = getattr(self.obj, self.publisher.unique_field) - self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value) - lookup_value = getattr(previous_obj, self.publisher.unique_field) - self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value) - - self.mock_get_redirect_form() - self.mock_add_redirect(status=status) - - if status == 200: - self.publisher.add_url_redirect(self.obj, previous_obj) - self.assertEqual(responses.calls[-1].request.url, '{}/add'.format(self.publisher.redirect_api_base)) - else: - with pytest.raises(RedirectCreateError): - self.publisher.add_url_redirect(self.obj, previous_obj) - class ProgramMarketingSitePublisherTests(MarketingSitePublisherTestMixin): """ @@ -394,6 +373,26 @@ def setUp(self): self.obj = ProgramFactory() + @mock.patch.object(ProgramMarketingSitePublisher, 'serialize_obj', return_value={'uuid': 'foo'}) + @mock.patch.object(ProgramMarketingSitePublisher, 'node_id', return_value=None) + @mock.patch.object(ProgramMarketingSitePublisher, 'create_node', return_value='node_id') + @mock.patch.object(ProgramMarketingSitePublisher, 'update_node_alias', return_value=None) + @mock.patch.object(ProgramMarketingSitePublisher, 'get_and_delete_alias', return_value=None) + def test_publish_obj_missed_in_drupal( + self, mock_get_and_delete_alias, mock_update_node_alias, mock_create_node, mock_node_id, _mock_serialize + ): + """ + Verify that the publisher correctly creates a node on drupal if for whatever reason, we think it should + already exist, but it does not on the marketing side. + """ + self.obj.type.name = 'Professional Certificate' + self.publisher.publish_obj(self.obj, previous_obj=self.obj) + + self.assertTrue(mock_node_id.called) + self.assertTrue(mock_create_node.called) + self.assertTrue(mock_get_and_delete_alias.called) + self.assertTrue(mock_update_node_alias.called) + @mock.patch.object(ProgramMarketingSitePublisher, 'serialize_obj', return_value={'uuid': 'foo'}) @mock.patch.object(ProgramMarketingSitePublisher, 'node_id', return_value='node_id') @mock.patch.object(ProgramMarketingSitePublisher, 'create_node', return_value='node_id') diff --git a/course_discovery/apps/course_metadata/tests/test_query.py b/course_discovery/apps/course_metadata/tests/test_query.py index 73e80a5e8f..d5e2dfd35d 100644 --- a/course_discovery/apps/course_metadata/tests/test_query.py +++ b/course_discovery/apps/course_metadata/tests/test_query.py @@ -105,16 +105,6 @@ def test_marketable(self): self.assertEqual(list(CourseRun.objects.marketable()), [course_run]) - def test_marketable_exclusions(self): - """ Verify the method excludes CourseRuns without a slug. """ - course_run = CourseRunFactory() - SeatFactory(course_run=course_run) - - course_run.slug = '' # blank out auto-generated slug - course_run.save() - - self.assertEqual(CourseRun.objects.marketable().exists(), False) - @ddt.data(True, False) def test_marketable_seats_exclusions(self, has_seats): """ Verify that the method excludes CourseRuns without seats. """ diff --git a/course_discovery/apps/course_metadata/tests/test_salesforce.py b/course_discovery/apps/course_metadata/tests/test_salesforce.py new file mode 100644 index 0000000000..1e37fd6845 --- /dev/null +++ b/course_discovery/apps/course_metadata/tests/test_salesforce.py @@ -0,0 +1,680 @@ +import ddt +import mock +from django.test import TestCase +from simple_salesforce import SalesforceExpiredSession + +from course_discovery.apps.core.tests.factories import SalesforceConfigurationFactory, UserFactory +from course_discovery.apps.course_metadata.choices import CourseRunStatus +from course_discovery.apps.course_metadata.models import Course, CourseRun # pylint: disable=unused-import +from course_discovery.apps.course_metadata.salesforce import ( + SalesforceMissingCaseException, SalesforceNotConfiguredException, SalesforceUtil, + populate_official_with_existing_draft, requires_salesforce_update +) +from course_discovery.apps.course_metadata.tests.factories import ( + CourseFactory, CourseFactoryNoSignals, CourseRunFactory, CourseRunFactoryNoSignals, OrganizationFactoryNoSignals +) + + +@ddt.ddt +class TestSalesforce(TestCase): + def setUp(self): + super(TestSalesforce, self).setUp() + self.salesforce_config = SalesforceConfigurationFactory() + self.salesforce_path = 'course_discovery.apps.course_metadata.salesforce.Salesforce' + self.salesforce_util_path = 'course_discovery.apps.course_metadata.salesforce.SalesforceUtil' + + def tearDown(self): + super().tearDown() + # Zero out the instances that are created during testing + SalesforceUtil.instances = {} + + def test_login(self): + # Update the config to reflect what we'll run locally + self.salesforce_config.security_token = '' + self.salesforce_config.save() + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch('course_discovery.apps.course_metadata.salesforce.requests') as mock_requests: + SalesforceUtil(self.salesforce_config.partner) + mock_salesforce.assert_called_with( + session=mock_requests.Session(), + **{ + 'username': self.salesforce_config.username, + 'password': self.salesforce_config.password, + 'organizationId': self.salesforce_config.organization_id, + 'security_token': '', + 'domain': 'test', + } + ) + + def test_wrapper_salesforce_expired_session_calls_login(self): + """ + Tests the wrapper when a SalesforceExpiredSession is thrown. + The first exception thrown will trigger a re-login, the second will + throw an exception as we don't want infinite retries. + """ + course = CourseFactoryNoSignals( + partner=self.salesforce_config.partner, + salesforce_id='TestSalesforceId', + salesforce_case_id='TestSalesforceCaseId', + ) + + with mock.patch( + self.salesforce_util_path + '._query', + side_effect=SalesforceExpiredSession(url='Test', status=401, resource_name='Test', content='Test') + ): + with mock.patch(self.salesforce_path) as mock_salesforce: + util = SalesforceUtil(self.salesforce_config.partner) + # Any method that has the decorator + with self.assertRaises(SalesforceExpiredSession): + util.get_comments_for_course(course) + # 2 calls, one for initialization, one for login before exception + self.assertEqual(len(mock_salesforce.call_args_list), 2) + + def test_wrapper_salesforce_os_error_calls_login(self): + """ + Tests the wrapper when an OSError exception is thrown. + The first exception thrown will trigger a re-login, the second will + throw an exception as we don't want infinite retries. + """ + course = CourseFactoryNoSignals( + partner=self.salesforce_config.partner, + salesforce_id='TestSalesforceId', + salesforce_case_id='TestSalesforceCaseId', + ) + + with mock.patch(self.salesforce_util_path + '._query', side_effect=OSError('Test Error')): + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch('course_discovery.apps.course_metadata.salesforce.logger') as mock_logger: + util = SalesforceUtil(self.salesforce_config.partner) + # Any method that has the decorator + with self.assertRaises(OSError): + util.get_comments_for_course(course) + # 2 calls, one for initialization, one for login before exception + self.assertEqual(len(mock_salesforce.call_args_list), 2) + mock_logger.warning.assert_called_with( + 'An OSError occurred while attempting to call get_comments_for_course' + ) + + def test_wrapper_salesforce_without_client_raises_not_configured_exception(self): + """ + Tests the wrapper when no config is found but a query is run + """ + course = CourseFactoryNoSignals( + partner=self.salesforce_config.partner, + salesforce_id='TestSalesforceId', + salesforce_case_id='TestSalesforceCaseId', + ) + with mock.patch(self.salesforce_path): + util = SalesforceUtil(self.salesforce_config.partner) + SalesforceUtil.instances[self.salesforce_config.partner].client = None + # Any method that has the decorator + with self.assertRaises(SalesforceNotConfiguredException): + util.get_comments_for_course(course) + + def test_singleton(self): + new_config = SalesforceConfigurationFactory() + with mock.patch(self.salesforce_path): + # Instantiate these twice for the same partner, verify only one instance is created each + SalesforceUtil(self.salesforce_config.partner) + SalesforceUtil(self.salesforce_config.partner) + SalesforceUtil(new_config.partner) + SalesforceUtil(new_config.partner) + self.assertEqual(len(SalesforceUtil.instances), 2) + + def test_soql_escape(self): + with mock.patch(self.salesforce_path): + util = SalesforceUtil(self.salesforce_config.partner) + escaped_string = util.soql_escape(r"Some 'test' \string") + self.assertEqual( + escaped_string, + r"Some \'test\' \\string", + ) + + def test_create_account_salesforce_id_set(self): + organization = OrganizationFactoryNoSignals( + key='edX', partner=self.salesforce_config.partner, salesforce_id='Test' + ) + + with mock.patch(self.salesforce_path) as mock_salesforce: + util = SalesforceUtil(self.salesforce_config.partner) + util.create_publisher_organization(organization) + + mock_salesforce().Publisher_Organization__c.create.assert_not_called() + + def test_create_account_salesforce_id_not_set(self): + organization = OrganizationFactoryNoSignals(key='edX', partner=self.salesforce_config.partner) + + return_value = { + 'id': 'SomeSalesforceId' + } + + with mock.patch(self.salesforce_path) as mock_salesforce: + mock_salesforce().Publisher_Organization__c.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + util.create_publisher_organization(organization) + mock_salesforce().Publisher_Organization__c.create.assert_called_with({ + 'Organization_Name__c': organization.name, + 'Organization_Key__c': organization.key, + }) + self.assertEqual(organization.salesforce_id, return_value.get('id')) + + def test_create_course_salesforce_id_set(self): + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id='Test') + + with mock.patch(self.salesforce_path) as mock_salesforce: + util = SalesforceUtil(self.salesforce_config.partner) + util.create_course(course) + mock_salesforce().Course__c.create.assert_not_called() + + def test_create_course_salesforce_id_not_set(self): + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner) + organization = OrganizationFactoryNoSignals( + key='edX', partner=self.salesforce_config.partner, salesforce_id='Test' + ) + course.authoring_organizations.add(organization) + partner = self.salesforce_config.partner + + return_value = { + 'id': 'SomeSalesforceId' + } + + with mock.patch(self.salesforce_path) as mock_salesforce: + mock_salesforce().Course__c.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + util.create_course(course) + mock_salesforce().Course__c.create.assert_called_with({ + 'Course_Name__c': course.title, + 'Link_to_Publisher__c': '{url}/courses/{uuid}'.format( + url=partner.publisher_url.strip('/'), uuid=course.uuid + ) if partner.publisher_url else None, + 'Link_to_Admin_Portal__c': '{url}/admin/course_metadata/course/{id}/change/'.format( + url=partner.site.domain.strip('/'), id=course.id + ) if partner.site.domain else None, + 'Course_Key__c': course.key, + 'Publisher_Organization__c': organization.salesforce_id, + }) + self.assertEqual(course.salesforce_id, return_value.get('id')) + + def test_create_course_organization_salesforce_id_not_set(self): + create_pub_org_path = (self.salesforce_util_path + '.create_publisher_organization') + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner) + organization = OrganizationFactoryNoSignals(key='edX', partner=self.salesforce_config.partner) + course.authoring_organizations.add(organization) + partner = self.salesforce_config.partner + + return_value = { + 'id': 'SomeSalesforceId' + } + + # Need to modify state of the instance passed in + def new_create_organization(self, instance): # pylint: disable=unused-argument + instance.salesforce_id = 'SomeSalesforceId' + instance.save() + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(create_pub_org_path, new=new_create_organization): + mock_salesforce().Course__c.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + self.assertIsNone(organization.salesforce_id) + util.create_course(course) + organization.refresh_from_db() + mock_salesforce().Course__c.create.assert_called_with({ + 'Course_Name__c': course.title, + 'Link_to_Publisher__c': '{url}/courses/{uuid}'.format( + url=partner.publisher_url.strip('/'), uuid=course.uuid + ) if partner.publisher_url else None, + 'Link_to_Admin_Portal__c': '{url}/admin/course_metadata/course/{id}/change/'.format( + url=partner.site.domain.strip('/'), id=course.id + ) if partner.site.domain else None, + 'Course_Key__c': course.key, + 'Publisher_Organization__c': organization.salesforce_id, + }) + + self.assertIsNotNone(organization.salesforce_id) + self.assertEqual(course.salesforce_id, return_value.get('id')) + + def test_create_course_run_salesforce_id_set(self): + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id='Test') + course_run = CourseRunFactoryNoSignals(course=course, salesforce_id='Test') + + with mock.patch(self.salesforce_path) as mock_salesforce: + util = SalesforceUtil(self.salesforce_config.partner) + util.create_course_run(course_run) + mock_salesforce().Course_Run__c.create.assert_not_called() + + def test_create_course_run_salesforce_id_not_set(self): + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id='TestSalesforceId') + course_run = CourseRunFactoryNoSignals(course=course) + partner = self.salesforce_config.partner + + return_value = { + 'id': 'SomeSalesforceId' + } + + with mock.patch(self.salesforce_path) as mock_salesforce: + mock_salesforce().Course_Run__c.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + util.create_course_run(course_run) + mock_salesforce().Course_Run__c.create.assert_called_with({ + 'Course__c': course_run.course.salesforce_id, + 'Link_to_Admin_Portal__c': '{url}/admin/course_metadata/courserun/{id}/change/'.format( + url=partner.site.domain.strip('/'), id=course_run.id + ), + 'Course_Start_Date__c': course_run.start.isoformat(), + 'Course_End_Date__c': course_run.end.isoformat(), + 'Publisher_Status__c': 'Live', # Expected return value from _get_equivalent_status + 'Course_Run_Name__c': course_run.title, + 'Expected_Go_Live_Date__c': None, + 'Course_Number__c': course_run.key, + # Expected return value from _get_equivalent_ofac_review_decision + 'OFAC_Review_Decision__c': 'OFAC Enabled', + }) + self.assertEqual(course_run.salesforce_id, return_value.get('id')) + + def test_create_course_run_course_salesforce_id_not_set(self): + create_course_path = self.salesforce_util_path + '.create_course' + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner) + course_run = CourseRunFactoryNoSignals(course=course) + partner = self.salesforce_config.partner + + return_value = { + 'id': 'SomeSalesforceId' + } + + # Need to modify state of the instance passed in + def new_create_course(self, instance): # pylint: disable=unused-argument + instance.salesforce_id = 'SomeSalesforceId' + instance.save() + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(create_course_path, new=new_create_course): + mock_salesforce().Course_Run__c.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + self.assertIsNone(course.salesforce_id) + util.create_course_run(course_run) + mock_salesforce().Course_Run__c.create.assert_called_with({ + 'Course__c': course_run.course.salesforce_id, + 'Link_to_Admin_Portal__c': '{url}/admin/course_metadata/courserun/{id}/change/'.format( + url=partner.site, id=course_run.id + ), + 'Course_Start_Date__c': course_run.start.isoformat(), + 'Course_End_Date__c': course_run.end.isoformat(), + 'Publisher_Status__c': 'Live', # Expected return value from _get_equivalent_status + 'Course_Run_Name__c': course_run.title, + 'Expected_Go_Live_Date__c': None, + 'Course_Number__c': course_run.key, + # Expected return value from _get_equivalent_ofac_review_decision + 'OFAC_Review_Decision__c': 'OFAC Enabled', + }) + self.assertIsNotNone(course.salesforce_id) + self.assertEqual(course_run.salesforce_id, return_value.get('id')) + + def test_create_case_for_course_salesforce_case_id_set(self): + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_case_id='Test') + + with mock.patch(self.salesforce_path) as mock_salesforce: + util = SalesforceUtil(self.salesforce_config.partner) + util.create_case_for_course(course) + mock_salesforce().Case.create.assert_not_called() + + def test_create_case_for_course_salesforce_case_id_not_set_salesforce_id_set(self): + course = CourseFactoryNoSignals( + partner=self.salesforce_config.partner, + salesforce_id='TestSalesforceId', + ) + official_course = CourseFactoryNoSignals( + partner=self.salesforce_config.partner, + draft_version=course, + ) + + return_value = { + 'id': 'SomeSalesforceId' + } + + with mock.patch(self.salesforce_path) as mock_salesforce: + mock_salesforce().Case.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + util.create_case_for_course(course) + mock_salesforce().Case.create.assert_called_with({ + 'Course__c': course.salesforce_id, + 'Status': 'Open', + 'Origin': 'Publisher', + 'Subject': '{} Comments'.format(course.title), + 'Description': 'This case is required to be Open for the Publisher comment service.', + 'RecordTypeId': self.salesforce_config.case_record_type_id, + }) + self.assertEqual(course.salesforce_case_id, return_value.get('id')) + self.assertEqual(official_course.salesforce_case_id, return_value.get('id')) + + def test_create_case_for_course_salesforce_case_id_not_set_salesforce_id_not_set(self): + create_course_path = self.salesforce_util_path + '.create_course' + + self.salesforce_config.case_record_type_id = 'TestId' + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner) + + return_value = { + 'id': 'SomeSalesforceId' + } + + # Need to modify state of the instance passed in + def new_create_course(self, instance): # pylint: disable=unused-argument + instance.salesforce_id = 'SomeSalesforceId' + instance.save() + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(create_course_path, new=new_create_course): + mock_salesforce().Case.create.return_value = return_value + util = SalesforceUtil(self.salesforce_config.partner) + self.assertIsNone(course.salesforce_id) + util.create_case_for_course(course) + mock_salesforce().Case.create.assert_called_with({ + 'Course__c': course.salesforce_id, + 'Status': 'Open', + 'Origin': 'Publisher', + 'Subject': '{} Comments'.format(course.title), + 'Description': 'This case is required to be Open for the Publisher comment service.', + 'RecordTypeId': self.salesforce_config.case_record_type_id, + }) + self.assertIsNotNone(course.salesforce_id) + self.assertEqual(course.salesforce_case_id, return_value.get('id')) + + def test_create_comment_for_course_case_salesforce_case_id_set(self): + create_case_path = self.salesforce_util_path + '.create_case_for_course' + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_case_id='TestSalesforceId') + user = UserFactory() + + body = 'Test body' + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(create_case_path) as mock_create_case_for_course: + util = SalesforceUtil(self.salesforce_config.partner) + util.create_comment_for_course_case(course, user, body) + mock_salesforce().FeedItem.create.assert_called_with({ + 'ParentId': course.salesforce_case_id, + 'Body': util.format_user_comment_body(user, body, None) + }) + mock_create_case_for_course.assert_not_called() + + def test_create_comment_for_course_case_salesforce_case_id_not_set(self): + create_case_path = self.salesforce_util_path + '.create_case_for_course' + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id='TestSalesforceId') + user = UserFactory() + + body = 'Test body' + + # Need to modify state of the instance passed in + def new_create_course_case(self, instance): # pylint: disable=unused-argument + instance.salesforce_case_id = 'SomeSalesforceId' + instance.save() + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(create_case_path, new=new_create_course_case): + util = SalesforceUtil(self.salesforce_config.partner) + self.assertIsNone(course.salesforce_case_id) + util.create_comment_for_course_case(course, user, body) + mock_salesforce().FeedItem.create.assert_called_with({ + 'ParentId': course.salesforce_case_id, + 'Body': util.format_user_comment_body(user, body, None) + }) + self.assertIsNotNone(course.salesforce_case_id) + + def test_create_comment_for_course_case_raises_exceptions(self): + create_case_path = self.salesforce_util_path + '.create_case_for_course' + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id='TestSalesforceId') + user = UserFactory() + + body = 'Test body' + + # Need to modify state of the instance passed in and make it None to simulate an error + def new_create_course_case(self, instance): # pylint: disable=unused-argument + instance.salesforce_case_id = None + instance.save() + + with mock.patch(self.salesforce_path): + with mock.patch(create_case_path, new=new_create_course_case): + util = SalesforceUtil(self.salesforce_config.partner) + self.assertIsNone(course.salesforce_case_id) + with self.assertRaises(SalesforceMissingCaseException): + util.create_comment_for_course_case(course, user, body) + + def test_get_comments_for_course_case_id_not_set(self): + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id='TestSalesforceId') + + with mock.patch(self.salesforce_path): + util = SalesforceUtil(self.salesforce_config.partner) + comments = util.get_comments_for_course(course) + self.assertEqual(comments, []) + + def test_get_comments_for_course_case_id_set(self): + user = UserFactory() + query_path = self.salesforce_util_path + '._query' + + course = CourseFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_case_id='TestSalesforceId') + + return_value = { + 'records': [ + { + 'CreatedBy': { + 'Username': user.username + }, + 'CreatedDate': '2000-01-01', + 'Body': ('[User]\n{}\n\n' + '[Course Run]\ncourse-v1:testX+TestX+Test\n\n' + '[Body]\nThis is a formatted test message.').format(user.username), + }, + { + 'CreatedBy': { + 'Username': 'internal' + }, + 'CreatedDate': '2000-01-01', + 'Body': 'This is an internal user comment without formatting.' + }, + ] + } + + with mock.patch(self.salesforce_path): + with mock.patch(query_path, return_value=return_value) as mock_query: + util = SalesforceUtil(self.salesforce_config.partner) + comments = util.get_comments_for_course(course) + mock_query.assert_called_with( + "SELECT CreatedDate,Body,CreatedBy.Username,CreatedBy.Email,CreatedBy.FirstName,CreatedBy.LastName " + "FROM FeedItem WHERE ParentId='{}' AND IsDeleted=FALSE ORDER BY CreatedDate ASC".format( + course.salesforce_case_id + ) + ) + self.assertEqual(comments, [ + { + 'user': { + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + }, + 'course_run_key': 'course-v1:testX+TestX+Test', + 'comment': 'This is a formatted test message.', + 'created': '2000-01-01', + }, + { + 'user': { + 'username': 'internal', + 'email': None, + 'first_name': None, + 'last_name': None, + }, + 'course_run_key': None, + 'comment': 'This is an internal user comment without formatting.', + 'created': '2000-01-01', + }, + ]) + + def test_format_and_parse(self): + user = UserFactory() + body = 'This is a test body.' + course_run_key = 'course-v1:testX+TestX+Test' + + with mock.patch(self.salesforce_path): + util = SalesforceUtil(self.salesforce_config.partner) + formatted_message = util.format_user_comment_body(user, body, course_run_key) + expected_formatted_message = '[User]\n{}\n\n[Course Run]\n{}\n\n[Body]\n{}'.format( + '{} {} ({})'.format(user.first_name, user.last_name, user.username), course_run_key, body + ) + self.assertEqual( + formatted_message, + expected_formatted_message + ) + parsed_message = util._parse_user_comment_body( # pylint: disable=protected-access + { + 'Body': formatted_message + } + ) + parsed_user = parsed_message.get('user') + self.assertEqual(parsed_user.get('username'), user.username) + # Below 3 will always be None for a matched comment + self.assertEqual(parsed_user.get('email'), None) + self.assertEqual(parsed_user.get('first_name'), None) + self.assertEqual(parsed_user.get('last_name'), None) + + self.assertEqual(parsed_message.get('course_run_key'), course_run_key) + self.assertEqual(parsed_message.get('comment'), body) + user.first_name = '' + user.last_name = '' + user.save() + + formatted_message = util.format_user_comment_body(user, body, course_run_key) + expected_formatted_message = '[User]\n{}\n\n[Course Run]\n{}\n\n[Body]\n{}'.format( + '{}'.format(user.username), course_run_key, body + ) + self.assertEqual( + formatted_message, + expected_formatted_message + ) + + @ddt.data('test-id', None) + def test_update_publisher_organization(self, salesforce_id): + """Test Cases: updating organization with a salesforce_id, with no salesforce_id""" + organization = OrganizationFactoryNoSignals(partner=self.salesforce_config.partner, salesforce_id=salesforce_id) + + with mock.patch(self.salesforce_path) as mock_salesforce: + util = SalesforceUtil(self.salesforce_config.partner) + util.update_publisher_organization(organization) + if salesforce_id: + mock_salesforce().Publisher_Organization__c.update.assert_called() + else: + mock_salesforce().Publisher_Organization__c.update.assert_not_called() + + @ddt.data('test-id', None) + def test_update_course(self, salesforce_id): + """Test Cases: updating course with a salesforce_id, with no salesforce_id""" + course = CourseFactory() + course.salesforce_id = salesforce_id + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(self.salesforce_util_path + '.create_course') as mock_create: + util = SalesforceUtil(self.salesforce_config.partner) + util.update_course(course) + if salesforce_id: + mock_salesforce().Course__c.update.assert_called() + else: + mock_salesforce().Course__c.update.assert_not_called() + mock_create.assert_called() + + @ddt.data('test-id', None) + def test_update_course_run(self, salesforce_id): + """Test Cases: updating course run with a salesforce_id, with no salesforce_id""" + course_run = CourseRunFactory() + course_run.salesforce_id = salesforce_id + + with mock.patch(self.salesforce_path) as mock_salesforce: + with mock.patch(self.salesforce_util_path + '.create_course_run') as mock_create: + util = SalesforceUtil(self.salesforce_config.partner) + util.update_course_run(course_run) + if salesforce_id: + mock_salesforce().Course_Run__c.update.assert_called() + else: + mock_salesforce().Course_Run__c.update.assert_not_called() + mock_create.assert_called() + + def test_requires_salesforce_update(self): + org = OrganizationFactoryNoSignals(partner=self.salesforce_config.partner) + org.description = 'changed' + self.assertEqual(requires_salesforce_update('organization', org), False) + + org.name = 'changed' + self.assertEqual(requires_salesforce_update('organization', org), True) + + course = CourseFactory() + course.short_description = 'changed' + self.assertEqual(requires_salesforce_update('course', course), False) + + course.title = 'changed' + self.assertEqual(requires_salesforce_update('course', course), True) + + course_run = CourseRunFactory() + course_run.full_description = 'changed' + self.assertEqual(requires_salesforce_update('course_run', course_run), False) + + course_run.key = 'changed' + self.assertEqual(requires_salesforce_update('course_run', course_run), True) + + def test_populate_official_with_existing_draft(self): + course_run = CourseRunFactory(draft=True, course=CourseFactory(draft=True)) + course_run.status = CourseRunStatus.Reviewed + course_run.save() + + salesforce_id = 'SomeSalesforceId' + + course_run_2 = CourseRunFactory( + draft=True, + course=CourseFactory( + draft=True, + salesforce_id=salesforce_id + ), + salesforce_id=salesforce_id + ) + course_run_2.status = CourseRunStatus.Reviewed + course_run_2.save() + + # Need to modify state of the instance passed in + def new_create_instance(instance): + instance.salesforce_id = salesforce_id + instance.save() + + with mock.patch( + 'course_discovery.apps.course_metadata.tests.test_salesforce.CourseRun.save' + ): + with mock.patch( + 'course_discovery.apps.course_metadata.tests.test_salesforce.Course.save' + ): + with mock.patch(self.salesforce_util_path) as mock_salesforce_util: + with mock.patch(self.salesforce_util_path + '.create_course_run', new=new_create_instance): + with mock.patch(self.salesforce_util_path + '.create_course', new=new_create_instance): + created = populate_official_with_existing_draft( + course_run.official_version, mock_salesforce_util + ) + self.assertTrue(created) + self.assertEqual(course_run.official_version.salesforce_id, salesforce_id) + + created = populate_official_with_existing_draft( + course_run.official_version.course, mock_salesforce_util + ) + self.assertTrue(created) + self.assertEqual(course_run.official_version.course.salesforce_id, salesforce_id) + + created = populate_official_with_existing_draft( + course_run_2.official_version, mock_salesforce_util + ) + self.assertFalse(created) + self.assertEqual(course_run_2.official_version.salesforce_id, salesforce_id) + + created = populate_official_with_existing_draft( + course_run_2.official_version.course, mock_salesforce_util + ) + self.assertFalse(created) + self.assertEqual(course_run_2.official_version.course.salesforce_id, salesforce_id) diff --git a/course_discovery/apps/course_metadata/tests/test_signals.py b/course_discovery/apps/course_metadata/tests/test_signals.py index a1328a4699..9d39aba2ef 100644 --- a/course_discovery/apps/course_metadata/tests/test_signals.py +++ b/course_discovery/apps/course_metadata/tests/test_signals.py @@ -1,14 +1,31 @@ +import datetime +from re import escape + +import ddt import mock import pytest from django.apps import apps -from factory import DjangoModelFactory +from django.core.exceptions import ValidationError +from django.test import TestCase +from factory.django import DjangoModelFactory +from pytz import UTC +from course_discovery.apps.api.v1.tests.test_views.mixins import FuzzyInt +from course_discovery.apps.course_metadata.algolia_models import ( + AlgoliaProxyCourse, AlgoliaProxyProduct, AlgoliaProxyProgram, SearchDefaultResultsConfiguration +) +from course_discovery.apps.course_metadata.choices import CourseRunStatus from course_discovery.apps.course_metadata.models import ( - DataLoaderConfig, DeletePersonDupsConfig, DrupalPublishUuidConfig, ProfileImageDownloadConfig, SubjectTranslation, - TopicTranslation + BackfillCourseRunSlugsConfig, BackpopulateCourseTypeConfig, BulkModifyProgramHookConfig, CourseRun, Curriculum, + CurriculumProgramMembership, DataLoaderConfig, DeletePersonDupsConfig, DrupalPublishUuidConfig, + LevelTypeTranslation, MigratePublisherToCourseMetadataConfig, ProfileImageDownloadConfig, Program, + ProgramTypeTranslation, RemoveRedirectsConfig, SubjectTranslation, TagCourseUuidsConfig, TopicTranslation ) +from course_discovery.apps.course_metadata.signals import _duplicate_external_key_message from course_discovery.apps.course_metadata.tests import factories +LOGGER_NAME = 'course_discovery.apps.course_metadata.signals' + @pytest.mark.django_db @mock.patch('course_discovery.apps.api.cache.set_api_timestamp') @@ -19,7 +36,9 @@ def test_model_change(self, mock_set_api_timestamp): are saved or deleted. """ factory_map = {} - for factorylike in factories.__dict__.values(): + for key, factorylike in factories.__dict__.items(): + if 'NoSignals' in key: + continue if isinstance(factorylike, type) and issubclass(factorylike, DjangoModelFactory): if getattr(factorylike, '_meta', None) and factorylike._meta.model: factory_map[factorylike._meta.model] = factorylike @@ -28,8 +47,14 @@ def test_model_change(self, mock_set_api_timestamp): # connecting to. We want to test each of them. for model in apps.get_app_config('course_metadata').get_models(): # Ignore models that aren't exposed by the API or are only used for testing. - if model in [DataLoaderConfig, DeletePersonDupsConfig, DrupalPublishUuidConfig, SubjectTranslation, - TopicTranslation, ProfileImageDownloadConfig] or 'abstract' in model.__name__.lower(): + if model in [BackpopulateCourseTypeConfig, DataLoaderConfig, DeletePersonDupsConfig, + DrupalPublishUuidConfig, MigratePublisherToCourseMetadataConfig, SubjectTranslation, + TopicTranslation, ProfileImageDownloadConfig, TagCourseUuidsConfig, RemoveRedirectsConfig, + BulkModifyProgramHookConfig, BackfillCourseRunSlugsConfig, AlgoliaProxyCourse, + AlgoliaProxyProgram, AlgoliaProxyProduct, ProgramTypeTranslation, + LevelTypeTranslation, SearchDefaultResultsConfiguration]: + continue + if 'abstract' in model.__name__.lower() or 'historical' in model.__name__.lower(): continue factory = factory_map.get(model) @@ -46,3 +71,637 @@ def test_model_change(self, mock_set_api_timestamp): assert mock_set_api_timestamp.called mock_set_api_timestamp.reset_mock() + + +@ddt.ddt +class ProgramStructureValidationTests(TestCase): + + @classmethod + def setUpTestData(cls): + """ + Set up program structure to test for cycles + + program p1 + / \\ + curriculum c1 c2 + / \\ / \\ + program p2 p3 p4 + | | + curriculum c3 c4 + | \\ | + program p5 p6 + """ + super().setUpTestData() + cls.program_1 = factories.ProgramFactory(title='program_1') + cls.program_2 = factories.ProgramFactory(title='program_2') + cls.program_3 = factories.ProgramFactory(title='program_3') + cls.program_4 = factories.ProgramFactory(title='program_4') + cls.program_5 = factories.ProgramFactory(title='program_5') + cls.program_6 = factories.ProgramFactory(title='program_6') + + cls.curriculum_1 = factories.CurriculumFactory(name='curriculum_1', program=cls.program_1) + cls.curriculum_2 = factories.CurriculumFactory(name='curriculum_2', program=cls.program_1) + cls.curriculum_3 = factories.CurriculumFactory(name='curriculum_3', program=cls.program_2) + cls.curriculum_4 = factories.CurriculumFactory(name='curriculum_4', program=cls.program_3) + + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_1, program=cls.program_2) + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_1, program=cls.program_3) + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_2, program=cls.program_3) + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_2, program=cls.program_4) + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_3, program=cls.program_5) + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_3, program=cls.program_6) + factories.CurriculumProgramMembershipFactory(curriculum=cls.curriculum_4, program=cls.program_6) + + def curriculum_program_membership_error(self, program, curriculum): + return 'Circular ref error. Program [{}] already contains Curriculum [{}]'.format( + program, + curriculum, + ) + + @ddt.data( + ('program_2', True), # immediate child program through CurriculumProgramMembership should fail + ('program_5', True), # nested child program should fail + ('program_6', True), # nested child program with multiple paths to it should fail + ('program_4', False), # 'nephew' program is non-circular and should save successfully + ('program_1', False), # keeping existing parent program should save successfully + ) + @ddt.unpack + def test_update_curriculum_program(self, program_title, is_circular_ref): + program = Program.objects.get(title=program_title) + curriculum = self.curriculum_1 + curriculum.program = program + + if is_circular_ref: + expected_error = 'Circular ref error. Curriculum already contains program {}'.format(program) + with self.assertRaisesRegex(ValidationError, escape(expected_error)): + curriculum.save() + else: + curriculum.save() + curriculum.refresh_from_db() + self.assertEqual(curriculum.program.title, program_title) + + def test_create_new_curriculum(self): + """ create should be unaffected, impossible to create circular ref """ + factories.CurriculumFactory(program=self.program_2) + + @ddt.data( + ('curriculum_1', 'program_1', True), # parent program as member program should fail + ('curriculum_3', 'program_1', True), # nth parent program up tree as member program should fail + ('curriculum_4', 'program_1', True), # nth parent program with multiple search paths as member should fail + ('curriculum_4', 'program_2', False), # valid non-circular CurriculumProgramMembership + ) + @ddt.unpack + def test_create_curriculum_program_membership(self, curriculum_name, program_title, is_circular_ref): + curriculum = Curriculum.objects.get(name=curriculum_name) + program = Program.objects.get(title=program_title) + + if is_circular_ref: + expected_error = self.curriculum_program_membership_error(program, curriculum) + with self.assertRaisesRegex(ValidationError, escape(expected_error)): + CurriculumProgramMembership.objects.create( + program=program, + curriculum=curriculum, + ) + else: + CurriculumProgramMembership.objects.create( + program=program, + curriculum=curriculum, + ) + + @ddt.data( + ('program_5', False), # update to valid program + ('program_1', True), # update creates circular reference + ('program_6', False), # re-saving with existing model should succeed + ) + @ddt.unpack + def test_update_curriculum_program_membership(self, new_program_title, is_circular_ref): + membership = CurriculumProgramMembership.objects.get(curriculum=self.curriculum_4, program=self.program_6) + new_program = Program.objects.get(title=new_program_title) + membership.program = new_program + + if is_circular_ref: + expected_error = self.curriculum_program_membership_error(new_program, self.curriculum_4) + with self.assertRaisesRegex(ValidationError, escape(expected_error)): + membership.save() + else: + membership.save() + + +class ExternalCourseKeyTestMixin: + + @staticmethod + def _add_courses_to_curriculum(curriculum, *courses): + for course in courses: + factories.CurriculumCourseMembershipFactory( + course=course, + curriculum=curriculum + ) + + @staticmethod + def _create_course_and_runs(course_identifier=1): + course_name = 'course-{}'.format(course_identifier) + course = factories.CourseFactory( + key='course-id/' + course_name + '/test', + title=course_name, + ) + for course_run_letter in ('a', 'b', 'c'): + course_run_name = course_name + course_run_letter + factories.CourseRunFactory( + course=course, + key='course-run-id/' + course_run_name + '/test', + external_key='ext-key-' + course_run_name, + end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + enrollment_end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + ) + return course + + @staticmethod + def _create_single_course_curriculum(external_key, curriculum_name,): + course_run = factories.CourseRunFactory( + external_key=external_key + ) + curriculum = factories.CurriculumFactory( + name=curriculum_name, + program=None, + ) + factories.CurriculumCourseMembershipFactory( + course=course_run.course, + curriculum=curriculum, + ) + return course_run, curriculum + + +class ExternalCourseKeyTestDataMixin(ExternalCourseKeyTestMixin): + + @classmethod + def setUpTestData(cls): + """ + Sets up the tree for testing external course keys + + program 1 2 + / \\ | + curriculum 1 2 3 + | \\ / \\ + course 1 2 3 + """ + super().setUpTestData() + cls.program_1 = factories.ProgramFactory(title='program_1') + cls.program_2 = factories.ProgramFactory(title='program_2') + cls.programs = [None, cls.program_1, cls.program_2] + cls.course_1 = cls._create_course_and_runs(1) + cls.course_2 = cls._create_course_and_runs(2) + cls.course_3 = cls._create_course_and_runs(3) + cls.curriculum_1 = factories.CurriculumFactory( + name='curriculum_1', + program=cls.program_1, + ) + cls.curriculum_2 = factories.CurriculumFactory( + name='curriculum_2', + program=cls.program_1, + ) + cls.curriculum_3 = factories.CurriculumFactory( + name='curriculum_3', + program=cls.program_2, + ) + cls.curriculums = [None, cls.curriculum_1, cls.curriculum_2, cls.curriculum_3] + cls._add_courses_to_curriculum(cls.curriculum_1, cls.course_1) + cls._add_courses_to_curriculum(cls.curriculum_2, cls.course_2) + cls._add_courses_to_curriculum(cls.curriculum_3, cls.course_2, cls.course_3) + + +@ddt.ddt +class ExternalCourseKeySingleCollisionTests(ExternalCourseKeyTestDataMixin, TestCase): + """ + There are currently three scenarios that can cause CourseRuns to have conflicting external_keys: + 1) Two Course Runs in the same Course + 2) Two Course Runs in different Courses but in the same Curriculum + 3) Two Course Runs in differenct Courses and Curricula but the same Program + """ + + @ddt.data( + 'course-run-id/course-2a/test', # Scenario 1, within the same Course + 'course-run-id/course-3a/test', # Scenario 2, within the same Curriculum + 'course-run-id/course-1a/test', # Scenario 3, within the same Program + ) + def test_create_course_run(self, copy_key): + copied_course_run = CourseRun.objects.get(key=copy_key) + message = _duplicate_external_key_message([copied_course_run]) + # This number may seem high but only 2 are select statements caused by the external key signal + with self.assertNumQueries(12, threshold=0): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CourseRunFactory( + course=self.course_2, + external_key=copied_course_run.external_key, + ) + + @ddt.data( + 'course-run-id/course-2b/test', # Scenario 1, within the same Course + 'course-run-id/course-3a/test', # Scenario 2, within the same Curriculum + 'course-run-id/course-1a/test', # Scenario 3, within the same Program + ) + def test_modify_course_run(self, copy_key): + copied_course_run = CourseRun.objects.get(key=copy_key) + message = _duplicate_external_key_message([copied_course_run]) + course_run = CourseRun.objects.get(key='course-run-id/course-2a/test') + # This number may seem high but only 3 are select statements caused by the external key signal + with self.assertNumQueries(9): + with self.assertRaisesRegex(ValidationError, escape(message)): + course_run.external_key = copied_course_run.external_key + course_run.save() + + @ddt.data( + 1, # Scenario 2, within the same Curriculum + 2, # Scenaario 3, within the same Program + ) + def test_create_curriculum_course_membership(self, curriculum_id): + new_course_run = factories.CourseRunFactory( + external_key='ext-key-course-1a' + ) + new_course = new_course_run.course + course_run_1a = CourseRun.objects.get(key='course-run-id/course-1a/test') + message = _duplicate_external_key_message([course_run_1a]) + with self.assertNumQueries(2): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CurriculumCourseMembershipFactory( + course=new_course, + curriculum=self.curriculums[curriculum_id], + ) + + @ddt.data( + 1, # Scenario 2, within the same Curriculum + 2, # Scenaario 3, within the same Program + ) + def test_modify_curriculum_course_membership(self, curriculum_id): + new_course_run = factories.CourseRunFactory( + external_key='ext-key-course-1a' + ) + new_course = new_course_run.course + curriculum_course_membership = factories.CurriculumCourseMembershipFactory( + course=new_course, + curriculum=self.curriculum_3, + ) + course_run_1a = CourseRun.objects.get(key='course-run-id/course-1a/test') + message = _duplicate_external_key_message([course_run_1a]) + with self.assertNumQueries(2): + with self.assertRaisesRegex(ValidationError, escape(message)): + curriculum_course_membership.curriculum = self.curriculums[curriculum_id] + curriculum_course_membership.save() + + def test_create_curriculum(self): + """ + I can't think of any case that would cause the exception on creating a curriculum + but I will keep this blank test here for enumeration's sake + """ + + def test_modify_curriculum(self): + course_run_1a = CourseRun.objects.get(key='course-run-id/course-1a/test') + _, curriculum_4 = self._create_single_course_curriculum('ext-key-course-1a', 'curriculum_4') + new_program = factories.ProgramFactory( + curricula=[curriculum_4] + ) + message = _duplicate_external_key_message([course_run_1a]) + with self.assertNumQueries(5): + with self.assertRaisesRegex(ValidationError, escape(message)): + curriculum_4.program = self.program_1 + curriculum_4.save() + curriculum_4.refresh_from_db() + self.assertEqual(curriculum_4.program, new_program) + + @ddt.data( + None, + '', + ) + def test_external_course_key_null_or_empty(self, external_key_to_test): + course_run_1a = CourseRun.objects.get(key='course-run-id/course-1a/test') + course_run_1a.external_key = external_key_to_test + course_run_1a.save() + + # Same course test + copy_course_run_1a = factories.CourseRunFactory( + course=self.course_1, + external_key=external_key_to_test, + ) + self.assertEqual(course_run_1a.external_key, copy_course_run_1a.external_key) + + # Same curriculum test but different courses + new_course_run = factories.CourseRunFactory( + external_key=external_key_to_test + ) + new_course = new_course_run.course + factories.CurriculumCourseMembershipFactory( + course=new_course, + curriculum=self.curriculum_1, + ) + self.assertEqual(course_run_1a.external_key, new_course_run.external_key) + + # Same programs but different curriculum test + _, curriculum_4 = self._create_single_course_curriculum(external_key_to_test, 'curriculum_4') + curriculum_4.program = self.program_1 + curriculum_4.save() + curriculum_4.refresh_from_db() + + +class ExternalCourseKeyMultipleCollisionTests(ExternalCourseKeyTestDataMixin, TestCase): + + @classmethod + def setUpTestData(cls): + """ + Sets up test data for testting multiple collisions of external_keys + """ + super().setUpTestData() + cls.course_run_1a = CourseRun.objects.get(key='course-run-id/course-1a/test') + cls.course_run_2b = CourseRun.objects.get(key='course-run-id/course-2b/test') + cls.course_run_3c = CourseRun.objects.get(key='course-run-id/course-3c/test') + cls.course = factories.CourseFactory() + cls.colliding_course_run_1a = factories.CourseRunFactory(course=cls.course, external_key='ext-key-course-1a') + cls.colliding_course_run_2b = factories.CourseRunFactory(course=cls.course, external_key='ext-key-course-2b') + cls.colliding_course_run_3c = factories.CourseRunFactory(course=cls.course, external_key='ext-key-course-3c') + cls.curriculum = factories.CurriculumFactory() + cls._add_courses_to_curriculum(cls.curriculum, cls.course) + + def test_multiple_collisions__curriculum_course_membership(self): + message = _duplicate_external_key_message([self.course_run_1a, self.course_run_2b]) + with self.assertNumQueries(2): + with self.assertRaisesRegex(ValidationError, escape(message)): + self._add_courses_to_curriculum(self.curriculum_2, self.course) + + def test_multiple_collisions__curriculum(self): + message = _duplicate_external_key_message([self.course_run_2b, self.course_run_3c]) + with self.assertNumQueries(5): + with self.assertRaisesRegex(ValidationError, escape(message)): + self.curriculum.program = self.program_2 + self.curriculum.save() + + +class ExternalCourseKeyIncompleteStructureTests(TestCase, ExternalCourseKeyTestMixin): + """ + Tests that the external_key validation still works within an incomplete hierarchy + (Course alone or curriculum without a program) + """ + + def test_create_course_run__course_run_only(self): + course = self._create_course_and_runs() + course_run = course.course_runs.first() + message = _duplicate_external_key_message([course_run]) + with self.assertNumQueries(11, threshold=0): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CourseRunFactory( + course=course, + external_key=course_run.external_key + ) + + def test_modify_course_run__course_run_only(self): + course = self._create_course_and_runs(1) + course_run_1a = course.course_runs.get(external_key='ext-key-course-1a') + course_run_1b = course.course_runs.get(external_key='ext-key-course-1b') + message = _duplicate_external_key_message([course_run_1a]) + with self.assertNumQueries(6): + with self.assertRaisesRegex(ValidationError, escape(message)): + course_run_1b.external_key = 'ext-key-course-1a' + course_run_1b.save() + + def test_create_course_run__curriculum_only(self): + course_run, _ = self._create_single_course_curriculum('colliding-key', 'curriculum_1') + message = _duplicate_external_key_message([course_run]) + with self.assertNumQueries(11, threshold=0): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CourseRunFactory( + course=course_run.course, + external_key='colliding-key' + ) + + def test_modify_course_run__curriculum_only(self): + course_run_1a, _ = self._create_single_course_curriculum('colliding-key', 'curriculum_1') + course_run_1b = factories.CourseRunFactory( + course=course_run_1a.course, + external_key='this-is-a-different-external-key' + ) + message = _duplicate_external_key_message([course_run_1a]) + with self.assertNumQueries(6): + with self.assertRaisesRegex(ValidationError, escape(message)): + course_run_1b.external_key = 'colliding-key' + course_run_1b.save() + + def test_create_curriculum_course_membership__curriculum_only(self): + course_run_1, curriculum_1 = self._create_single_course_curriculum('colliding-key', 'curriculum_1') + course_run_2 = factories.CourseRunFactory( + external_key='colliding-key' + ) + message = _duplicate_external_key_message([course_run_1]) + with self.assertNumQueries(2): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CurriculumCourseMembershipFactory( + course=course_run_2.course, + curriculum=curriculum_1, + ) + + def test_modify_curriculum_course_membership__curriculum_only(self): + course_run_1, curriculum_1 = self._create_single_course_curriculum('colliding-key', 'curriculum_1') + course_run_2, _ = self._create_single_course_curriculum('colliding-key', 'curriculum_2') + curriculum_course_membership_2 = course_run_2.course.curriculum_course_membership.first() + message = _duplicate_external_key_message([course_run_1]) + with self.assertNumQueries(2): + with self.assertRaisesRegex(ValidationError, escape(message)): + curriculum_course_membership_2.curriculum = curriculum_1 + curriculum_course_membership_2.save() + + +class ExternalCourseKeyDBTests(TestCase, ExternalCourseKeyTestMixin): + + def test_mix_of_curriculums_with_and_without_programs(self): + course_a = self._create_course_and_runs('a') + course_b = self._create_course_and_runs('b') + course_c = self._create_course_and_runs('c') + program_1 = factories.ProgramFactory(title='program_1') + program_2 = factories.ProgramFactory(title='program_2') + curriculum_1 = factories.CurriculumFactory(program=program_1) + curriculum_2 = factories.CurriculumFactory(program=program_2) + curriculum_3 = factories.CurriculumFactory(program=None) + curriculum_4 = factories.CurriculumFactory(program=None) + self._add_courses_to_curriculum(curriculum_1, course_a, course_b) + self._add_courses_to_curriculum(curriculum_2, course_a, course_b) + self._add_courses_to_curriculum(curriculum_3, course_a, course_b, course_c) + self._add_courses_to_curriculum(curriculum_4, course_a, course_b, course_c) + + course_run = course_a.course_runs.first() + course_run_ca = CourseRun.objects.get(external_key='ext-key-course-ca') + message = _duplicate_external_key_message([course_run_ca]) + with self.assertNumQueries(FuzzyInt(6, 1)): # 3 Selects + with self.assertRaisesRegex(ValidationError, escape(message)): + course_run.external_key = course_run_ca.external_key + course_run.save() + + with self.assertNumQueries(FuzzyInt(36, 1)): + course_run.external_key = 'some-safe-key' + course_run.save() + + def test_curriculum_repeats(self): + course_a = self._create_course_and_runs('a') + course_b = self._create_course_and_runs('b') + course_c = self._create_course_and_runs('c') + program = factories.ProgramFactory(title='program_1') + curriculum_1 = factories.CurriculumFactory(program=program) + curriculum_2 = factories.CurriculumFactory(program=program) + curriculum_3 = factories.CurriculumFactory(program=program) + self._add_courses_to_curriculum(curriculum_1, course_a, course_b, course_c) + self._add_courses_to_curriculum(curriculum_2, course_a, course_b, course_c) + self._add_courses_to_curriculum(curriculum_3, course_a, course_b, course_c) + course_run = course_a.course_runs.first() + course_run_ba = CourseRun.objects.get(external_key='ext-key-course-ba') + message = _duplicate_external_key_message([course_run_ba]) + with self.assertNumQueries(FuzzyInt(6, 1)): # 3 Selects + with self.assertRaisesRegex(ValidationError, escape(message)): + course_run.external_key = course_run_ba.external_key + course_run.save() + + with self.assertNumQueries(FuzzyInt(36, 1)): + course_run.external_key = 'some-safe-key' + course_run.save() + + +class ExternalCourseKeyDraftTests(ExternalCourseKeyTestDataMixin, TestCase): + """ + Tests for the behavior of draft Course Runs. + Draft or not, a course run will only be checked for collisions against _published_ courseruns. + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.draft_course_1 = factories.CourseFactory( + draft=True, + key='course-id/draft-course-1/test', + title='draft-course-1' + ) + cls.draft_course_run_1 = factories.CourseRunFactory( + course=cls.draft_course_1, + draft=True, + external_key='external-key-drafttest' + ) + + def test_draft_does_not_collide_with_draft(self): + with self.assertNumQueries(77, threshold=0): + factories.CourseRunFactory( + course=self.course_1, + draft=True, + external_key='external-key-drafttest', + end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + enrollment_end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + ) + + def test_draft_collides_with_nondraft(self): + course_run_1a = self.course_1.course_runs.get(external_key='ext-key-course-1a') + message = _duplicate_external_key_message([course_run_1a]) + with self.assertNumQueries(12, threshold=0): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CourseRunFactory( + course=self.course_1, + draft=True, + external_key='ext-key-course-1a', + ) + + def test_nondraft_does_not_collide_with_draft(self): + with self.assertNumQueries(77, threshold=0): + factories.CourseRunFactory( + course=self.course_1, + draft=False, + external_key='external-key-drafttest', + end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + enrollment_end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + ) + + def test_collision_does_not_include_drafts(self): + with self.assertNumQueries(77, threshold=0): + course_run = factories.CourseRunFactory( + course=self.course_1, + draft=False, + external_key='external-key-drafttest', + end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + enrollment_end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + ) + message = _duplicate_external_key_message([course_run]) # Not draft_course_run_1 + with self.assertNumQueries(11, threshold=0): + with self.assertRaisesRegex(ValidationError, escape(message)): + factories.CourseRunFactory( + course=self.course_1, + draft=False, + external_key='external-key-drafttest', + end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + enrollment_end=datetime.datetime(2014, 1, 1, tzinfo=UTC), + ) + + def test_update_or_create_official_version(self): + # This test implicitly checks that a collision does not happen + # and that the external_key is properly copied over to the official version + self.draft_course_run_1.update_or_create_official_version() + official_run = self.draft_course_run_1.official_version + self.assertEqual(self.draft_course_run_1.external_key, official_run.external_key) + + +class SalesforceTests(TestCase): + def setUp(self): + super().setUp() + self.salesforce_util_path = 'course_discovery.apps.course_metadata.utils.SalesforceUtil' + + def test_update_or_create_salesforce_organization(self): + with mock.patch(self.salesforce_util_path) as mock_salesforce_util: + organization = factories.OrganizationFactory() + + mock_salesforce_util().create_publisher_organization.assert_called() + mock_salesforce_util().update_publisher_organization.assert_not_called() + + organization.name = 'changed' + organization.save() + + mock_salesforce_util().update_publisher_organization.assert_called() + + def test_update_or_create_salesforce_course(self): + with mock.patch(self.salesforce_util_path) as mock_salesforce_util: + # Does not update for non-drafts + course = factories.CourseFactory(draft=True) + + mock_salesforce_util().create_course.assert_not_called() + mock_salesforce_util().update_course.assert_not_called() + + course.save() + + # This shows that an update to a draft does not hit the salesforce update method + mock_salesforce_util().update_course.assert_not_called() + organization = factories.OrganizationFactory() + course.authoring_organizations.add(organization) + + course.draft = False + course.title = 'changed' + course.save() + + self.assertEqual(1, mock_salesforce_util().update_course.call_count) + + def test_update_or_create_salesforce_course_run(self): + with mock.patch(self.salesforce_util_path) as mock_salesforce_util: + course_run = factories.CourseRunFactory(draft=True, status=CourseRunStatus.Published) + + mock_salesforce_util().create_course_run.assert_called() + mock_salesforce_util().update_course_run.assert_not_called() + + course_run.draft = False + course_run.status = CourseRunStatus.Unpublished + course_run.save() + + mock_salesforce_util().update_course_run.assert_called() + + def test_authoring_organizations_changed(self): + with mock.patch(self.salesforce_util_path) as mock_salesforce_util: + # Does not update for non-draftstest_comments.py + organization = factories.OrganizationFactory() + course = factories.CourseFactory(draft=False) + + course.authoring_organizations.add(organization) + mock_salesforce_util().create_course.assert_not_called() + + # Updates for drafts when an auth org is added (new) courses + organization = factories.OrganizationFactory() + + course = factories.CourseFactory(draft=True) + + course.authoring_organizations.add(organization) + mock_salesforce_util().create_course.assert_called() diff --git a/course_discovery/apps/course_metadata/tests/test_test_utils.py b/course_discovery/apps/course_metadata/tests/test_test_utils.py new file mode 100644 index 0000000000..692c35ff36 --- /dev/null +++ b/course_discovery/apps/course_metadata/tests/test_test_utils.py @@ -0,0 +1,14 @@ +import ddt +from django.test import TestCase + +from course_discovery.apps.course_metadata.tests.utils import build_salesforce_exception + + +@ddt.ddt +class BuildSalesforceException(TestCase): + @ddt.data('Organization', 'Course', 'CourseRun') + def test_build_salesforce_exception(self, record_type): + expected = 'The Partner of this {record_type} has a Salesforce Configuration, ' \ + 'try using {record_type}FactoryNoSignals instead.'.format(record_type=record_type) + + assert build_salesforce_exception(record_type) == expected diff --git a/course_discovery/apps/course_metadata/tests/test_utils.py b/course_discovery/apps/course_metadata/tests/test_utils.py index 149be66d74..cbc4af0e8e 100644 --- a/course_discovery/apps/course_metadata/tests/test_utils.py +++ b/course_discovery/apps/course_metadata/tests/test_utils.py @@ -1,15 +1,35 @@ # -*- coding: utf-8 -*- +import datetime import re +import urllib import ddt +import mock +import pytest +import pytz +import requests import responses from django.test import TestCase +from course_discovery.apps.api.tests.mixins import SiteMixin +from course_discovery.apps.api.v1.tests.test_views.mixins import OAuth2Mixin +from course_discovery.apps.core.models import Currency +from course_discovery.apps.core.utils import serialize_datetime from course_discovery.apps.course_metadata import utils -from course_discovery.apps.course_metadata.exceptions import MarketingSiteAPIClientException -from course_discovery.apps.course_metadata.tests.factories import ProgramFactory +from course_discovery.apps.course_metadata.exceptions import ( + EcommerceSiteAPIClientException, MarketingSiteAPIClientException +) +from course_discovery.apps.course_metadata.models import Course, CourseEditor, CourseRun, Seat, SeatType, Track +from course_discovery.apps.course_metadata.tests.factories import ( + CourseEditorFactory, CourseEntitlementFactory, CourseFactory, CourseRunFactory, ModeFactory, OrganizationFactory, + ProgramFactory, SeatFactory, SeatTypeFactory +) from course_discovery.apps.course_metadata.tests.mixins import MarketingSiteAPIClientTestMixin +from course_discovery.apps.course_metadata.utils import ( + calculated_seat_upgrade_deadline, clean_html, create_missing_entitlement, ensure_draft_world, + serialize_entitlement_for_ecommerce_api, serialize_seat_for_ecommerce_api +) @ddt.ddt @@ -39,11 +59,11 @@ def test_upload_to(self, path, field, ext): @ddt.ddt class UslugifyTests(TestCase): """ - Test the utiltity function uslugify + Test the utility function uslugify """ @ddt.data( - ('技研究', 'ji-yan-jiu'), - ('عائشة', 'ysh'), + ('技研究', '技研究'), + ('عائشة', 'عائشة'), ('TWO WORDS', 'two-words'), ) @ddt.unpack @@ -52,6 +72,150 @@ def test_uslugify(self, string, expected): self.assertEqual(output, expected) +class PushToEcommerceTests(OAuth2Mixin, TestCase): + """ + Test the utility function push_to_ecommerce_for_course_run + """ + def setUp(self): + super().setUp() + + # Set up an official that we then convert to a draft + audit_track = Track.objects.get(seat_type__slug=Seat.AUDIT) + verified_track = Track.objects.get(seat_type__slug=Seat.VERIFIED) + self.course_run = CourseRunFactory(type__tracks=[audit_track, verified_track]) + CourseEntitlementFactory(course=self.course_run.course, mode=SeatTypeFactory.verified()) + SeatFactory(course_run=self.course_run, type=SeatTypeFactory.verified(), sku=None) + SeatFactory(course_run=self.course_run, type=SeatTypeFactory.audit(), sku=None) + self.course_run = ensure_draft_world(self.course_run).official_version + + # Now we're dealing with just official versions again + self.course = self.course_run.course + self.partner = self.course.partner + self.seats = self.course_run.seats.all() + self.entitlement = self.course.entitlements.first() + self.api_root = self.partner.ecommerce_api_url + + def mock_publication(self, status=200, json=None): + responses.add( + responses.POST, + urllib.parse.urljoin(self.api_root, 'publication/'), + status=status, + json=json if json else { + 'name': self.course_run.title, + 'message': None, + 'products': [ + {'expires': '2019-12-21T23:59:59Z', 'product_class': 'Seat', 'price': '50.00', + 'partner_sku': 'XXXXXXXX', + 'attribute_values': [{'name': 'certificate_type', 'value': 'verified'}, + {'name': 'id_verification_required', 'value': True}]}, + {'expires': None, 'product_class': 'Seat', 'price': '0.00', + 'partner_sku': 'YYYYYYYY', + 'attribute_values': [{'name': 'certificate_type', 'value': ''}, + {'name': 'id_verification_required', 'value': False}]}, + {'product_class': 'Course Entitlement', 'price': '50.00', + 'partner_sku': 'ZZZZZZZZ', + 'attribute_values': [{'name': 'certificate_type', 'value': 'verified'}]}, + ], + 'uuid': str(self.course.uuid), + 'id': self.course_run.key, + 'verification_deadline': '2019-12-31T00:00:00Z', + }, + ) + + @responses.activate + def test_push(self): + """ Happy path """ + self.partner.lms_url = 'http://127.0.0.1:8000' + self.mock_access_token() + self.mock_publication() + self.assertTrue(utils.push_to_ecommerce_for_course_run(self.course_run)) + for s in self.seats: + s.refresh_from_db() + self.entitlement.refresh_from_db() + self.assertEqual({s.sku for s in self.seats}, {'XXXXXXXX', 'YYYYYYYY'}) + self.assertEqual(self.entitlement.sku, 'ZZZZZZZZ') + + # Check draft versions too + self.assertEqual({s.sku for s in self.course_run.draft_version.seats.all()}, {'XXXXXXXX', 'YYYYYYYY'}) + self.assertEqual(self.course.draft_version.entitlements.first().sku, 'ZZZZZZZZ') + + def test_status_failure(self): + self.partner.lms_url = 'http://127.0.0.1:8000' + self.mock_access_token() + self.mock_publication(status=500) + with self.assertRaises(requests.HTTPError): + utils.push_to_ecommerce_for_course_run(self.course_run) + + responses.reset() + self.mock_publication(status=500, json={'error': 'Test error message'}) + with self.assertRaises(EcommerceSiteAPIClientException): + utils.push_to_ecommerce_for_course_run(self.course_run) + + def test_no_products(self): + for seat in self.seats: + seat.delete() + self.entitlement.delete() + self.assertFalse(utils.push_to_ecommerce_for_course_run(self.course_run)) + + def test_no_ecommerce_url(self): + self.partner.ecommerce_api_url = None + self.partner.save() + self.assertFalse(utils.push_to_ecommerce_for_course_run(self.course_run)) + + +@ddt.ddt +class TestSerializeSeatForEcommerceApi(TestCase): + @ddt.data( + ('', False), + ('verified', True), + ) + @ddt.unpack + def test_serialize_seat_for_ecommerce_api(self, certificate_type, is_id_verified): + seat = SeatFactory() + mode = ModeFactory(certificate_type=certificate_type, is_id_verified=is_id_verified) + actual = serialize_seat_for_ecommerce_api(seat, mode) + expected = { + 'expires': serialize_datetime(calculated_seat_upgrade_deadline(seat)), + 'price': str(seat.price), + 'product_class': 'Seat', + 'stockrecords': [{'partner_sku': seat.sku}], + 'attribute_values': [ + { + 'name': 'certificate_type', + 'value': mode.certificate_type, + }, + { + 'name': 'id_verification_required', + 'value': mode.is_id_verified, + } + ] + } + self.assertEqual(actual, expected) + seat.sku = None + actual = serialize_seat_for_ecommerce_api(seat, mode) + expected['stockrecords'][0]['partner_sku'] = None + self.assertEqual(actual, expected) + + +@pytest.mark.django_db +class TestSerializeEntitlementForEcommerceApi: + def test_serialize_entitlement_for_ecommerce_api(self): + entitlement = CourseEntitlementFactory() + actual = serialize_entitlement_for_ecommerce_api(entitlement) + expected = { + 'price': str(entitlement.price), + 'product_class': 'Course Entitlement', + 'attribute_values': [ + { + 'name': 'certificate_type', + 'value': entitlement.mode.slug, + }, + ] + } + + assert actual == expected + + class MarketingSiteAPIClientTests(MarketingSiteAPIClientTestMixin): """ Unit test cases for MarketinSiteAPIClient @@ -123,3 +287,368 @@ def test_api_session_failed(self): self.mock_csrf_token_response(500) with self.assertRaises(MarketingSiteAPIClientException): self.api_client.api_session # pylint: disable=pointless-statement + + +@ddt.ddt +class TestEnsureDraftWorld(SiteMixin, TestCase): + @ddt.data( + None, + {'weeks_to_complete': 7}, + {'weeks_to_complete': 7, 'title_override': 'New Title'}, + ) + def test_set_draft_state(self, attrs): + course_run = CourseRunFactory() + draft_course_run, original_course_run = utils.set_draft_state(course_run, CourseRun, attrs) + + self.assertEqual(1, len(CourseRun.objects.all())) + self.assertEqual(2, len(CourseRun.everything.all())) + + self.assertTrue(draft_course_run.draft) + self.assertFalse(original_course_run.draft) + + if attrs: + model_fields = [field.name for field in CourseRun._meta.get_fields()] + diff_of_fields = list(filter( + lambda f: getattr(original_course_run, f, None) != getattr(draft_course_run, f, None), + model_fields + )) + for key, value in attrs.items(): + # Make sure that any attributes we changed are different in the draft course run from the original + self.assertIn(key, diff_of_fields) + self.assertEqual(getattr(draft_course_run, key), value) + + def test_set_draft_state_with_foreign_key(self): + course = CourseFactory() + course_run = CourseRunFactory(course=course) + draft_course, original_course = utils.set_draft_state(course, Course) + draft_course_run, original_course_run = utils.set_draft_state(course_run, CourseRun, {'course': draft_course}) + + self.assertEqual(1, len(CourseRun.objects.all())) + self.assertEqual(2, len(CourseRun.everything.all())) + self.assertEqual(1, len(Course.objects.all())) + self.assertEqual(2, len(Course.everything.all())) + + self.assertTrue(draft_course_run.draft) + self.assertFalse(original_course_run.draft) + + self.assertTrue(draft_course.draft) + self.assertFalse(original_course.draft) + + self.assertNotEqual(draft_course_run.course, original_course_run.course) + self.assertEqual(draft_course_run.course, draft_course) + self.assertEqual(original_course_run.course, original_course) + + def test_ensure_draft_world_draft_obj_given(self): + course_run = CourseRunFactory(draft=True) + ensured_draft_course_run = utils.ensure_draft_world(course_run) + + self.assertEqual(ensured_draft_course_run, course_run) + self.assertEqual(ensured_draft_course_run.id, course_run.id) + self.assertEqual(ensured_draft_course_run.uuid, course_run.uuid) + self.assertEqual(ensured_draft_course_run.draft, course_run.draft) + + def test_ensure_draft_world_not_draft_course_run_given(self): + course = CourseFactory() + course_run = CourseRunFactory(course=course) + verified_seat = SeatFactory(type=SeatTypeFactory.verified(), course_run=course_run) + audit_seat = SeatFactory(type=SeatTypeFactory.audit(), course_run=course_run) + course_run.seats.add(verified_seat, audit_seat) + + ensured_draft_course_run = utils.ensure_draft_world(course_run) + not_draft_course_run = CourseRun.objects.get(uuid=course_run.uuid) + + self.assertNotEqual(ensured_draft_course_run, not_draft_course_run) + self.assertEqual(ensured_draft_course_run.uuid, not_draft_course_run.uuid) + self.assertTrue(ensured_draft_course_run.draft) + self.assertNotEqual(ensured_draft_course_run.course, not_draft_course_run.course) + self.assertEqual(ensured_draft_course_run.course.uuid, not_draft_course_run.course.uuid) + + # Check slugs are equal + self.assertEqual(ensured_draft_course_run.slug, not_draft_course_run.slug) + + # Seat checks + draft_seats = ensured_draft_course_run.seats.all() + not_draft_seats = not_draft_course_run.seats.all() + self.assertNotEqual(draft_seats, not_draft_seats) + self.assertEqual(len(draft_seats), len(not_draft_seats)) + for i, __ in enumerate(draft_seats): + self.assertEqual(draft_seats[i].price, not_draft_seats[i].price) + self.assertEqual(draft_seats[i].sku, not_draft_seats[i].sku) + self.assertNotEqual(draft_seats[i].course_run, not_draft_seats[i].course_run) + self.assertEqual(draft_seats[i].course_run.uuid, not_draft_seats[i].course_run.uuid) + self.assertEqual(draft_seats[i].official_version, not_draft_seats[i]) + self.assertEqual(not_draft_seats[i].draft_version, draft_seats[i]) + + # Check draft course is also created + draft_course = ensured_draft_course_run.course + not_draft_course = Course.objects.get(uuid=course.uuid) + self.assertNotEqual(draft_course, not_draft_course) + self.assertEqual(draft_course.uuid, not_draft_course.uuid) + self.assertTrue(draft_course.draft) + + # Check official and draft versions match up + self.assertEqual(ensured_draft_course_run.official_version, not_draft_course_run) + self.assertEqual(not_draft_course_run.draft_version, ensured_draft_course_run) + + def test_ensure_draft_world_not_draft_course_given(self): + course = CourseFactory() + entitlement = CourseEntitlementFactory(course=course) + course.entitlements.add(entitlement) + course_runs = CourseRunFactory.create_batch(3, course=course) + for run in course_runs: + course.course_runs.add(run) + course.canonical_course_run = course_runs[0] + course.save() + org = OrganizationFactory() + course.authoring_organizations.add(org) + editor = CourseEditorFactory(course=course) + + ensured_draft_course = utils.ensure_draft_world(course) + not_draft_course = Course.objects.get(uuid=course.uuid) + + self.assertNotEqual(ensured_draft_course, not_draft_course) + self.assertEqual(ensured_draft_course.uuid, not_draft_course.uuid) + self.assertTrue(ensured_draft_course.draft) + + # Check slugs are equal + self.assertEqual(ensured_draft_course.slug, not_draft_course.slug) + + # Check authoring orgs are equal + self.assertEqual(list(ensured_draft_course.authoring_organizations.all()), + list(not_draft_course.authoring_organizations.all())) + + # Check canonical course run was updated + self.assertNotEqual(ensured_draft_course.canonical_course_run, not_draft_course.canonical_course_run) + self.assertTrue(ensured_draft_course.canonical_course_run.draft) + self.assertEqual(ensured_draft_course.canonical_course_run.uuid, not_draft_course.canonical_course_run.uuid) + + # Check course editors are moved from the official version to the draft version + self.assertEqual(CourseEditor.objects.count(), 1) + self.assertEqual(editor.course, ensured_draft_course) + + # Check course runs all share the same UUIDs, but are now all drafts + not_draft_course_runs_uuids = [run.uuid for run in course_runs] + draft_course_runs_uuids = [ + run.uuid for run in ensured_draft_course.course_runs.all() + ] + self.assertListEqual(draft_course_runs_uuids, not_draft_course_runs_uuids) + + # Entitlement checks + draft_entitlement = ensured_draft_course.entitlements.first() + not_draft_entitlement = not_draft_course.entitlements.first() + self.assertNotEqual(draft_entitlement, not_draft_entitlement) + self.assertEqual(draft_entitlement.price, not_draft_entitlement.price) + self.assertEqual(draft_entitlement.sku, not_draft_entitlement.sku) + self.assertNotEqual(draft_entitlement.course, not_draft_entitlement.course) + self.assertEqual(draft_entitlement.course.uuid, not_draft_entitlement.course.uuid) + + # check slug history not copied over + self.assertEqual(ensured_draft_course.url_slug_history.count(), 0) + self.assertEqual(not_draft_course.url_slug_history.count(), 1) + + # Check official and draft versions match up + self.assertEqual(ensured_draft_course.official_version, not_draft_course) + self.assertEqual(not_draft_course.draft_version, ensured_draft_course) + + self.assertEqual(draft_entitlement.official_version, not_draft_entitlement) + self.assertEqual(not_draft_entitlement.draft_version, draft_entitlement) + + def test_ensure_draft_world_creates_course_entitlement_from_seats(self): + """ + If the official course has no entitlement, an entitlement is created from the seat data from active runs. + """ + future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + course = CourseFactory() + run = CourseRunFactory(course=course, end=future, enrollment_end=None) + seat = SeatFactory(course_run=run, type=SeatTypeFactory.verified()) + ensured_draft_course = utils.ensure_draft_world(course) + + draft_entitlement = ensured_draft_course.entitlements.first() + self.assertEqual(draft_entitlement.price, seat.price) + self.assertEqual(draft_entitlement.currency, seat.currency) + self.assertEqual(draft_entitlement.mode.slug, Seat.VERIFIED) + + +@ddt.ddt +class TestCreateMissingEntitlement(TestCase): + @ddt.data( + # single verified seat makes entitlement + ([[{'type': Seat.VERIFIED, 'price': 50, 'currency': 'EUR'}]], (Seat.VERIFIED, 50, 'EUR')), + # single professional seat makes entitlement too + ([[{'type': Seat.PROFESSIONAL, 'price': 70, 'currency': 'USD'}]], (Seat.PROFESSIONAL, 70, 'USD')), + ([[{'type': Seat.VERIFIED}, {'type': Seat.PROFESSIONAL}]], None), # multiple valid seats makes nothing + ([[{'type': Seat.AUDIT}]], None), # no valid seats make nothing + ([[]], None), # no seats at all make nothing + ([], None), # no runs at all make nothing + # runs that disagree about valid seats make nothing + ([[{'type': Seat.VERIFIED}], [{'type': Seat.PROFESSIONAL}]], None), + # runs that disagree about price make nothing + ([[{'type': Seat.VERIFIED, 'price': 10}], [{'type': Seat.VERIFIED, 'price': 20}]], None), + # runs that disagree about currency make nothing + ([[{'type': Seat.VERIFIED, 'price': 10, 'currency': 'EUR'}], + [{'type': Seat.VERIFIED, 'price': 10, 'currency': 'USD'}]], None), + # multiple agreeing runs makes entitlement + ([[{'type': Seat.VERIFIED, 'price': 20, 'currency': 'EUR'}], + [{'type': Seat.VERIFIED, 'price': 20, 'currency': 'EUR'}]], (Seat.VERIFIED, 20, 'EUR')), + ([[{'type': Seat.MASTERS}, {'type': Seat.CREDIT}, {'type': Seat.PROFESSIONAL, 'price': 10, 'currency': 'USD'}]], + (Seat.PROFESSIONAL, 10, 'USD')), # non-relevant seats don't stop us from making an entitlement + ) + @ddt.unpack + def test_seat_combos(self, runs, expected): + """ Verify that the right entitlement gets created when possible """ + future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + course = CourseFactory() + for seats in runs: + run = CourseRunFactory(course=course, end=future, enrollment_end=None) + for seat in seats: + if 'currency' in seat: + seat['currency'] = Currency.objects.get(code=seat['currency']) + if 'type' in seat: + seat['type'] = SeatType.objects.get_or_create(slug=seat['type'])[0] + SeatFactory(**seat, course_run=run) + + self.assertFalse(course.entitlements.exists()) # sanity check + create_missing_entitlement(course) + + self.assertEqual(course.entitlements.count(), 1 if expected else 0) + if expected: + entitlement = course.entitlements.first() + self.assertEqual(entitlement.mode.slug, expected[0]) + self.assertEqual(entitlement.price, expected[1]) + self.assertEqual(entitlement.currency.code, expected[2]) + self.assertEqual(entitlement.partner, course.partner) + self.assertFalse(entitlement.draft) # tested below + + def test_draft_course(self): + """ Verifies that a draft course will create a draft entitlement """ + future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + course = CourseFactory(draft=True) + run = CourseRunFactory(course=course, end=future, enrollment_end=None, draft=True) + seat = SeatFactory(course_run=run, type=SeatTypeFactory.verified(), draft=True) + + self.assertFalse(course.entitlements.exists()) # sanity check + create_missing_entitlement(course) + + self.assertEqual(course.entitlements.count(), 1) + entitlement = course.entitlements.first() + self.assertEqual(entitlement.mode.slug, Seat.VERIFIED) + self.assertEqual(entitlement.price, seat.price) + self.assertEqual(entitlement.currency, seat.currency) + self.assertTrue(entitlement.draft) + + @ddt.data( + ((10, -10), 10), + ((10, 10), 10), + ((10,), 10), + ((-10,), -10), + ((-10, -20), -20), + ) + @ddt.unpack + def test_active_runs(self, dates, expected): + """ Verifies that we only consider active runs (or last inactive) """ + + def date_to_price(offset): + # Because we are just working with date offsets here, to avoid negative prices if we ever require that, + # we convert a dates to a positive price number + if offset < 0: + return offset * -10 + 1 + else: + return offset * 10 + + course = CourseFactory() + now = datetime.datetime.now(pytz.UTC) + usd = Currency.objects.get(code='USD') + for date in dates: + run = CourseRunFactory(course=course, end=now + datetime.timedelta(days=date), enrollment_end=None) + SeatFactory(course_run=run, type=SeatTypeFactory.verified(), price=date_to_price(date), currency=usd) + + self.assertFalse(course.entitlements.exists()) # sanity check + self.assertTrue(create_missing_entitlement(course)) + + entitlement = course.entitlements.first() + self.assertEqual(entitlement.price, date_to_price(expected)) + + @mock.patch('course_discovery.apps.course_metadata.utils.push_to_ecommerce_for_course_run') + def test_push_to_ecommerce(self, mock_push): + """ Verifies that we push new entitlement to ecommerce """ + future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10) + course = CourseFactory() + run = CourseRunFactory(course=course, end=future, enrollment_end=None) + SeatFactory(course_run=run, type=SeatTypeFactory.verified()) + + course.canonical_course_run = run + course.save() + + self.assertFalse(course.entitlements.exists()) # sanity check + self.assertTrue(create_missing_entitlement(course)) + + self.assertEqual(mock_push.call_count, 1) + self.assertEqual(mock_push.call_args[0][0], run) + + +# pylint: disable=line-too-long +@ddt.ddt +class CleanHtmlTests(TestCase): + @ddt.data( + # Make sure we leave certain things alone + ('', '',), + ('

Para

', '

Para

'), + ('

One

Two

', '

One

\n

Two

'), + ('Em', '

Em

'), + ('Entities&', '

Entities&

'), + ('Link', '

Link

'), + ('
  • 1
  • 2
', '
    \n
  • 1
  • \n
  • 2
  • \n
'), + + # Make sure our diacritics are handled nicely + # pylint: disable=line-too-long + ('These are our at risk characters áàãäéêèíîóôœòöúùü', '

These are our at risk characters áàãäéêèíîóôœòöúùü

'), + # pylint: disable=line-too-long + ('These are our at risk characters áàãäéêèíîóôœòöúùü', '

These are our at risk characters áàãäéêèíîóôœòöúùü

'), + + # Make sure we treat incoming text as HTML, not markdown + ('Bare Text\nSame Para\n\nNew Para', '

Bare Text Same Para New Para

'), + + # And make sure we strip what we should + ('

Class

', '

Class

'), + ('

Inline Style

', '

Inline Style

'), + ('', ''), + ('', ''), + ('NB SP', '

NBSP

'), + + # Make sure that only spans with lang tags are preserved in the saved string + ('

with lang

', '

with lang

'), + ('

lang and class

', '

lang and class

'), + ('

class only

', '

class only

'), + + # A sample text from real life when pasting from Microsoft Word into a rich text editor + ('

qwerty

 

Take Notes

·        To take notes, just tap here and start typing.

·        Or, easily create a digital notebook for all your notes that automatically syncs across your devices, using the free OneNote app.

To learn more and get OneNote, visit www.onenote.com.

 

Heading 2

1.    Ordered item1

2.    Ordered item2

3.    Ordered item3 with italics, bold, underline, strikethrough, all

 

 

 

', + """

qwerty

+

Take Notes

+
    +
  • +

    To take notes, just tap here and start typing.

    +
  • +
  • +

    Or, easily create a digital notebook for all your notes that automatically syncs across your devices, using the free OneNote app.

    +
  • +
+

To learn more and get OneNote, visit www.onenote.com.

+

Heading 2

+
    +
  1. +

    Ordered item1

    +
  2. +
  3. +

    Ordered item2

    +
  4. +
  5. +

    Ordered item3 with italics , bold , underline , ~~strikethrough~~ , ~~_all~~_

    +
  6. +
+

"""), + ) + @ddt.unpack + def test_clean_html(self, content, expected): + """ Verify the method removes unnecessary HTML attributes. """ + self.maxDiff = None + self.assertEqual(clean_html(content), expected) diff --git a/course_discovery/apps/course_metadata/tests/test_validators.py b/course_discovery/apps/course_metadata/tests/test_validators.py new file mode 100644 index 0000000000..3b2c28d5d9 --- /dev/null +++ b/course_discovery/apps/course_metadata/tests/test_validators.py @@ -0,0 +1,27 @@ +import ddt +from django.core.exceptions import ValidationError +from django.test import TestCase + +from course_discovery.apps.course_metadata.validators import validate_html + + +@ddt.ddt +class TestHtmlValidator(TestCase): + @ddt.data('', None, '

Hello World

', '

', '', '') + def test_valid_html(self, html): + validate_html(html) + + @ddt.data( + (' - - - - - - - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/add_courserun_form.html b/course_discovery/apps/publisher/templates/publisher/add_courserun_form.html deleted file mode 100644 index d006a861c2..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/add_courserun_form.html +++ /dev/null @@ -1,175 +0,0 @@ -{% extends 'publisher/base.html' %} -{% load i18n %} -{% load static %} - -{% block title %} - {% trans "New Course Run" %} -{% endblock title %} - -{% block page_content %} -

-

{% trans "New Course Run" %}

-
-

- {% blocktrans %} - When you create a course run, Publisher immediately creates a page for the course run in Publisher, and the edX team creates a Studio URL for the course run. You will receive an email message when edX has created the Studio URL. - {% endblocktrans %} -

-
- - {% include 'alert_messages.html' %} - -
{% csrf_token %} - - {% if course_form %} - - {% endif %} - -
-
-
-
- -
{% trans "COURSE START DATE" %} * Required
-
-
-
    -
  • - {% trans "Start on a Tuesday, Wednesday, or Thursday." %} -
  • -
  • - {% trans "Avoid major U.S. holidays." %} -
  • -
  • - {% trans "Specify a month, day, and year. If you are unsure of the exact date, specify a day that is close to the estimated start date. For example, if your course will start near the end of March, specify March 31." %} -
  • -
-
-
- - {{ run_form.start }} -
-
- - {% if not publisher_enable_read_only_fields %} -
{% trans "PACING TYPE" %}* Required
-
-
-

{% trans "Instructor-paced courses include individual assignments that have specific due dates before the course end date." %}

-

{% trans "Self-paced courses do not have individual assignments that have specific due dates before the course end date. All assignments are due on the course end date." %}

-
-
- -
{{ run_form.pacing_type }}
-
-
- {% endif %} - -
{% trans "COURSE END DATE" %} * Required
-
-
- {% trans "Specify a month, day, and year. If you are unsure of the exact date, specify a day that is close to the estimated end date. For example, if your course will end near the end of March, specify March 31." %} -
-
- - {{ run_form.end }} -
-
- -
-
-
-
- -
-
-
-
- -
{% trans "CERTIFICATE TYPE AND PRICE" %}
-
-
- {% trans "If the course offers a verified or professional education certificate, select the certificate type and enter the price for the certificate." %} -
-
-
-
- {{ seat_form.type }} -
-
- - {{ seat_form.price }} -
- - {{ seat_form.credit_price }} -
-
-
- {% if seat_form.price.errors %} -
- - {{ seat_form.price.errors|escape }} - -
- {% endif %} -
-
- -
-
-
-
- -
-
- {% trans "Cancel" %} - -
-
-
-
-{% endblock %} - -{% block extra_js %} - - - -{% endblock %} - -{% block js_without_compress %} - {{ course_form.media }} -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/admin/add_user_form.html b/course_discovery/apps/publisher/templates/publisher/admin/add_user_form.html deleted file mode 100644 index 38813058aa..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/admin/add_user_form.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n %} - -{% block form_top %} -

{% trans "First, enter a username and select a group. Then, you'll be able to edit more user options." %}

-{% endblock %} - -{% block after_field_sets %} - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/admin/import_course.html b/course_discovery/apps/publisher/templates/publisher/admin/import_course.html deleted file mode 100644 index f134f65392..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/admin/import_course.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'publisher/base.html' %} -{% load i18n %} -{% load static %} -{% block title %} - {% trans "Course Detail" %} -{% endblock title %} - -{% block page_content %} -
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} -
- -
- {% csrf_token %} -
- - {{ form.start_id }} - -
-
-{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/base.html b/course_discovery/apps/publisher/templates/publisher/base.html deleted file mode 100644 index 40911c13e9..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/base.html +++ /dev/null @@ -1,88 +0,0 @@ -{% extends 'base.html' %} - -{% load compress %} -{% load i18n %} -{% load static %} - -{% block css %} - {{ block.super }} - {% compress css %} - - - - {% endcompress %} -{% endblock %} - -{% block content %} -
- {% include 'publisher/_header.html' %} -
- - - - {% if request.path == dashboard_url %} -
- {% else %} -
- {% endif %} - - {% block breadcrumbs %} - {% include 'publisher/_breadcrumbs.html' %} - {% endblock %} - {% block page_content %} - {% endblock %} -
- -
- - {% include 'publisher/_footer.html' %} -
-{% endblock %} - -{% block js %} - {{ block.super }} - - {% compress js %} - - - - - - - - - - - - {% endcompress %} - - - - {% compress js %} - {% block extra_js %} - {% endblock %} - {% endcompress %} - - {% block js_without_compress %}{% endblock %} -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/comments/add_auth_comments.html b/course_discovery/apps/publisher/templates/publisher/comments/add_auth_comments.html deleted file mode 100644 index 3394286316..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/comments/add_auth_comments.html +++ /dev/null @@ -1,28 +0,0 @@ -{% load i18n %} -{% load comments %} - -{% if user.is_authenticated and comment_object %} -
-
{% if box_label %}{{ box_label }}:{% else %}{% trans 'Comment:' %}{% endif %}
-
- {% get_comment_form for comment_object as form %} -
- {% csrf_token %} - {{ form.comment }} - {{ form.content_type }} - {{ form.object_pk }} - {{ form.timestamp }} - {{ form.security_hash }} - - -
- {% if btn_label %} - - {% else %} - - {% endif %} -
-
-
-
-{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/comments/comments_list.html b/course_discovery/apps/publisher/templates/publisher/comments/comments_list.html deleted file mode 100644 index 10e33ac5b6..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/comments/comments_list.html +++ /dev/null @@ -1,26 +0,0 @@ -{% load i18n %} -{% load comments %} -{% if comment_object %} -
- {% get_comment_list for comment_object as comment_list %} -
- {% for comment in comment_list reversed %} -
{{ comment.modified|date:"F d, Y, H:i:s a" }} 
- {% ifequal comment.comment_type 'decline_preview' %}Preview Decline: -
{{ comment.comment }}
- {% else %} -
{{ comment.comment }}
- {% endifequal %} - -
- {% endfor %} -
-
-{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_detail.html b/course_discovery/apps/publisher/templates/publisher/course_detail.html deleted file mode 100644 index 436fabfcdf..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_detail.html +++ /dev/null @@ -1,281 +0,0 @@ -{% extends 'publisher/base.html' %} -{% load i18n %} -{% load static %} -{% block title %} - {{ object.number }} -{% endblock title %} - -{% block page_content %} -{% include 'alert_messages.html' %} - - - - -
-
-
- -
- {% with object.history.all as history_list %} - {% if history_list.count > 1 %} -
- {% include 'publisher/_history_widget.html' %} -
- {% endif %} - {% endwith %} - - {% if can_edit %} - {% url 'publisher:publisher_courses_edit' pk=object.id as edit_page_url %} - - {% trans "EDIT" %} - -
- {% endif %} -
- -
-
- {% trans "Organization Name" %} -
-
- {{ object.organizations.first }} -
-
- -
-
- {% trans "Course Team Admin" %} -
-
{% firstof object.course_team_admin.full_name object.course_team_admin.username %}
-
- -
-
- {% trans "Course Title" %} -
-
{{ object.course_title }}
-
-
- -
-
- {% trans "Course Number" %} -
-
{{ object.number }}
-
-
- -
-
- {% trans "Enrollment Track" %} -
-
- {% with object.entitlements.first.mode as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "Certificate Price" %} -
-
- {% with object.entitlements.first.price as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "Short Description" %} -
-
- {% with object.course_short_description as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "Long Description" %} -
-
- {% with object.course_full_description as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "What You Will Learn" %} -
-
- {% with object.expected_learnings as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "Primary Subject" %} -
-
- {% with object.primary_subject as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "Additional Subject" %} -
-
- {% with object.secondary_subject as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
- -
-
- {% trans "Additional Subject" %} -
-
- {% with object.tertiary_subject as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
-
- {% trans "Course Image" %} -
- {% if object.image %} - {% trans 'Course Image' %} - {% else %} - {% trans "(Required) Not yet added" %} - {% endif %} -
-
-
- {% trans "Prerequisites" %} -
-
- {% with object.prerequisites as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
-
{% trans "Course Level" %} -
-
- {% with object.level_type as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
-
{% trans "Learner Testimonials" %} -
-
- {% with object.learner_testimonial as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
-
{% trans "FAQ" %} -
-
- {% with object.faq as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
- -
-
{% trans "Additional Information" %} -
-
- {% with object.additional_information as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
- - -
-
{% trans "Syllabus" %} -
-
- {% with object.syllabus as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
- -
-
{% trans "About Video Link" %} -
- {% with object.video_link as value %} - {% if value %} - {{ value }} - {% else %} - {% trans "(Optional) Not yet added" %} - {% endif %} - {% endwith %} -
-
-
-
- - -
-{% endblock %} -{% block extra_js %} - - - - - - - - - - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_detail/_edit_warning_popup.html b/course_discovery/apps/publisher/templates/publisher/course_detail/_edit_warning_popup.html deleted file mode 100644 index 3fc47d3a01..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_detail/_edit_warning_popup.html +++ /dev/null @@ -1,19 +0,0 @@ -{% load i18n %} - - diff --git a/course_discovery/apps/publisher/templates/publisher/course_detail/_widgets.html b/course_discovery/apps/publisher/templates/publisher/course_detail/_widgets.html deleted file mode 100644 index 60b57cf1b7..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_detail/_widgets.html +++ /dev/null @@ -1,54 +0,0 @@ -{% load i18n %} - -
- - -
- -
-
{% trans "COURSE RUNS" %}
- - {% trans "CREATE RUN" %} - -
-
- {% if course.course_runs %} - {% for course_run in course.course_runs %} -
-
- {% blocktrans with created_date=course_run.created.date created_time=course_run.created.time created_by=course_run.created_by %} - Created {{ created_date }} at {{ created_time }} by {{ created_by }} - {% endblocktrans %} -
-
- -
- {% trans "STUDIO URL" %} - - {% if course_run.studio_url %} - {{ course_run.lms_course_id }} - {% else %} - {% trans "To be added by edX" %} - {% endif %} -
-
-
- {% endfor %} - {% else %} -
- {% trans "No course runs exist for this course" %} -
- {% endif %} -
- -
- {% include 'publisher/_approval_widget.html' %} -
-
- -{% if add_warning_popup %} - {% include 'publisher/course_detail/_edit_warning_popup.html' %} -{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_edit_form.html b/course_discovery/apps/publisher/templates/publisher/course_edit_form.html deleted file mode 100644 index 1ae15aa771..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_edit_form.html +++ /dev/null @@ -1,654 +0,0 @@ -{% extends 'publisher/base.html' %} - -{% load compress %} -{% load i18n %} -{% load static %} - -{% block title %} - {% trans "Edit Course" %} -{% endblock title %} - -{% block css %} - {% compress css %} - - - {% endcompress %} - {{ block.super }} -{% endblock %} - -{% block page_content %} -
-

{% trans "Edit Course" %}

- -
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} -

- {% trans "All required fields must be complete before this course can be sent to edX marketing for review." %} -

-

- {% trans "Note:" %} - {% trans "If you edit course information after edX marketing has reviewed the course, you have to send the course for review again." %} -

-
{% csrf_token %} - -
-
-
-
- -
{% trans "COURSE TITLE" %} * Required for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-

{% trans "Maximum 70 characters. Recommended 50 or fewer characters." %}

- -

{% trans "An effective course title:" %}

-
    -
  • {% trans "Clearly indicates the course subject matter." %}
  • -
  • {% trans "Follows search engine optimization (SEO) guidelines." %}
  • -
  • {% trans "Targets a global audience." %}
  • -
-

{% trans "If the course is part of a sequence, include both sequence and course information as \"Sequence: Course\"." %}

-
-
-

- {% trans "Single Courses" %} -
    -
  • {% trans "English Grammar and Essay Writing" %}
  • -
- {% trans "Sequence Courses:" %} -
    -
  • {% trans "Statistics: Inference" %}
  • -
  • {% trans "Statistics: Probability" %}
  • -
-
-
-
- {% if form.organization.field.queryset.all.count > 1 %} - - {{ form.organization }} - {% else %} - {% with form.organization.field.queryset|first as organization %} - - {{ organization.name }} - - {% endwith %} - {% endif %} - - {{ form.team_admin }} - - - - - {{ form.title }} -

{% trans "255 character limit, including spaces." %}

- {% if form.title.errors %} -
- - {{ form.title.errors|escape }} - -
- {% endif %} -
-
-
{% trans "COURSE NUMBER" %} * Required for announcement
-
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-
    -
  • {% trans "Maximum 50 characters. Characters can be letters, numbers, periods, underscores or hyphens." %}
  • -
  • {% trans "If a course consists of several modules, the course number can have an ending such as .1x or .2x." %}
  • -
-
-
-
    -
  • {% trans "CS002x" %}
  • -
  • {% trans "BIO1.1x, BIO1.2x" %}
  • -
-
-
-
- - {% if has_course_run %} - {{ course.number }} - - {% else %} - {{ form.number }} - {% if form.number.errors %} -
- {{ form.number.errors|escape }} -
- {% endif %} - {% endif %} -
-
-
-
- -
-
-
-
{% trans "SHORT DESCRIPTION" %} * Required for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-

{% trans "An effective short description:" %}

-
    -
  • {% trans "Contains 25–50 words." %}
  • -
  • {% trans "Functions as a tagline." %}
  • -
  • {% trans "Conveys compelling reasons to take the course." %}
  • -
  • {% trans "Follows SEO guidelines." %}
  • -
  • {% trans "Targets a global audience." %}
  • -
-
-
-

{% trans "The first MOOC to teach positive psychology. Learn science-based principles and practices for a happy, meaningful life." %}

-
-
-
- - - {{ form.short_description }} - -
-
- -
{% trans "LONG DESCRIPTION" %} * Required for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-

{% trans "An effective long description:" %}

-
    -
  • {% trans "Contains 150–300 words." %}
  • -
  • {% trans "Is easy to skim." %}
  • -
  • {% trans "Uses bullet points instead of dense text paragraphs." %}
  • -
  • {% trans "Follows SEO guidelines." %}
  • -
  • {% trans "Targets a global audience." %}
  • -
-

{% trans "The first four lines are visible when the About page opens. Learners can select \"See More\" to view the full description." %}

-
-
- {% trans "Content-based example:" %} -

{% trans "Want to learn computer programming, but unsure where to begin? This is the course for you! Scratch is the computer programming language that makes it easy and fun to create interactive stories, games and animations and share them online." %}

-

{% trans "This course is an introduction to computer science using the programming language Scratch, developed by MIT. Starting with the basics of using Scratch, the course will stretch your mind and challenge you. You will learn how to create amazing games, animated images and songs in just minutes with a simple “drag and drop” interface." %}

-

{% trans "No previous programming knowledge needed. Join us as you start your computer science journey." %}

- {% trans "Skills-based example:" %} - {% trans "Taught by instructors with decades of experience on Wall Street, this M&A course will equip analysts and associates with the skills they need to rise to employment in the M&A field. Additionally, directors and managers who have transitioned, or hope to transition, to M&A from other areas such as equities or fixed income can use this course to eliminate skill gaps." %} -
-
-
- - - {{ form.full_description }} - -
-
- - -
{% trans "WHAT YOU WILL LEARN" %} * Required for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-
    -
  • {% trans "The skills and knowledge learners will acquire in this course." %}
  • -
  • {% trans "Format each item as a bullet with four to ten words." %}
  • -
-
-
-
    -
  • {% trans "Basic R Programming" %}
  • -
  • {% trans "An applied understanding of linear and logistic regression" %}
  • -
  • {% trans "Application of text analytics" %}
  • -
  • {% trans "Linear and integer optimization" %}
  • -
-
-
-
- - - {{ form.expected_learnings }} - -
-
- -
{% trans "SUBJECT FIELD" %} * Required for announcement
-
-
-

{% trans "The subject of the course." %}

-

{% trans "You can select up to two subjects in addition to the primary subject. Only the primary subject appears on the About page." %}

-
-
- - {{ form.primary_subject }} -
-
-
-
  -
-
- - {{ form.secondary_subject }} -
-
- -
-
  -
-
- - {{ form.tertiary_subject }} -
-
- -
{% trans "COURSE IMAGE" %} * Required for announcement
-
-
- {% trans "An eye-catching, colorful image that captures the essence of your course." %} -
- {% trans "Course Image Guidelines:" %} -
    -
  • - {% trans "New course images must be 1134 X 675 pixels in size. " %} - - {% trans "Learn more." %} - -
  • -
  • {% trans "Each course in a sequence must have a unique image." %}
  • -
  • {% trans "The image cannot include text or headlines." %}
  • -
  • {% trans "You must have permission to use the image. Possible image sources include Flickr creative commons, Stock Vault, Stock XCHNG, and iStock Photo." %}
  • -
-
-
- -
- {{ form.image }} - - {% if form.image.errors %} -
- - {{ form.image.errors|escape }} - -
- {% endif %} -
-
-
- -
{% trans "PREREQUISITES" %} Optional for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-
    -
  • {% trans "Recommended maximum 1000 characters." %}
  • -
  • {% trans "Specific knowledge learners must have to be successful in the course. If the course has no prerequisites, enter \"None\"." %}
  • -
- -
-
-
    -
  • {% trans "Secondary school (high school) algebra; basic mathematics concepts" %}
  • -
  • {% trans "Graduate-level understanding of Keynesian economics" %}
  • -
  • {% trans "Basic algebra" %}
  • -
-
-
-
- - - {{ form.prerequisites }} - -
-
- -
{% trans "SYLLABUS" %} Optional for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-
    -
  • {% trans "A review of content covered in your course, organized by week or module." %}
  • -
  • {% trans "Focus on topics and content." %}
  • -
  • {% trans "Do not include detailed information about course logistics, such as grading, communication policies, and reading lists." %}
  • -
  • {% trans "Format items as either paragraphs or a bulleted list." %}
  • -
-
-
-
    -
  • {% trans "Week 1: From Calculator to Computer" %}
    - {%trans "Introduction to basic programming concepts, such as values and expressions, as well as making decisions when implementing algorithms and developing programs." %} -
  • -
  • {% trans "Week 2: State Transformation" %}
    - {%trans "Introduction to state transformation, including representation of data and programs as well as conditional repetition." %} -
  • -
-
-
-
- - - {{ form.syllabus }} - -
-
- -
{% trans "LEVEL" %} * Required for announcement
-
-
-
    -
  • {% trans "Introductory - No prerequisites; a learner who has completed some or all secondary school could complete the course." %}
  • -
  • {% trans "Intermediate - Basic prerequisites; learners need to complete secondary school or some university courses." %}
  • -
  • {% trans "Advanced - Significant prerequisites; the course is geared to third or fourth year university students or master's degree students." %}
  • -
-
-
- - {{ form.level_type }} -
-
- -
{{ form.faq.label }} Optional for announcement
-
-
-

{% trans "Any frequently asked questions and the answers to those questions." %}

-
-
- x -
-
-
- - - {{ form.faq }} - -
-
- - {% if is_internal_user %} -
{{ form.video_link.label|upper }} Optional for announcement
-
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-

- {%trans "The About video should excite and entice potential students to take your course. Think of it as a movie trailer or TV show promotion. The video should be compelling, and exhibit the instructor’s personality." %}
- {%trans "The ideal length is 30-90 seconds (learners typically watch an average of 30 seconds)." %}
- {%trans "The About video should be produced and edited, using elements such as graphics and stock footage." %} -

-

{% trans "The About video should answer these key questions." %}

-
    -
  • {% trans "Why should a learner register?" %}
  • -
  • {% trans "What topics and concepts are covered?" %}
  • -
  • {% trans "Who is teaching the course?" %}
  • -
  • {% trans "What institution is delivering the course?" %}
  • -
-

{% trans "Naming specifications:" %}

- -

{% trans "Technical specifications:" %}

-
    -
  • {% trans "Codec: H.264" %}
  • -
  • {% trans "Container: .mp4" %}
  • -
  • {% trans "Resolution: 1920x1080" %}
  • -
  • {% trans "Frame rate: 29.97 fps" %}
  • -
  • {% trans "Aspect: 1.0" %}
  • -
  • {% trans "Bitrate: 5Mbps VBR" %}
  • -
  • {% trans "Audio codec: AAC 44.1KHz/192 Kbps" %}
  • -
-
-
-

- {%trans "Visit edX’s YouTube channel for examples of other About videos:" %} - www.youtube.com/user/EdXOnline -

-
-
-
- - {{ form.video_link }} -
-
- {% else %} - {{ form.video_link }} - {% endif %} - -
{{ form.learner_testimonial.label|upper }} Optional for announcement
-
-
-
- x -
-
-
-
    -
  • - {% trans "Best Practices" %} -
  • -
  • - {% trans "Examples" %} -
  • -
-
-
-
    -
  • {% trans "A quote from a learner in the course, demonstrating the value of taking the course." %}
  • -
  • {% trans "Should be no more than 25-50 words in length." %}
  • -
-
-
-

- {%trans "“Brilliant course! It's definitely the best introduction to electronics in the world! Interesting material, clean explanations, well prepared quizzes, challenging homeworks and fun labs.” – Previous Student" %} -

-
-
-
- - - - {{ form.learner_testimonial }} - -
-
- -
{{ form.additional_information.label }} Optional for announcement
-
-
-

{% trans "Any additional information to be provided to learners." %}

-
-
- x -
-
-
- - - {{ form.additional_information }} - -
-
- - {% if entitlement_form %} -
{% trans "CERTIFICATE TYPE AND PRICE" %}
-
-
-
- {% trans "If the course offers a verified or professional education certificate, select the certificate type and enter the price for the certificate." %} -
-
- {% trans "If you are transitioning the certificate type and price from the course run level to the course level, please be informed that this is a permanent change and you will be maintaining this information at the course level from now on." %} -
-
-
-
-
- - {{ entitlement_form.mode }} -
-
- - {{ entitlement_form.price }} -
-
-
-
- {% endif %} -
-
-
-
-
- -
-
- - {% trans "Cancel" %} - - -
-
- -
-
-
-{% endblock %} - -{{% block extra_js %} - - - - - - - - - - - - - - - - - - - - - - -{% endblock %} - - diff --git a/course_discovery/apps/publisher/templates/publisher/course_list.html b/course_discovery/apps/publisher/templates/publisher/course_list.html deleted file mode 100644 index 26ae4597bb..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_list.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends 'publisher/base.html' %} - -{% load i18n %} -{% load static %} -{% load compress %} - -{% block title %} - {% trans "Courses" %} -{% endblock title %} - -{% block page_content %} -

{{publisher_total_courses_count}} Courses

- - {% trans "Create New Course" %} - -
- - - - - - - - - - - - - - - -
- {% trans "Course Name" %} - - {% trans "Course Number" %} - - {% trans "Organization" %} - - {% trans "Project Coordinator" %} - - {% trans "Runs" %} - - {% trans "Course Team Status" %} - - {% blocktrans with site_name=site_name trimmed %}{{ site_name }} Status{% endblocktrans %} - - {% trans "Last Handoff" %} -
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_revision_history.html b/course_discovery/apps/publisher/templates/publisher/course_revision_history.html deleted file mode 100644 index 9cfb5d9fa9..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_revision_history.html +++ /dev/null @@ -1,174 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% load static %} -{% load compress %} - -{% block title %} - {% trans "Revision History" %} -{% endblock title %} - -{% block content %} -
-
{% trans "Revision History" %}
- -
-

- {% with date=history_object.history_date|date:"d-m-Y" name=history_object.history_user.full_name %} - {% blocktrans trimmed %} - {{ date }} by {{ name }} - {% endblocktrans %} - {% endwith %} - -

-
- -
-
-
-
- -
-
{% trans "Course Title" %}
- {{ object.title }} - {{ history_object.title }} - -
- -
-
{% trans "Course Number" %}
- {{ object.number }} - {{ history_object.number }} - -
- - -
-
{% trans "Short Description" %}
- {{ object.short_description }} - - {% with history_object.short_description as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - - -
- - -
-
{% trans "Long Description" %}
- {{ object.full_description }} - - {% with history_object.full_description as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - - - -
- -
-
{% trans "Expected Learning" %}
- {{ object.expected_learnings }} - - {% with history_object.expected_learnings as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - - -
- -
-
{% trans "Primary Subject" %}
- {{ object.primary_subject }} - - {% with history_object.primary_subject as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - - -
- -
-
{% trans "Additional Subject" %}
- {{ object.secondary_subject }} - - {% with history_object.secondary_subject as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} - - -
- -
-
{% trans "Additional Subject" %}
- {{ object.tertiary_subject }} - - {% with history_object.tertiary_subject as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} - - -
- -
-
{% trans "Prerequisites" %}
- {{ object.prerequisites }} - - {% with history_object.prerequisites as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - - -
- -
-
{% trans "Course Level" %}
- {{ object.level_type }} - - {% with history_object.level_type as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - - -
- -
-
{% trans "Learner Testimonials" %}
- {{ object.learner_testimonial }} - - {% with history_object.learner_testimonial as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} - - -
- -
-
{% trans "FAQ" %}
- {{ object.faq }} - - {% with history_object.faq as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} - - -
- -
-
{% trans "About Video Link" %}
- {{ object.video_link }} - {{ history_object.video_link }} - -
-
- -{% endblock %} - -{% block js %} - {{ block.super }} - {% compress js %} - - - - {% endcompress %} -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run/edit_run_form.html b/course_discovery/apps/publisher/templates/publisher/course_run/edit_run_form.html deleted file mode 100644 index cbe3ae987d..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run/edit_run_form.html +++ /dev/null @@ -1,391 +0,0 @@ -{% extends 'publisher/base.html' %} -{% load i18n %} -{% load static %} - -{% block title %} - {% trans "Edit Course Run" %} -{% endblock title %} - -{% block page_content %} -
-

- {% trans "Edit Course Run" %} -

- -
-{% if messages %} - {% for message in messages %} - - {% endfor %} -{% endif %} -

- {% blocktrans with span_start='' span_end='' trimmed %} - {{ span_start }}* All required fields must be complete before this course run can be sent for review.{{ span_end }} - {% endblocktrans %} -

-

- {% blocktrans with strong_start='' strong_end='' trimmed %} - {{ strong_start }}Note:{{ strong_end }} If you edit course information after edX marketing has reviewed the course, you have to send the course to edX marketing for review again. - {% endblocktrans %} -

-
-{% csrf_token %} -
-
-
-
-
{% trans "COURSE START DATE" %} * Required for review
-
-
- {% if publisher_enable_read_only_fields %} -

- {% trans "The start date for this course." %} - {% blocktrans with link_start='' link_end='' url=course_run.studio_schedule_and_details_url %} - For the most accurate information, and to make any edits to start date, please visit {{ link_start }}{{ url }}{{ link_middle }}Studio{{ link_end }}. - {% endblocktrans %} -

- {% else %} -
    -
  • - {% trans "Note that times use UTC." %} -
  • -
  • - {% trans "Start on a Tuesday, Wednesday, or Thursday." %} -
  • -
  • - {% trans "Avoid major U.S. holidays." %} -
  • -
  • - {% trans "Specify a month, day, and year. If you are unsure of the exact date, specify a day that is close to the estimated start date. For example, if your course will start near the end of March, specify March 31." %} -
  • -
- {% endif %} -
-
- {% if publisher_enable_read_only_fields %} - -
{{ course_run.start_date_temporary| date:"M d, Y, H:i:s A" }}
- {% else %} - - {% endif %} - {{ run_form.start }} - {% if run_form.start.errors %} -
- {{ run_form.start.errors|escape }} -
- {% endif %} -
-
- -
{% trans "COURSE END DATE" %} * Required for review
-
-
- {% if publisher_enable_read_only_fields %} -

- {% trans "The end date for this course." %} - {% blocktrans with link_start='' link_end='' url=course_run.studio_schedule_and_details_url %} - For the most accurate information, and to make any edits to end date, please visit {{ link_start }}{{ url }}{{ link_middle }}Studio{{ link_end }}. - {% endblocktrans %} -

- {% else %} - {% trans "Note that times use UTC. Specify a month, day, and year. If you are unsure of the exact date, specify a day that is close to the estimated end date. For example, if your course will end near the end of March, specify March 31." %} - {% endif %} -
-
- {% if publisher_enable_read_only_fields %} - -
{{ course_run.end_date_temporary| date:"M d, Y, H:i:s A" }}
- {% else %} - - {% endif %} - {{ run_form.end }} - {% if run_form.end.errors %} -
- {{ run_form.end.errors|escape }} -
- {% endif %} -
-
- -
{% trans "COURSE PACING" %} * Required for review
-
-
- {% if publisher_enable_read_only_fields %} -

- {% trans "The pacing type for this course." %} - {% blocktrans with link_start='' link_end='' url=course_run.studio_schedule_and_details_url %} - For the most accurate information, and to make any edits to pacing type, please visit {{ link_start }}{{ url }}{{ link_middle }}Studio{{ link_end }}. - {% endblocktrans %} -

- {% else %} -

{% trans "Instructor-paced courses include individual assignments that have specific due dates before the course end date." %}

-

{% trans "Self-paced courses do not have individual assignments that have specific due dates before the course end date. All assignments are due on the course end date." %}

- {% endif %} -
-
- {% if publisher_enable_read_only_fields %} - -
{{ course_run.get_pacing_type_temporary_display }}
- {% else %} - -
{{ run_form.pacing_type }}
- {% endif %} -
-
- -
{% trans "STUDIO URL" %} * Required for review
-
-
-

{% trans "The Studio URL for this course run. The edX PC creates this URL." %}

-
-
- - {{ run_form.lms_course_id }} - - {% if not is_project_coordinator %} -
- {% trans "STUDIO URL" %} - - {% if course_run.studio_url %} - {{ course_run.lms_course_id }} - {% else %} - {% trans "Not yet created" %} - {% endif %} -
- {% endif %} -
-
-
-
-
-
-
{% trans "PROGRAM ASSOCIATION" %} Optional
-
-
- {% trans "If this course is part of a program, select the program type, and then enter the name of the program." %} -
-
-
-
- - {{ run_form.is_micromasters}} -
- - {% if run_form.micromasters_name.errors %} -
- - {{ run_form.micromasters_name.errors|escape }} - -
- {% endif %} -
-
-
- - {{ run_form.is_professional_certificate}} -
- - {% if run_form.professional_certificate_name.errors %} -
- - {{ run_form.professional_certificate_name.errors|escape }} - -
- {% endif %} -
-
-
- - {{ run_form.is_xseries}} -
- - {% if run_form.xseries_name.errors %} -
- - {{ run_form.xseries_name.errors|escape }} - -
- {% endif %} -
-
-
- - {% if seat_form %} -
{% trans "CERTIFICATE TYPE AND PRICE" %} * Required for review
-
-
- {% trans "If the course offers a verified or professional education certificate, select the certificate type and enter the price for the certificate." %} -
-
-
-
- {{ seat_form.type }} -
-
- - {{ seat_form.price }} -
- - {{ seat_form.credit_price }} -
-
-
- {% if seat_form.price.errors %} -
- - {{ seat_form.price.errors|escape }} - -
- {% endif %} -
-
- {% endif %} - -
{% trans "COURSE STAFF" %} * Required for review
-
-
-
{% trans "The primary instructor or instructors for the course." %}
-
{% trans "The order that instructors are entered here is the same order they will be displayed on course pages." %}
- - {% trans "Make sure that you search for an instructor before you create a new instructor." %} - -
- - - -
- - -
- {% if publisher_add_instructor_feature %} - - {% endif %} - {% for staff in run_form.instance.staff.all %} - {% include "publisher/_personFieldLabel.html" %} - {% endfor %} - -
-
-
- -
{% trans "ESTIMATED EFFORT" %} * Required for review
-
-
- {% trans "The number of hours per week the learner should expect to spend on the course. This may be a range." %} -
-
-
-
- - {{ run_form.min_effort }} - {% if run_form.min_effort.errors %} - {{ run_form.min_effort.errors|escape }} - {% endif %} -
-
- - {{ run_form.max_effort }} -
-
-
-
- -
{% trans "LANGUAGE(S)" %} * Required for review
-
-
- {% trans "The languages available for videos, video transcripts, and other course content." %} -
-
- - {{ run_form.language}} - - - {{ run_form.transcript_languages}} - - - {{ run_form.video_language}} -
-
- -
{% trans "LENGTH" %} * Required for review
-
-
- {% trans "The length of the course, in weeks, rounded to the nearest whole number." %} -
-
- - {{ run_form.length}} -
-
- - {% if is_internal_user %} -
{{ run_form.has_ofac_restrictions.label|upper }} - Updated with legal review -
-
-
- {% trans "Checking this box will add the OFAC language to the FAQ on the marketing page." %} -
-
- -
{{ run_form.has_ofac_restrictions }}
-
-
- {% endif %} - -
-
-
-
- -
-
- {% trans "Cancel" %} - -
-
- -
-
-
-{% if publisher_add_instructor_feature %} - {% include "publisher/_add_instructor_popup.html" %} -{% endif %} -{% endblock %} - -{% block extra_js %} - - - - - - -{% endblock %} - -{% block js_without_compress %} - {{ run_form.media }} - {% if seat_form %} - - {% endif %} -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_all.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_all.html deleted file mode 100644 index 4638f891fa..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_all.html +++ /dev/null @@ -1,198 +0,0 @@ -{% load i18n %} - -
- -
-
- {% trans "Studio URL" %} -
-
- {% if course_run.studio_url %} - {{ course_run.lms_course_id }} - {% else %} - {% trans "(Required) To be added by edX" %} - {% endif %} -
-
- -
-
- {% trans "Start Date (time in UTC)" %} -
-
{{ course_run.start_date_temporary }}
-
- -
-
- {% trans "End Date (time in UTC)" %} -
-
{{ course_run.end_date_temporary }}
-
- - {% if is_seat_version %} -
-
- {% trans "Enrollment Track" %} -
-
{{ course_run.course_type }}
-
- - {% if course_run.seat_price %} -
-
- {% trans "Certificate Price" %} -
-
- ${{ course_run.seat_price }} -
-
- {% endif %} - - {% if course_run.credit_seat_price %} -
-
- {% trans "Credit Price" %} -
-
- ${{ course_run.credit_seat_price }} -
-
- {% endif %} - {% endif %} - -
-
- {% trans "Course Staff" %} -
-
- {% if not course_run.course_staff %} - {% trans "(Required) Not yet added" %} - {% else %} - {% for obj in course_run.course_staff %} -
- - -
- -
{{ obj.position }}
-
{{ obj.organization }}
-
-
-
- {% endfor %} - {% endif %} -
-
- -
-
- {% trans "Estimated Effort" %} -
-
- {% if course_run.min_effort and course_run.max_effort %} - {{ course_run.min_effort }}-{{ course_run.max_effort }} {% trans "hours per week" %} - {% elif course_run.min_effort %} - {{ course_run.min_effort }} {% trans "hours per week" %} - {% else %} - {% with course_run.estimated_effort as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - {% endif %} -
-
- -
-
- {% trans "Course Content Language" %} -
-
- {% with course_run.language.name as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Video Transcript Language" %} -
-
- {% with course_run.transcript_languages as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Course Video Language" %} -
-
- {% with course_run.video_language as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Course Length (Weeks)" %} -
-
- {% with course_run.length as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Has OFAC Restriction text" %} -
-
- {% with course_run.has_ofac_restrictions as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "MicroMasters Program Name" %} -
-
- {% with course_run.micromasters_name as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "XSeries Program Name" %} -
-
- {% with course_run.xseries_name as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Professional Certificate Program Name" %} -
-
- {% with course_run.professional_certificate_name as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_cat.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_cat.html deleted file mode 100644 index a274a1fd56..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_cat.html +++ /dev/null @@ -1,33 +0,0 @@ -{% load i18n %} -{% block content %} - -
-
-
- {% trans "Course ID" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.lms_course_id as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Enrollment Types" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.course_type|capfirst }}
-
-
- - - - {% include 'publisher/course_run_detail/_seats.html' %} - {% include 'publisher/course_run_detail/_credit_seat.html' %} - -
- -{% endblock %} - diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_clipboard.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_clipboard.html deleted file mode 100644 index 1360ee7c8e..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_clipboard.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_credit_seat.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_credit_seat.html deleted file mode 100644 index c69738effb..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_credit_seat.html +++ /dev/null @@ -1,25 +0,0 @@ -{% load i18n %} -

{% trans "Credit Seats" %}

-{% if course_run.credit_seat %} -
- - - - - - - - - - - - - - - - -
{% trans "Credit Provider" %}{% trans "Price" %}{% trans "Currency" %}{% trans "Credit Hours" %}{% trans "Upgrade Deadline (time in UTC)" %}
{{ course_run.credit_seat.credit_provider}}{{ course_run.credit_seat.price}}{{ course_run.credit_seat.currency.name}}{{ course_run.credit_seat.credit_hours }}{{ course_run.credit_seat.upgrade_deadline }}
-
-{% else %} - {% trans "(Optional) Not yet added" %} -{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_drupal.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_drupal.html deleted file mode 100644 index e4fb64448f..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_drupal.html +++ /dev/null @@ -1,367 +0,0 @@ -{% load i18n %} -{% block content %} -
-
-
- {% trans "Title" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.title }}
-
-
-
- {% trans "Number" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.number }}
-
-
-
- {% trans "Course ID" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.lms_course_id as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Subtitle" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.short_description as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Organization" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.organization_key }}
-
- -
-
- {% trans "MicroMasters" %} -
-
- {% with course_run.micromasters_name as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "XSeries" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% if course_run.is_xseries %} - {{ course_run.xseries_name }} - {% else %} - {% trans "(Optional) Not yet added" %} - {% endif %} -
-
- -
-
- {% trans "Professional Certificate Name" %} -
-
- {% with course_run.professional_certificate_name as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Start Date (time in UTC)" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.start_date_temporary|date:"Y-m-d" }}
-
-
-
{% trans "End Date (time in UTC)" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.end_date_temporary|date:"Y-m-d" }}
-
-
-
- {% trans "Self Paced" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.pacing_type_temporary }}
-
-
-
- {% trans "Staff" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% if not course_run.course_staff %} - {% trans "(Required) Not yet added" %} - {% else %} - {% for obj in course_run.course_staff %} -
- - -
- -
{{ obj.position }}
-
{{ obj.organization }}
-
-
-
- {% endfor %} - {% endif %} -
-
-
-
- {% trans "Estimated Effort" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% if course_run.min_effort and course_run.max_effort %} - {{ course_run.min_effort }} {% trans "to" %} {{ course_run.max_effort }} {% trans "hours per week" %} - {% elif course_run.min_effort %} - {{ course_run.min_effort }} {% trans "hours per week" %} - {% else %} - {% with course_run.estimated_effort as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} - {% endif %} -
-
-
-
- {% trans "Languages" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.language.name as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Video Transcript Languages" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.transcript_languages as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Level" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.level_type as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Full Description" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.full_description as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "What You'll Learn" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.expected_learnings as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Course Image" %} -
-
- {% if course_run.course.image %} - {% trans 'Course Image' %} - - {% else %} - {% trans "(Required) Not yet added" %} - {% endif %} -
-
-
-
- {% trans "Prerequisites" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.course.prerequisites as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Keywords" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.keywords as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Sponsors" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% if course_run.wrapped_obj.sponsor.all %} - {% for sponsor in course_run.wrapped_obj.sponsor.all %} - {{ sponsor.name }}
- {% endfor %} - {% else %} - {% trans "(Optional) Not yet added" %} - {% endif %} -
-
- -
-
- {% trans "Course Syllabus" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.syllabus as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Primary Subject" %} -
-
- {% with course_run.subjects.0 as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Additional Subject" %} -
-
- {% with course_run.subjects.1 as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Additional Subject" %} -
-
- {% with course_run.subjects.2 as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Course Length (Weeks)" %} -
-
- {% with course_run.length as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -
-
- {% trans "Learner Testimonials" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.course.learner_testimonial as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "FAQ" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.course.faq as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Course About Video" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
- {% with course_run.course.video_link as value %} - {% if value %} - {{ value }} - {% else %} - {% trans "(Optional) Not yet added" %} - {% endif %} - {% endwith %} -
- -

{% trans "Enrollment Types" %}

-
-
- {% trans "Seats" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% if course_run.wrapped_obj.seats.all %} - {% for seat in course_run.wrapped_obj.seats.all %} - {{ seat.type }} - {% endfor %} - {% else %} - {% trans "(Required) Not yet added" %} - {% endif %} -
-
-
-
-{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_edit_warning.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_edit_warning.html deleted file mode 100644 index dc56910302..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_edit_warning.html +++ /dev/null @@ -1,20 +0,0 @@ -{% load i18n %} - - diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_instructor_profile.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_instructor_profile.html deleted file mode 100644 index 3de34f001f..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_instructor_profile.html +++ /dev/null @@ -1,82 +0,0 @@ -{% load compress %} -{% load i18n %} -{% load static %} - - -{% block extra_js %} - - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_preview_accept_popup.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_preview_accept_popup.html deleted file mode 100644 index bd96ce561e..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_preview_accept_popup.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load i18n %} - - diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_salesforce.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_salesforce.html deleted file mode 100644 index 83e98890a3..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_salesforce.html +++ /dev/null @@ -1,249 +0,0 @@ -{% load i18n %} -{% block content %} -
-

{% trans "Course" %}

-
-
-
- {% trans "Title" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.title }}
-
-
-
- {% trans "Number" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.number }}
-
-
-
- {% trans "Account" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.organization_name }}
-
-
-
- {% trans "Authored in Studio?" %} -
-
{{ course_run.is_authored_in_studio }}
-
-
-
- {% trans "Multiple Partner Course?" %} -
-
{{ course_run.is_multiple_partner_course }}
-
-
-
- {% trans "Course Level" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.level_type as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Subject Area" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.course.primary_subject.name as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Language: Course Text" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.language.name as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Language: Transcripts" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.transcript_languages as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Language: Video" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.video_language as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
- -

{% trans "Course Run" %}

-
- -
-
- {% trans "New or Rerun?" %} -
-
{{ course_run.is_re_run }}
-
-
-
- {% trans "Platform" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ platform_name }}
-
-
-
- {% trans "Faculty" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% if not course_run.course_staff %} - {% trans "(Required) Not yet added" %} - {% else %} - {% for obj in course_run.course_staff %} -
- - -
-
- {% if obj.profile_url %} - {{ obj.full_name }} - {% else %} - {{ obj.full_name }} - {% endif %} -
-
{{ obj.position }}
-
{{ obj.organization }}
-
-
-
- {% endfor %} - {% endif %} -
-
-
-
- {% trans "Course Run Display Name" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.title }}
-
-
-
- {% trans "Course Run Number" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.number }}
-
-
-
- {% trans "edX Course Run ID" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.lms_course_id as field %} - {% include "publisher/_render_required_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Certificate Type" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.course_type }}
-
-
-
- {% trans "Course $ (minimum)" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.seat_price as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Credit $ (minimum)" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.credit_seat_price as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Self Paced?" %} -
-
{{ course_run.is_self_paced }}
-
-
-
- {% trans "MDC Submission Due Date" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.mdc_submission_due_date as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Start Date" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.start_date_temporary }}
-
-
-
- {% trans "End Date" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.end_date_temporary }}
-
-
-
- {% trans "Verified Registration Expiration Date" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.verified_seat_expiry as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-
- {% trans "Certificate Issued Date" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
- {% with course_run.certificate_generation as field %} - {% include "publisher/_render_optional_field.html" %} - {% endwith %} -
-
-
-{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_seats.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_seats.html deleted file mode 100644 index ede4938afe..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_seats.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load i18n %} -

Seats

-{% if course_run.non_credit_seats %} -
- - - - - - - - {% for seat in course_run.non_credit_seats %} - - - - - - - {% endfor %} -
{% trans "Enrollment Type" %}{% trans "Price" %}{% trans "Currency" %}{% trans "Upgrade Deadline (time in UTC)" %}
{{ seat.type}}{{ seat.price}}{{ seat.currency}}{{ seat.upgrade_deadline }}
-
-{% else %} - {% trans "(Required) Not yet added" %} -{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_studio.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_studio.html deleted file mode 100644 index c7ce927a3d..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_studio.html +++ /dev/null @@ -1,50 +0,0 @@ -{% load i18n %} -{% block content %} - -
-
-
- {% trans "Course Name" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.title }}
-
-
-
- {% trans "Organization" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.organization_name }}
-
-
-
- {% trans "Number" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.number }}
-
- -
-
- {% trans "Start Date (time in UTC)" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.start_date_temporary|date:"M d, Y, H:i:s A" }}
-
-
-
- {% trans "End Date (time in UTC)" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.end_date_temporary|date:"M d, Y, H:i:s A" }}
-
-
-
- {% trans "Pacing Type" %} - {% include "publisher/course_run_detail/_clipboard.html" %} -
-
{{ course_run.pacing_type_temporary }}
-
-
-
-{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_widgets.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/_widgets.html deleted file mode 100644 index 0445fc1798..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/_widgets.html +++ /dev/null @@ -1,25 +0,0 @@ -{% load i18n %} - -
- {% if can_edit %} - {% url 'publisher:publisher_course_runs_edit' pk=course_run.id as edit_page_url %} - - {% trans "EDIT" %} - -
- {% endif %} -
- {% include 'publisher/_approval_widget.html' %} - - - {% include 'publisher/course_run_detail/_preview_accept_popup.html' %} - - {% if add_warning_popup %} - {% include 'publisher/course_run_detail/_edit_warning.html' %} - {% endif %} -
-
diff --git a/course_discovery/apps/publisher/templates/publisher/course_run_detail/course_run_detail.html b/course_discovery/apps/publisher/templates/publisher/course_run_detail/course_run_detail.html deleted file mode 100644 index cfcfe5df57..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/course_run_detail/course_run_detail.html +++ /dev/null @@ -1,166 +0,0 @@ -{% extends 'publisher/base.html' %} - -{% load compress %} -{% load i18n %} -{% load static %} - -{% block title %} - {{ course_run.course.number }} - {{ course_run.start_date_temporary|date:'M Y' }} -{% endblock title %} - -{% block page_content %} -
-
- {% if course_run.course_run_state.is_published %} -
- - - {% blocktrans with publish_date|date:'m/d/y' as course_publish_date trimmed %} - The About page for this course run was published on {{ course_publish_date }}. - {% endblocktrans %} - {% if course_run.preview_url %} - {% trans "View it on edx.org at" %} - {% endif %} - - {% if course_run.preview_url %} - {{ course_run.preview_url }} - {% endif %} -
- {% endif %} - - -
- - - {% if request.user.is_staff and course_run.lms_course_id %} - - {% endif %} - - - - {% include 'alert_messages.html' %} - - - -
-
- {% include 'publisher/course_run_detail/_all.html' %} -
- {% if can_view_all_tabs %} -
- {% include 'publisher/course_run_detail/_studio.html' %} -
-
- {% include 'publisher/course_run_detail/_cat.html' %} -
-
- {% include 'publisher/course_run_detail/_drupal.html' %} -
-
- {% include 'publisher/course_run_detail/_salesforce.html' %} -
- {% endif %} -
-
-
- - -
-{% include 'publisher/course_run_detail/_instructor_profile.html' %} -{% endblock %} - -{% block extra_js %} - - - - - - - - - - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/courserun_list.html b/course_discovery/apps/publisher/templates/publisher/courserun_list.html deleted file mode 100644 index fa0d646966..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/courserun_list.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends 'publisher/base.html' %} -{% load i18n %} -{% load static %} - -{% block title %} -{% trans "Dashboard" %} -{% endblock title %} - -{% block page_content %} -
-

{% trans "Course About Pages" %}

-
-

{% trans "EdX Publisher is used to create course About pages. Users enter, review, and approve content in Publisher. Publisher keeps track of the details and sends email updates when actions are necessary." %}

-

{% trans "EdX Publisher is a companion to edX Studio. Course teams enter About page information in Publisher, and course content in Studio." %}

-
- - -
-
- {% with studio_count=studio_request_courses|length published_count=published_course_runs|length preview_count=preview_course_runs|length in_progress_count=in_progress_course_runs|length %} -

{% trans "Course runs" %}

-
    - - - {% if is_project_coordinator %} - - {% endif %} - - -
- -
- {% include "publisher/dashboard/_in_progress.html" %} -
- - - - {% if is_project_coordinator %} -
- {% include "publisher/dashboard/_studio_requests.html" %} -
- {% endif %} - -
- {% include "publisher/dashboard/_published.html" %} -
-
- - {% endwith %} -{% endblock %} - -{% block extra_js %} - - -{% endblock %} diff --git a/course_discovery/apps/publisher/templates/publisher/dashboard/_in_preview.html b/course_discovery/apps/publisher/templates/publisher/dashboard/_in_preview.html deleted file mode 100644 index a98491840a..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/dashboard/_in_preview.html +++ /dev/null @@ -1,57 +0,0 @@ -{% load i18n %} -{% if preview_count == 0 %} -
{% trans "No course runs currently have an About page preview available for course team review." %}
-{% else %} -

- {% trans "About page previews for the following course runs are available for course team review. After the course team approves the preview, the edX publisher will publish the About page for the course run on edx.org. The course team will receive an email message when the About page has been published." %} -

-
- - - - - - - - - - - {% for course_run in preview_course_runs %} - - - - - - - {% endfor %} - -
- {% trans "Course Name" %} - - {% trans "Organization" %} - - {% trans "Status" %} - - {% trans "Modified" %} -
- {{ course_run.title }} -
- {{ course_run.preview_url }} -
- {{ course_run.course.organization_name }} - - {% if course_run.course_run_state.preview_accepted %} - {% trans "Approved since " %} - {% elif course_run.preview_declined and course_run.owner_role_is_publisher %} - {% trans "Declined since " %} - {% elif not course_run.preview_url %} - {% trans "Preview Requested since " %} - {% else %} - {% trans "In Review since " %} - {% endif %} - {{ course_run.owner_role_modified }} - - {{ course_run.modified }} -
-
-{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/dashboard/_in_progress.html b/course_discovery/apps/publisher/templates/publisher/dashboard/_in_progress.html deleted file mode 100644 index 108087c7fd..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/dashboard/_in_progress.html +++ /dev/null @@ -1,61 +0,0 @@ -{% load i18n %} -{% if in_progress_count == 0 %} -
{% trans "No About pages for any course runs are currently in development." %}
-{% else %} - -
- {% trans "Filter by" %}: - - - -
- - - - - - - - - - - - - {% for course_run in in_progress_course_runs %} - - - - - - - - - - {% endfor %} - -
- {% trans "Course Name" %} - - {% trans "Course Number" %} - - {% trans "Organization" %} - - {% trans "Modified" %} - - {% trans "Course Team" %} - - {{ site_name }} - - {% trans "Last Handoff" %} -
- - {{ course_run.title }} - - {{ course_run.number }}{{ course_run.course.organization_name }}{{ course_run.modified|date:"Y-m-d" }}{{ course_run.course_team_status }}{{ course_run.internal_user_status }}{{ course_run.owner_role_last_modified }}
-{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/dashboard/_published.html b/course_discovery/apps/publisher/templates/publisher/dashboard/_published.html deleted file mode 100644 index e0c8740081..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/dashboard/_published.html +++ /dev/null @@ -1,46 +0,0 @@ -{% load i18n %} -{% if published_count == 0 %} -
{% trans "No About pages have been published yet." %}
-{% else %} -

- {% blocktrans trimmed %} - About pages for the following course runs have been published in the past {{ default_published_days }} days. - {% endblocktrans %} -

- - - - - - - - - - - {% for course_run in published_course_runs %} - - - - - - - {% endfor %} - -
- {% trans "Course Name" %} - - {% trans "Course Number" %} - - {% trans "Organization" %} - - {% trans "Published Date" %} -
- {{ course_run.title }} - - {{ course_run.number }} - - {{ course_run.course.organization_name }} - - {{ course_run.course_run_state.modified|date:"Y-m-d" }} -
-{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/dashboard/_studio_requests.html b/course_discovery/apps/publisher/templates/publisher/dashboard/_studio_requests.html deleted file mode 100644 index b4257ab83b..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/dashboard/_studio_requests.html +++ /dev/null @@ -1,62 +0,0 @@ -{% load i18n %} - -{% if studio_count > 0 %} -

{% trans "The following courses are ready for a Studio URL." %}

-
-

-
- -
- - - - - - - - - - - - {% for course_run in studio_request_courses %} - - {% url 'publisher:publisher_course_run_detail' course_run.id as run_page_url %} - - - - - - - {% endfor %} - -
- {% trans "Course Name" %} - - {% trans "Organization" %} - - {% trans "Modified" %} - - {% trans "Course Number" %} - - {% trans "Studio Course Run Key" %} -
- {{ course_run.title }} - - {{ course_run.course.organization_name }} - - {{ course_run.modified|date:"Y-m-d" }} - - {{ course_run.number }} - - - -
-
- -{% else %} -
{% trans "No courses are currently ready for a Studio URL." %}
-{% endif %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course/mark_as_reviewed.html b/course_discovery/apps/publisher/templates/publisher/email/course/mark_as_reviewed.html deleted file mode 100644 index 2945280323..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course/mark_as_reviewed.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

-

- {% blocktrans with link_start='' link_end='' trimmed %} - The {{ sender_team }} has reviewed {{ link_start }}{{ page_url }}{{ link_middle }} {{ course_name }}{{ link_end }} and has suggested no changes. The review for this course is complete. - {% endblocktrans %} -

- -

- {% blocktrans with link_start='' link_end='' trimmed %} - You can now submit a course run for review. To do this, {{ link_start }}{{ page_url }}{{ link_middle }} go to the course page{{ link_end }} and open the page for any course run. On the course run page, add all required information for that course run, and then submit the course run for review. - {% endblocktrans %} -

- {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ sender_name }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course/mark_as_reviewed.txt b/course_discovery/apps/publisher/templates/publisher/email/course/mark_as_reviewed.txt deleted file mode 100644 index 2eea43de17..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course/mark_as_reviewed.txt +++ /dev/null @@ -1,17 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - The {{ sender_team }} has reviewed {{ link_start }}{{ page_url }}{{ link_middle }} {{ course_name }}{{ link_end }} and has suggested no changes. The review for this course is complete. -{% endblocktrans %} -{% blocktrans trimmed %} - You can now submit a course run for review. To do this, {{ link_start }}{{ page_url }}{{ link_middle }} go to the course page{{ link_end }} and open the page for any course run. On the course run page, add all required information for that course run, and then submit the course run for review. -{% endblocktrans %} -{% trans "Thanks," %} -{{ sender_name }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course/send_for_review.html b/course_discovery/apps/publisher/templates/publisher/email/course/send_for_review.html deleted file mode 100644 index 78c43f722a..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course/send_for_review.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ sender_team }} from {{ org_name }} has submitted {{ course_name }} for review. {{ link_start }}{{ page_url }}{{ link_middle }} View this course in Publisher{{ link_end }} to mark the course as reviewed or suggest edits. - {% endblocktrans %} -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ sender_name }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course/send_for_review.txt b/course_discovery/apps/publisher/templates/publisher/email/course/send_for_review.txt deleted file mode 100644 index 2fb9a15add..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course/send_for_review.txt +++ /dev/null @@ -1,15 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - {{ sender_team }} from {{ org_name }} has submitted {{ course_name }} for review. {{ page_url }} View this course in Publisher to mark the course as reviewed or suggest edits. -{% endblocktrans %} - -{% trans "Thanks," %} -{% trans "The edX team" %} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course/seo_review.html b/course_discovery/apps/publisher/templates/publisher/email/course/seo_review.html deleted file mode 100644 index b76c60ad41..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course/seo_review.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ sender_team }} from {{ org_name }} has submitted {{ course_name }} for review. {{ link_start }}{{ course_page_url }}{{ link_middle }}View this course in Publisher{{ link_end }} to determine OFAC status. - {% endblocktrans %} -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ sender_team }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course/seo_review.txt b/course_discovery/apps/publisher/templates/publisher/email/course/seo_review.txt deleted file mode 100644 index 05aa5dde4e..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course/seo_review.txt +++ /dev/null @@ -1,15 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - {{ sender_team }} from {{ org_name }} has submitted {{ course_name }} for review. {{ course_page_url }} View this course in Publisher to determine OFAC status. -{% endblocktrans %} - -{% trans "Thanks," %} -{{ sender_team }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_created.html b/course_discovery/apps/publisher/templates/publisher/email/course_created.html deleted file mode 100644 index 490409f798..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_created.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - -

- {# Translators: project_coordinator_name is a member name. #} - {% blocktrans trimmed %} - Dear {{ project_coordinator_name }}, - {% endblocktrans %} -

-

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ course_team_name }} created the {{ link_start }}{{ page_url }}{{ link_middle }}{{ run_number }} course run{{ link_end }} of {{ course_title }} in Publisher on {{ date }} at {{ time }}. - {% endblocktrans %} -

- - {# Translators: It's closing of mail. #} -

{% trans "Thanks," %}

-

{% trans "The edX team" %}

- -{% endblock body %} - - diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_created.txt b/course_discovery/apps/publisher/templates/publisher/email/course_created.txt deleted file mode 100644 index af9cc6ae1a..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_created.txt +++ /dev/null @@ -1,11 +0,0 @@ -{% load i18n %} - -{% trans "Dear" %} {{ project_coordinator_name }}, - -{% blocktrans trimmed %} - {{ course_team_name }} created the {{ course_run }} : {{ dashboard_url }} course run of {{ course_title }} in Publisher on {{ date }} at {{ time }}. -{% endblocktrans %} - - -{% trans "Thanks," %} -{% trans "The edX team" %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed.html deleted file mode 100644 index f03c46716e..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ sender_team }} has reviewed the {{ link_start }}{{ page_url }}{{ link_middle }}{{ run_number }} course run{{ link_end }} of {{ course_name }} and has not added comments or suggested edits. The review for this course run is complete. - {% endblocktrans %} -

-

- {% trans "Please create a preview of the About page for this course run and enter the preview URL in Publisher." %} -

-

- - {% trans "Additionally, please check the comments in Publisher for information about OFAC blocking." %} - -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ sender_name }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed.txt b/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed.txt deleted file mode 100644 index 068a754980..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed.txt +++ /dev/null @@ -1,17 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - The {{ sender_team }} has reviewed the {{ run_number }} {{ page_url }} course run of {{ course_name }} and has not added comments or suggested edits. The review for this course run is complete. -{% endblocktrans %} -{% trans "Please create a preview of the About page for this course run and enter the preview URL in Publisher." %} -{% trans "Additionally, please check the comments in Publisher for information about OFAC blocking." %} - -{% trans "Thanks," %} -{{ sender_name }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed_pc.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed_pc.html deleted file mode 100644 index bee3dde4d0..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed_pc.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ sender_team }} has reviewed the {{ link_start }}{{ page_url }}{{ link_middle }}{{ run_number }} course run{{ link_end }} of {{ course_name }} and has not added comments or suggested edits. The review for this course run is complete. - {% endblocktrans %} -

-

- - {% trans "Additionally, please check the comments in Publisher for information about OFAC blocking." %} - -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ sender_name }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed_pc.txt b/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed_pc.txt deleted file mode 100644 index ccaac91584..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/mark_as_reviewed_pc.txt +++ /dev/null @@ -1,16 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - The {{ sender_team }} has reviewed the {{ run_number }} {{ page_url }} course run of {{ course_name }} and has not added comments or suggested edits. The review for this course run is complete. -{% endblocktrans %} -{% trans "Additionally, please check the comments in Publisher for information about OFAC blocking." %} - -{% trans "Thanks," %} -{{ sender_name }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_accepted.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_accepted.html deleted file mode 100644 index d739e77a84..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_accepted.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - -

- {% blocktrans trimmed %} - Dear {{ publisher_role_name }}, - {% endblocktrans %} -

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ course_team }} has reviewed the preview of the About page for the {{ link_start }}{{ page_url }}{{ link_middle }}{{ run_number }}{{ link_end }} course run of {{ course_name }} from {{ org_name }}. You can now publish this About page. - {% endblocktrans %} -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ course_team }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_accepted.txt b/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_accepted.txt deleted file mode 100644 index 94ad30a302..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_accepted.txt +++ /dev/null @@ -1,15 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ publisher_role_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - {{ course_team }} has reviewed the preview of the About page for the {{ run_number }} {{ page_url }} course run of {{ course_name }} from {{ org_name }}. You can now publish this About page. -{% endblocktrans %} - -{% trans "Thanks," %} -{{ course_team }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_available.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_available.html deleted file mode 100644 index a0fdb7180d..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_available.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

-

- {% blocktrans trimmed with run=course_run_key.run title=course_run.course.title %} - A preview is now available for the {{ run }} run of {{ title }}. - {% endblocktrans %} -

-

- {% trans "Follow these steps to move forward with publishing the course run." %} -

-

- - {% blocktrans trimmed %} - Please do not share the preview URL publicly or use it in your own advertising. The finalized - public URL will be available after the page is fully published. - {% endblocktrans %} - -

-
    -
  1. {% trans "Preview the about page" %}
  2. -
  3. {% trans "Submit feedback in Publisher" %}
  4. -
- - {% comment %}Translators: This is part of an email signature.{% endcomment %} - {% trans "Thanks," %}
- {{ platform_name }} {{ sender_role }} - -

- {% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact - {{ contact_us_email }}. - {% endblocktrans %} -

-{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_available.txt b/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_available.txt deleted file mode 100644 index a46c603647..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/preview_available.txt +++ /dev/null @@ -1,27 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} - -{% blocktrans trimmed with run=course_run_key.run title=course_run.course.title %} - A preview is now available for the {{ run }} run of {{ title }}. -{% endblocktrans %} - -{% trans "Follow these steps to move forward with publishing the course run." %} - -{% blocktrans trimmed %} - Please do not share the preview URL publicly or use it in your own advertising. The finalized - public URL will be available after the page is fully published. -{% endblocktrans %} - -1. {% trans "Preview the about page" %} ({{ course_run.preview_url }}) -2. {% trans "Submit feedback in Publisher" %} ({{ course_run_publisher_url }}) - -{% comment %}Translators: This is part of an email signature.{% endcomment %} -{% trans "Thanks," %} -{{ platform_name }} {{ sender_role }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/published.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/published.html deleted file mode 100644 index 42e1ce9c73..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/published.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - -

- {% blocktrans trimmed %} - The About page for the {{ course_run_number }} course run of {{ course_name }} has been published. No further action is necessary. - {% endblocktrans %} -

-

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ link_start }}{{ preview_url }}{{ link_middle }}View this About page on edx.org.{{ link_end }} - {% endblocktrans %} -

- {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ platform_name}} {{ sender_role }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/published_course_run_editing.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/published_course_run_editing.html deleted file mode 100644 index c516f46e5b..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/published_course_run_editing.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

-

- {% blocktrans trimmed %} - {{ course_team }} has made changes to the following published course run. - {% endblocktrans %} -

-

- {{ course_name }} {{ course_run_number }} {% trans "View the course run in Publisher" %} {% trans "to make changes and republish the course run." %} -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ course_team }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/published_course_run_editing.txt b/course_discovery/apps/publisher/templates/publisher/email/course_run/published_course_run_editing.txt deleted file mode 100644 index 97f0abf914..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/published_course_run_editing.txt +++ /dev/null @@ -1,19 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} - -{% blocktrans trimmed %} - {{ course_team }} has made changes to the following published course run. -{% endblocktrans %} - -{{ course_name }} {{ course_run_number }} {% trans "View the course run in Publisher" %} {% trans "to make changes and republish the course run." %} - -{% comment %}Translators: It's closing of mail.{% endcomment %} -{% trans "Thanks," %}
-{{ course_team }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/send_for_review.html b/course_discovery/apps/publisher/templates/publisher/email/course_run/send_for_review.html deleted file mode 100644 index 77da1f153d..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/send_for_review.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - - -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

- {% blocktrans with link_start='' link_end='' trimmed %} - {{ sender_team }} for course_name from {{ org_name }} has submitted the {{ run_number }} course run for review. {{ link_start }}{{ page_url }}{{ link_middle }} View this course run in Publisher{{ link_end }} to review the changes or suggest edits. - {% endblocktrans %} -

- -

- {% blocktrans with link_start='' link_end='' trimmed %} - This is a good time to {{ link_start }}{{ studio_url }}{{ link_middle }} review this course run in Studio{{ link_end }}. - {% endblocktrans %} -

- {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thank you," %}
- {{ sender_name }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/course_run/send_for_review.txt b/course_discovery/apps/publisher/templates/publisher/email/course_run/send_for_review.txt deleted file mode 100644 index 370c999d9b..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/course_run/send_for_review.txt +++ /dev/null @@ -1,18 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - {{ sender_team }} for {{ course_name }} from {{ org_name }} has submitted the {{ run_number }} course run for review. {{ page_url }} View this course run in Publisher to review the changes or suggest edits. -{% endblocktrans %} -{% blocktrans trimmed %} - This is a good time to {{ studio_url }} review this course run in Studio. -{% endblocktrans %} - -{% trans "Thank you," %} -{{ sender_name }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/decline_preview.html b/course_discovery/apps/publisher/templates/publisher/email/decline_preview.html deleted file mode 100644 index e3fb6694b0..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/decline_preview.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} -

- {% blocktrans trimmed %} - Dear {{ recipient_name }}, - {% endblocktrans %} -

- -

- {% blocktrans trimmed with link_start='' link_end='' %} - {{ team_name }} has declined the preview of the About page for the {{ run_number }} course run of {{course_name}}. {{ link_start }}{{ page_url }}{{ link_middle }}View the course run in Publisher.{{ link_end }} - {% endblocktrans %} -

- -

- {% blocktrans trimmed %} - {{ team_name }} added the following comment. - {% endblocktrans %} -

-

- "{{ comment.comment }}" -

- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {% trans "The edX team" %} - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/decline_preview.txt b/course_discovery/apps/publisher/templates/publisher/email/decline_preview.txt deleted file mode 100644 index 618253a732..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/decline_preview.txt +++ /dev/null @@ -1,16 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ recipient_name }}, -{% endblocktrans %} - -{% blocktrans trimmed %} - {{ team_name }} has declined the preview of the About page for the {{ run_number }} course run of {{ course_name }}. View the course run in Publisher. -{% endblocktrans %} - -{% trans "{{ team_name }} added the following comment." %}{{ page_url }} - -{{ comment.comment }} - -{% trans "Thanks," %} -{% trans "The edX team" %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/role_assignment_changed.html b/course_discovery/apps/publisher/templates/publisher/email/role_assignment_changed.html deleted file mode 100644 index 55818e8281..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/role_assignment_changed.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} - -{% block body %} -

- {% blocktrans trimmed with link_start='' link_end='' %} - The {{ role_name }} for {{ link_start }}{{ course_url }}{{ link_middle }}{{ course_title }}{{ link_end }} has changed. - {% endblocktrans %} -

- -

- {% blocktrans trimmed %} - Former {{ role_name }}: {{ former_user_name }} - {% endblocktrans %} -
- {% blocktrans trimmed %} - Current {{ role_name }}: {{ current_user_name }} - {% endblocktrans %} -

-{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/role_assignment_changed.txt b/course_discovery/apps/publisher/templates/publisher/email/role_assignment_changed.txt deleted file mode 100644 index cc72cfc61f..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/role_assignment_changed.txt +++ /dev/null @@ -1,13 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - The {{ role_name }} for {{ course_title }}: {{ course_url }} has changed. -{% endblocktrans %} - -{% blocktrans trimmed %} - Former {{ role_name }}: {{ former_user_name}} -{% endblocktrans %} - -{% blocktrans trimmed %} - Current {{ role_name }}: {{ current_user_name}} -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/studio_instance_created.html b/course_discovery/apps/publisher/templates/publisher/email/studio_instance_created.html deleted file mode 100644 index a5abe4f47f..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/studio_instance_created.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends "publisher/email/email_base.html" %} -{% load i18n %} -{% block body %} - - -

- {% comment %}Translators: course_team_name is course team member name.{% endcomment %} - {% blocktrans trimmed %} - Dear {{ course_team_name }}, - {% endblocktrans %} -

- {% blocktrans trimmed %} - {{ project_coordinator_name }} has created a Studio URL for the {{ course_run }}. You can now take either of the following actions. - {% endblocktrans %} -

- -

- - {% trans "Important Notes" %} - -

-
    -
  • - {% trans "Before edX can publish the About page for this course run, you must also add the following items for this course run in Studio." %} -
  • -
      -
    • {% trans "Course start date" %}
    • -
    • {% trans "Course end date" %}
    • -
    • {% trans "Course image" %}
    • -
    • {% trans "Certificate assets (including signatory names, titles, and signature images)" %}
    • -
    -
  • - {% blocktrans with link_start='' link_end='' trimmed %} - EdX expects your completed {{ link_start }}MOOC Development Checklist{{ link_end }} before the course run starts. - {% endblocktrans %} -
  • -
  • - {% blocktrans with link_start='' link_end='' trimmed %} - If this is the first run of a course, you should upload the course About video before you submit the course for review in Publisher. For more information, see {{ link_start }}Add a Course About Video to edx.org{{ link_end }}. - {% endblocktrans %} -
  • -
- - {% comment %}Translators: It's closing of mail.{% endcomment %} - {% trans "Thanks," %}
- {{ project_coordinator_name }} - - -{% blocktrans trimmed %} -

Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}.

-{% endblocktrans %} - - -{% endblock body %} diff --git a/course_discovery/apps/publisher/templates/publisher/email/studio_instance_created.txt b/course_discovery/apps/publisher/templates/publisher/email/studio_instance_created.txt deleted file mode 100644 index 184f359cfd..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email/studio_instance_created.txt +++ /dev/null @@ -1,34 +0,0 @@ -{% load i18n %} - -{% blocktrans trimmed %} - Dear {{ course_team_name }}, -{% endblocktrans %} -{% blocktrans trimmed %} - {{ project_coordinator_name }} has created a Studio URL for the {{ course_run}} course run of {{ course_name }}. You can now take either of the following actions. -{% endblocktrans %} - -{% trans "Enter course run content in Studio." %} {{ studio_url }} - -{% trans "Continue adding About page information for this course run in Publisher." %} {{ course_run_page_url }} - -{% trans "Important Notes" %} - -{% trans "Before edX can publish the About page for this course run, you must also add the following items for this course run in Studio." %} - -{% trans "Course start date" %} -{% trans "Course end date" %} -{% trans "Course image" %} -{% trans "Certificate assets (including signatory names, titles, and signature images)" %} - -{% trans "EdX expects your completed MOOC Development Checklist before the course run starts." %} https://docs.google.com/spreadsheets/d/1phjmDNoARq4YjJoiFZc0ELumj3r04r36LEncCjN9PVk/ - -{% trans "If this is the first run of a course, you should upload the course About video before you submit the course for review in Publisher. For more information, see Add a Course About Video to edx.org." %} -http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/set_up_course/setting_up_student_view.html#add-a-course-about-video-to-edx-org - - -{% trans "Thanks," %} -{{ project_coordinator_name }} - -{% blocktrans trimmed %} - Note: This email address is unable to receive replies. For questions or comments, contact {{ contact_us_email }}. -{% endblocktrans %} diff --git a/course_discovery/apps/publisher/templates/publisher/email_preview.html b/course_discovery/apps/publisher/templates/publisher/email_preview.html deleted file mode 100644 index 831b992766..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/email_preview.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - Email Preview - - - -

HTML

-{{ html_content|safe }} - -

Text

-
-{{ text_content }}
-
- - - diff --git a/course_discovery/apps/publisher/templates/publisher/form_field.html b/course_discovery/apps/publisher/templates/publisher/form_field.html deleted file mode 100644 index f5d68bdaf7..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/form_field.html +++ /dev/null @@ -1,11 +0,0 @@ -
- - {{ field }} - {% for error in field.errors %} -
- - {{ error|escape }} - -
- {% endfor %} -
diff --git a/course_discovery/apps/publisher/templates/publisher/seat_form.html b/course_discovery/apps/publisher/templates/publisher/seat_form.html deleted file mode 100644 index 94fb4186aa..0000000000 --- a/course_discovery/apps/publisher/templates/publisher/seat_form.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends 'publisher/base.html' %} -{% load i18n %} -{% load static %} -{% block title %} - {% trans "Seat Form" %} -{% endblock title %} - -{% block page_content %} -
-
-
-

{% trans "Seat Form" %}

-
- {% csrf_token %} -
- {% for field in form %} - {% include "publisher/form_field.html" %} - {% endfor %} -
- -
-
-
- {% include 'publisher/comments/comments_list.html' %} - {% include 'publisher/comments/add_auth_comments.html' %} -
-
-
- -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/course_discovery/apps/publisher/tests/factories.py b/course_discovery/apps/publisher/tests/factories.py index 2d5bbf3d21..06e4624b0e 100644 --- a/course_discovery/apps/publisher/tests/factories.py +++ b/course_discovery/apps/publisher/tests/factories.py @@ -1,160 +1,39 @@ -from datetime import datetime - import factory from django.contrib.auth.models import Group -# pylint:disable=ungrouped-imports -from factory.fuzzy import FuzzyChoice, FuzzyDateTime, FuzzyDecimal, FuzzyInteger, FuzzyText -from pytz import UTC +from factory.fuzzy import FuzzyChoice, FuzzyText -from course_discovery.apps.core.models import Currency -from course_discovery.apps.core.tests.factories import UserFactory, add_m2m_data -from course_discovery.apps.course_metadata.choices import CourseRunPacing +from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.course_metadata.tests import factories -from course_discovery.apps.ietf_language_tags.models import LanguageTag -from course_discovery.apps.publisher.choices import PublisherUserRole -from course_discovery.apps.publisher.models import ( - Course, CourseEntitlement, CourseRun, CourseRunState, CourseState, CourseUserRole, DrupalLoaderConfig, - OrganizationExtension, OrganizationUserRole, Seat, UserAttributes -) - - -class CourseFactory(factory.DjangoModelFactory): - title = FuzzyText() - short_description = FuzzyText() - full_description = FuzzyText() - number = FuzzyText() - prerequisites = FuzzyText() - expected_learnings = FuzzyText() - syllabus = FuzzyText() - learner_testimonial = FuzzyText() - level_type = factory.SubFactory(factories.LevelTypeFactory) - image = factory.django.ImageField() - version = Course.SEAT_VERSION - - primary_subject = factory.SubFactory(factories.SubjectFactory) - secondary_subject = factory.SubFactory(factories.SubjectFactory) - tertiary_subject = factory.SubFactory(factories.SubjectFactory) - faq = FuzzyText() - video_link = factory.Faker('url') - - @factory.post_generation - def organizations(self, create, extracted, **kwargs): # pylint: disable=unused-argument - if create: - add_m2m_data(self.organizations, extracted) - - class Meta: - model = Course - - -class CourseRunFactory(factory.DjangoModelFactory): - course = factory.SubFactory(CourseFactory) - start = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC)) - end = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC)).end_dt - certificate_generation = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC)) - min_effort = FuzzyInteger(1, 10) - max_effort = FuzzyInteger(10, 20) - language = factory.Iterator(LanguageTag.objects.all()) - pacing_type = FuzzyChoice(CourseRunPacing.values.keys()) - length = FuzzyInteger(1, 10) - notes = FuzzyText() - contacted_partner_manager = FuzzyChoice((True, False)) - video_language = factory.Iterator(LanguageTag.objects.all()) - short_description_override = FuzzyText() - title_override = FuzzyText() - full_description_override = FuzzyText() - - @factory.post_generation - def staff(self, create, extracted, **kwargs): # pylint: disable=unused-argument - if create: - add_m2m_data(self.staff, extracted) +from course_discovery.apps.publisher.choices import InternalUserRole +from course_discovery.apps.publisher.models import OrganizationExtension, OrganizationUserRole, UserAttributes - @factory.post_generation - def transcript_languages(self, create, extracted, **kwargs): # pylint: disable=unused-argument - if create: - add_m2m_data(self.transcript_languages, extracted) - class Meta: - model = CourseRun - - -class SeatFactory(factory.DjangoModelFactory): - type = FuzzyChoice([name for name, __ in Seat.SEAT_TYPE_CHOICES]) - price = FuzzyDecimal(0.0, 650.0) - currency = factory.Iterator(Currency.objects.all()) - upgrade_deadline = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC)) - course_run = factory.SubFactory(CourseRunFactory) - credit_price = FuzzyDecimal(0.0, 650.0) - - class Meta: - model = Seat - - -class CourseEntitlementFactory(factory.DjangoModelFactory): - mode = FuzzyChoice([name for name, __ in CourseEntitlement.COURSE_MODE_CHOICES]) - price = FuzzyDecimal(1.0, 650.0) - currency = factory.Iterator(Currency.objects.all()) - course = factory.SubFactory(CourseFactory) - - class Meta: - model = CourseEntitlement - - -class GroupFactory(factory.DjangoModelFactory): +class GroupFactory(factory.django.DjangoModelFactory): name = FuzzyText() class Meta: model = Group -class UserAttributeFactory(factory.DjangoModelFactory): +class UserAttributeFactory(factory.django.DjangoModelFactory): user = factory.SubFactory(UserFactory) class Meta: model = UserAttributes -class OrganizationUserRoleFactory(factory.DjangoModelFactory): +class OrganizationUserRoleFactory(factory.django.DjangoModelFactory): organization = factory.SubFactory(factories.OrganizationFactory) user = factory.SubFactory(UserFactory) - role = FuzzyChoice(PublisherUserRole.values.keys()) + role = FuzzyChoice(InternalUserRole.values.keys()) class Meta: model = OrganizationUserRole -class CourseUserRoleFactory(factory.DjangoModelFactory): - course = factory.SubFactory(CourseFactory) - user = factory.SubFactory(UserFactory) - role = FuzzyChoice(PublisherUserRole.values.keys()) - - class Meta: - model = CourseUserRole - - -class OrganizationExtensionFactory(factory.DjangoModelFactory): +class OrganizationExtensionFactory(factory.django.DjangoModelFactory): organization = factory.SubFactory(factories.OrganizationFactory) group = factory.SubFactory(GroupFactory) class Meta: model = OrganizationExtension - - -class CourseStateFactory(factory.DjangoModelFactory): - course = factory.SubFactory(CourseFactory) - owner_role = FuzzyChoice(PublisherUserRole.values.keys()) - - class Meta: - model = CourseState - - -class CourseRunStateFactory(factory.DjangoModelFactory): - course_run = factory.SubFactory(CourseRunFactory) - owner_role = FuzzyChoice(PublisherUserRole.values.keys()) - - class Meta: - model = CourseRunState - - -class DrupalLoaderConfigFactory(factory.DjangoModelFactory): - class Meta: - model = DrupalLoaderConfig diff --git a/course_discovery/apps/publisher/tests/test_admin.py b/course_discovery/apps/publisher/tests/test_admin.py index fa3bacbedb..fbcf224936 100644 --- a/course_discovery/apps/publisher/tests/test_admin.py +++ b/course_discovery/apps/publisher/tests/test_admin.py @@ -1,102 +1,25 @@ -import ddt from django.contrib.auth.models import Group from django.test import TestCase from django.urls import reverse from guardian.shortcuts import get_group_perms -from waffle.testutils import override_switch from course_discovery.apps.api.tests.mixins import SiteMixin from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory -from course_discovery.apps.publisher.choices import PublisherUserRole -from course_discovery.apps.publisher.constants import ( - PARTNER_MANAGER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME, PUBLISHER_ENABLE_READ_ONLY_FIELDS, PUBLISHER_GROUP_NAME, - REVIEWER_GROUP_NAME -) -from course_discovery.apps.publisher.forms import CourseRunAdminForm -from course_discovery.apps.publisher.models import CourseRun, OrganizationExtension +from course_discovery.apps.publisher.constants import PROJECT_COORDINATOR_GROUP_NAME, REVIEWER_GROUP_NAME +from course_discovery.apps.publisher.models import OrganizationExtension from course_discovery.apps.publisher.tests import factories -from course_discovery.apps.publisher.tests.factories import CourseFactory USER_PASSWORD = 'password' -# pylint: disable=no-member -class AdminTests(SiteMixin, TestCase): - """ Tests Admin page.""" - - def setUp(self): - super(AdminTests, self).setUp() - self.user = UserFactory(is_staff=True, is_superuser=True) - self.client.login(username=self.user.username, password=USER_PASSWORD) - self.course_run = factories.CourseRunFactory(changed_by=self.user, lms_course_id='') - self.run_state = factories.CourseRunStateFactory( - course_run=self.course_run - ) - self.change_url = reverse('admin:publisher_courserun_add') - self.form = self.client.get(self.change_url) - - self.assertFalse(CourseRun.objects.filter(lms_course_id__isnull=True).exists()) - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_update_course_form(self): - """ Verify that admin save the none in case of empty lms-course-id.""" - - organization = OrganizationFactory() - self.course_run.course.organizations.add(organization) - - # in case of empty string no data appears. - data = self._post_data(self.course_run) - self.client.post(self.change_url, data=data) - self.assertTrue(CourseRun.objects.filter(lms_course_id__isnull=True).exists()) - - def test_update_course_with_valid_key(self): - """ Verify that admin save the none in case of empty lms-course-id.""" - - key = 'test/course/key' - data = self._post_data(self.course_run) - data['lms_course_id'] = key - self.client.post(self.change_url, data=data) - self.assertTrue(CourseRun.objects.filter(lms_course_id=key).exists()) - - def test_error_with_invalid_key(self): - """ Verify that admin forms return error in case of invalid course-id.""" - key = 'test' - data = self._post_data(self.course_run) - data['lms_course_id'] = key - form = CourseRunAdminForm(data) - self.assertFalse(form.is_valid()) - self.assertEqual(form.errors, {'lms_course_id': ['Invalid course key.']}) - - def _post_data(self, course_run): - return { - 'lms_course_id': '', - 'pacing_type': course_run.pacing_type_temporary, - 'course': course_run.course.id, - 'start_0': course_run.start_date_temporary.date(), - 'start_1': course_run.start_date_temporary.time(), - 'end_0': course_run.end_date_temporary.date(), - 'end_1': course_run.end_date_temporary.time(), - 'state': self.run_state.id, - 'contacted_partner_manager': course_run.contacted_partner_manager, - 'changed_by': self.user.id, - - } - - def _assert_response(self, url): - """ Verify page loads successfully.""" - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - class OrganizationExtensionAdminTests(SiteMixin, TestCase): """ Tests for OrganizationExtensionAdmin.""" def setUp(self): - super(OrganizationExtensionAdminTests, self).setUp() + super().setUp() self.user = UserFactory(is_staff=True, is_superuser=True) self.client.login(username=self.user.username, password=USER_PASSWORD) - self.run_state = factories.CourseRunStateFactory() self.admin_page_url = reverse('admin:publisher_organizationextension_add') def test_organization_extension_permission(self): @@ -139,83 +62,3 @@ def test_organization_extension_permission(self): def _assert_permissions(self, organization_extension, group, expected_permissions): permissions = get_group_perms(group, organization_extension) self.assertEqual(sorted(permissions), sorted(expected_permissions)) - - -@ddt.ddt -class OrganizationUserRoleAdminTests(SiteMixin, TestCase): - """ Tests for OrganizationUserRoleAdmin.""" - - def setUp(self): - super(OrganizationUserRoleAdminTests, self).setUp() - self.user = UserFactory(is_staff=True, is_superuser=True) - self.client.login(username=self.user.username, password=USER_PASSWORD) - self.admin_page_url = reverse('admin:publisher_organizationuserrole_add') - - self.organization = OrganizationFactory() - - self.course1 = CourseFactory(organizations=[self.organization]) - self.course2 = CourseFactory(organizations=[self.organization]) - - @ddt.data( - (PublisherUserRole.MarketingReviewer, REVIEWER_GROUP_NAME), - (PublisherUserRole.ProjectCoordinator, PROJECT_COORDINATOR_GROUP_NAME), - (PublisherUserRole.Publisher, PUBLISHER_GROUP_NAME), - (PublisherUserRole.PartnerManager, PARTNER_MANAGER_GROUP_NAME) - ) - @ddt.unpack - def test_organization_user_role_groups(self, role, group_name): - """ - Verify that a group is assigned to user according to its role upon OrganizationUserRole creation - and create course users also. - """ - test_user = UserFactory() - post_data = { - 'organization': self.organization.id, 'user': test_user.id, 'role': role - } - - self.client.post(self.admin_page_url, data=post_data) - - # Verify that user is added to the group. - self.assertIn(Group.objects.get(name=group_name), test_user.groups.all()) - - self.assertEqual(self.course1.course_user_roles.filter(role=role).count(), 1) - self.assertEqual(self.course2.course_user_roles.filter(role=role).count(), 1) - self.assertEqual(self.course2.course_user_roles.filter(role=role).first().user, test_user) - - def test_save_method_add_course_user_roles(self): - """ - Verify that save method will not create the duplicate course user roles. - """ - # for course 3 add course roles - user = UserFactory() - course3 = CourseFactory(organizations=[self.organization]) - factories.CourseUserRoleFactory(course=course3, role=PublisherUserRole.MarketingReviewer, user=user) - - # for course 4 add course roles - project_coordinator = UserFactory() - course4 = CourseFactory(organizations=[self.organization]) - factories.CourseUserRoleFactory(course=course4, role=PublisherUserRole.ProjectCoordinator, - user=project_coordinator) - - test_user = UserFactory() - post_data = { - 'organization': self.organization.id, 'user': test_user.id, 'role': PublisherUserRole.MarketingReviewer - } - self.client.post(self.admin_page_url, data=post_data) - - # for course-4 course-user-role does not change - self.assertTrue( - course4.course_user_roles.filter(role=PublisherUserRole.ProjectCoordinator, - user=project_coordinator).exists() - ) - - # for course-3 course-user-role also changes to test_user - self.assertTrue(course3.course_user_roles.filter(role=PublisherUserRole.MarketingReviewer, - user=test_user).exists()) - - self.assertTrue( - self.course1.course_user_roles.filter(role=PublisherUserRole.MarketingReviewer, user=test_user).exists() - ) - self.assertTrue( - self.course2.course_user_roles.filter(role=PublisherUserRole.MarketingReviewer, user=test_user).exists() - ) diff --git a/course_discovery/apps/publisher/tests/test_context_processors.py b/course_discovery/apps/publisher/tests/test_context_processors.py deleted file mode 100644 index 4c12b36158..0000000000 --- a/course_discovery/apps/publisher/tests/test_context_processors.py +++ /dev/null @@ -1,19 +0,0 @@ -""" Publisher context processor tests. """ - -from django.test import RequestFactory, TestCase - -from course_discovery.apps.core.tests.factories import UserFactory -from course_discovery.apps.publisher.context_processors import publisher -from course_discovery.apps.publisher.utils import is_email_notification_enabled - - -class PublisherContextProcessorTests(TestCase): - """ Tests for publisher.context_processors.publisher """ - - def test_publisher(self): - """ Validate that publisher context processor returns expected result. """ - request = RequestFactory().get('/') - request.user = UserFactory() - self.assertDictEqual( - publisher(request), {'is_email_notification_enabled': is_email_notification_enabled(request.user)} - ) diff --git a/course_discovery/apps/publisher/tests/test_emails.py b/course_discovery/apps/publisher/tests/test_emails.py deleted file mode 100644 index addb9750d1..0000000000 --- a/course_discovery/apps/publisher/tests/test_emails.py +++ /dev/null @@ -1,703 +0,0 @@ -# pylint: disable=no-member - -import mock -from django.contrib.auth.models import Group -from django.core import mail -from django.test import TestCase -from django.urls import reverse -from opaque_keys.edx.keys import CourseKey -from testfixtures import LogCapture - -from course_discovery.apps.api.tests.mixins import SiteMixin -from course_discovery.apps.core.models import User -from course_discovery.apps.core.tests.factories import UserFactory -from course_discovery.apps.course_metadata.tests import toggle_switch -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory as DiscoveryCourseRunFactory -from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory, PersonFactory -from course_discovery.apps.publisher import emails -from course_discovery.apps.publisher.choices import PublisherUserRole -from course_discovery.apps.publisher.constants import LEGAL_TEAM_GROUP_NAME -from course_discovery.apps.publisher.models import UserAttributes -from course_discovery.apps.publisher.tests import factories -from course_discovery.apps.publisher.tests.factories import UserAttributeFactory - - -class StudioInstanceCreatedEmailTests(SiteMixin, TestCase): - """ - Tests for the studio instance created email functionality. - """ - - def setUp(self): - super(StudioInstanceCreatedEmailTests, self).setUp() - self.user = UserFactory() - self.course_run = factories.CourseRunFactory(lms_course_id='course-v1:edX+DemoX+Demo_Course') - - # add user in course-user-role table - factories.CourseUserRoleFactory( - course=self.course_run.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - - self.course_team = UserFactory() - factories.CourseUserRoleFactory( - course=self.course_run.course, role=PublisherUserRole.CourseTeam, user=self.course_team - ) - - UserAttributeFactory(user=self.user, enable_email_notification=True) - - toggle_switch('enable_publisher_email_notifications', True) - - @mock.patch('django.core.mail.message.EmailMessage.send', mock.Mock(side_effect=TypeError)) - def test_email_with_error(self): - """ Verify that emails failure raise exception.""" - - with self.assertRaises(Exception) as ex: - emails.send_email_for_studio_instance_created(self.course_run, self.site) - error_message = 'Failed to send email notifications for course_run [{}]'.format(self.course_run.id) - self.assertEqual(ex.message, error_message) - - def test_email_sent_successfully(self): - """ Verify that emails sent successfully for studio instance created.""" - - emails.send_email_for_studio_instance_created(self.course_run, self.site) - course_key = CourseKey.from_string(self.course_run.lms_course_id) - self.assert_email_sent( - reverse('publisher:publisher_course_run_detail', kwargs={'pk': self.course_run.id}), - 'Studio URL created: {title} {run_number}'.format( - title=self.course_run.course.title, - run_number=course_key.run - ), - 'created a Studio URL for the' - ) - - def assert_email_sent(self, object_path, subject, expected_body): - """ Assert email data""" - self.assertEqual(len(mail.outbox), 1) - self.assertEqual([self.course_team.email], mail.outbox[0].to) - self.assertEqual(str(mail.outbox[0].subject), subject) - - body = mail.outbox[0].body.strip() - self.assertIn(expected_body, body) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=object_path) - self.assertIn(page_url, body) - self.assertIn('Enter course run content in Studio.', body) - self.assertIn('Thanks', body) - self.assertIn('This email address is unable to receive replies. For questions or comments', body) - self.assertIn(self.course_team.full_name, body) - self.assertIn(self.user.full_name, body) - self.assertIn('Note: This email address is unable to receive replies.', body) - self.assertIn( - 'For questions or comments, contact {}.'.format(self.user.email), body - ) - - -class CourseCreatedEmailTests(SiteMixin, TestCase): - """ Tests for the new course created email functionality. """ - - def setUp(self): - super(CourseCreatedEmailTests, self).setUp() - self.user = UserFactory() - self.course_run = factories.CourseRunFactory() - - # add user in course-user-role table - factories.CourseUserRoleFactory( - course=self.course_run.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - - self.course_team = UserFactory() - factories.CourseUserRoleFactory( - course=self.course_run.course, role=PublisherUserRole.CourseTeam, user=self.course_team - ) - - UserAttributeFactory(user=self.user, enable_email_notification=True) - - toggle_switch('enable_publisher_email_notifications', True) - - @mock.patch('django.core.mail.message.EmailMessage.send', mock.Mock(side_effect=TypeError)) - def test_email_with_error(self): - """ Verify that emails failure logs error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_course_creation(self.course_run.course, self.course_run, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications for creation of course [{}]'.format( - self.course_run.course.id - ) - ) - ) - - def test_email_sent_successfully(self): - """ Verify that studio instance request email sent successfully.""" - - emails.send_email_for_course_creation(self.course_run.course, self.course_run, self.site) - subject = 'Studio URL requested: {title}'.format(title=self.course_run.course.title) - self.assert_email_sent(subject) - - def assert_email_sent(self, subject): - """ Assert email data.""" - self.assertEqual(len(mail.outbox), 1) - self.assertEqual([self.user.email], mail.outbox[0].to) - self.assertEqual(str(mail.outbox[0].subject), subject) - - body = mail.outbox[0].body.strip() - self.assertIn('{name} created the'.format(name=self.course_team.full_name), body) - self.assertIn('{dashboard_url}'.format(dashboard_url=reverse('publisher:publisher_dashboard')), body) - self.assertIn('Thanks', body) - - def test_email_not_sent_with_notification_disabled(self): - """ Verify that emails not sent if notification disabled by user.""" - user_attribute = UserAttributes.objects.get(user=self.user) - user_attribute.enable_email_notification = False - user_attribute.save() - emails.send_email_for_course_creation(self.course_run.course, self.course_run, self.site) - - self.assertEqual(len(mail.outbox), 0) - - -class SendForReviewEmailTests(SiteMixin, TestCase): - """ Tests for the send for review email functionality. """ - - def setUp(self): - super(SendForReviewEmailTests, self).setUp() - self.user = UserFactory() - self.course_state = factories.CourseStateFactory() - - def test_email_with_error(self): - """ Verify that email failure logs error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_send_for_review(self.course_state.course, self.user, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications send for review of course {}'.format( - self.course_state.course.id - ) - ) - ) - - -class CourseMarkAsReviewedEmailTests(SiteMixin, TestCase): - """ Tests for the mark as reviewed email functionality. """ - - def setUp(self): - super(CourseMarkAsReviewedEmailTests, self).setUp() - self.user = UserFactory() - self.course_state = factories.CourseStateFactory() - - def test_email_with_error(self): - """ Verify that email failure logs error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_mark_as_reviewed(self.course_state.course, self.user, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications mark as reviewed of course {}'.format( - self.course_state.course.id - ) - ) - ) - - -class CourseRunSendForReviewEmailTests(SiteMixin, TestCase): - """ Tests for the CourseRun send for review email functionality. """ - - def setUp(self): - super(CourseRunSendForReviewEmailTests, self).setUp() - self.user = UserFactory() - self.user_2 = UserFactory() - self.user_3 = UserFactory() - - self.seat = factories.SeatFactory() - self.course_run = self.seat.course_run - self.course = self.course_run.course - self.course.organizations.add(OrganizationFactory()) - - # add user in course-user-role table - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.CourseTeam, user=self.user_2 - ) - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.Publisher, user=self.user_3 - ) - self.course_run_state = factories.CourseRunStateFactory(course_run=self.course_run) - self.course_run.lms_course_id = 'course-v1:edX+DemoX+Demo_Course' - self.course_run.save() - - self.course_key = CourseKey.from_string(self.course_run.lms_course_id) - - toggle_switch('enable_publisher_email_notifications', True) - - def test_email_sent_by_marketing_reviewer(self): - """ Verify that email works successfully for marketing user.""" - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - emails.send_email_for_send_for_review_course_run(self.course_run_state.course_run, self.user, self.site) - subject = 'Review requested: {title} {run_number}'.format(title=self.course, run_number=self.course_key.run) - self.assert_email_sent(subject, self.user_2) - - def test_email_sent_by_course_team(self): - """ Verify that email works successfully for course team user.""" - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - emails.send_email_for_send_for_review_course_run(self.course_run_state.course_run, self.user_2, self.site) - subject = 'Review requested: {title} {run_number}'.format(title=self.course, run_number=self.course_key.run) - self.assert_email_sent(subject, self.user) - - def test_email_with_error(self): - """ Verify that email failure logs error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_send_for_review_course_run(self.course_run, self.user, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications send for review of course-run {}'.format( - self.course_run.id - ) - ) - ) - - def assert_email_sent(self, subject, to_email): - """ Assert email data.""" - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(to_email.email, mail.outbox[0].to[0]) - self.assertEqual(str(mail.outbox[0].subject), subject) - body = mail.outbox[0].body.strip() - page_path = reverse('publisher:publisher_course_run_detail', kwargs={'pk': self.course_run.id}) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=page_path) - self.assertIn(page_url, body) - self.assertIn('View this course run in Publisher to review the changes or suggest edits.', body) - - -class CourseRunMarkAsReviewedEmailTests(SiteMixin, TestCase): - """ Tests for the CourseRun mark as reviewed email functionality. """ - - def setUp(self): - super(CourseRunMarkAsReviewedEmailTests, self).setUp() - self.user = UserFactory() - self.user_2 = UserFactory() - self.user_3 = UserFactory() - - self.seat = factories.SeatFactory() - self.course_run = self.seat.course_run - self.course = self.course_run.course - self.course.organizations.add(OrganizationFactory()) - - # add user in course-user-role table - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.CourseTeam, user=self.user_2 - ) - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.Publisher, user=self.user_3 - ) - self.course_run_state = factories.CourseRunStateFactory(course_run=self.course_run) - - self.course_run.lms_course_id = 'course-v1:edX+DemoX+Demo_Course' - self.course_run.save() - - toggle_switch('enable_publisher_email_notifications', True) - - def test_email_not_sent_by_project_coordinator(self): - """ Verify that no email is sent if approving person is project coordinator. """ - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - emails.send_email_for_mark_as_reviewed_course_run(self.course_run_state.course_run, self.user, self.site) - self.assertEqual(len(mail.outbox), 0) - - def test_email_sent_by_course_team(self): - """ Verify that email works successfully for course team user.""" - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - emails.send_email_for_mark_as_reviewed_course_run(self.course_run_state.course_run, self.user_2, self.site) - self.assert_email_sent(self.user) - - def test_email_mark_as_reviewed_with_error(self): - """ Verify that email failure log error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_mark_as_reviewed_course_run(self.course_run, self.user, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications for mark as reviewed of course-run {}'.format( - self.course_run.id - ) - ) - ) - - def test_email_sent_to_publisher(self): - """ Verify that email works successfully.""" - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - emails.send_email_to_publisher(self.course_run_state.course_run, self.user, self.site) - self.assert_email_sent(self.user_3) - - def test_email_to_publisher_with_error(self): - """ Verify that email failure log error message.""" - - with mock.patch('django.core.mail.message.EmailMessage.send', side_effect=TypeError): - with LogCapture(emails.logger.name) as l: - emails.send_email_to_publisher(self.course_run, self.user_3, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications for mark as reviewed of course-run {}'.format( - self.course_run.id - ) - ) - ) - - def assert_email_sent(self, to_email): - """ Verify the email data for tests cases.""" - - course_key = CourseKey.from_string(self.course_run.lms_course_id) - subject = 'Review complete: {course_name} {run_number}'.format( - course_name=self.course.title, - run_number=course_key.run - ) - - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(to_email.email, mail.outbox[0].to[0]) - self.assertEqual(str(mail.outbox[0].subject), subject) - body = mail.outbox[0].body.strip() - page_path = reverse('publisher:publisher_course_run_detail', kwargs={'pk': self.course_run.id}) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=page_path) - self.assertIn(page_url, body) - self.assertIn('The review for this course run is complete.', body) - - -class CourseRunPreviewEmailTests(SiteMixin, TestCase): - """ - Tests for the course preview email functionality. - """ - - def setUp(self): - super(CourseRunPreviewEmailTests, self).setUp() - self.user = UserFactory() - - self.run_state = factories.CourseRunStateFactory() - self.course = self.run_state.course_run.course - - self.course.organizations.add(OrganizationFactory()) - - # add users in CourseUserRole table - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.CourseTeam, user=self.user - ) - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.Publisher, user=UserFactory() - ) - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=UserFactory() - ) - - toggle_switch('enable_publisher_email_notifications', True) - - def test_preview_accepted_email(self): - """ - Verify that preview accepted email functionality works fine. - """ - lms_course_id = 'course-v1:edX+DemoX+Demo_Course' - self.run_state.course_run.lms_course_id = lms_course_id - - emails.send_email_preview_accepted(self.run_state.course_run, self.site) - - course_key = CourseKey.from_string(lms_course_id) - subject = 'Publication requested: {course_name} {run_number}'.format( - course_name=self.course.title, - run_number=course_key.run - ) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual([self.course.publisher.email, self.course.project_coordinator.email], mail.outbox[0].bcc) - self.assertEqual(str(mail.outbox[0].subject), subject) - body = mail.outbox[0].body.strip() - page_path = reverse('publisher:publisher_course_run_detail', kwargs={'pk': self.run_state.course_run.id}) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=page_path) - self.assertIn(page_url, body) - self.assertIn('You can now publish this About page.', body) - - def test_preview_accepted_email_with_error(self): - """ Verify that email failure log error message.""" - - message = 'Failed to send email notifications for preview approved of course-run [{}]'.format( - self.run_state.course_run.id - ) - with mock.patch('django.core.mail.message.EmailMessage.send', side_effect=TypeError): - with self.assertRaises(Exception) as ex: - self.assertEqual(str(ex.exception), message) - with LogCapture(emails.logger.name) as l: - emails.send_email_preview_accepted(self.run_state.course_run, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - message - ) - ) - - def test_preview_available_email(self): - """ - Verify that preview available email functionality works fine. - """ - course_run = self.run_state.course_run - course_run.lms_course_id = 'course-v1:testX+testX1.0+2017T1' - course_run.save() - - emails.send_email_preview_page_is_available(course_run, self.site) - - course_key = CourseKey.from_string(course_run.lms_course_id) - subject = 'Review requested: Preview for {course_name} {run_number}'.format( - course_name=self.course.title, - run_number=course_key.run - ) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual([self.course.course_team_admin.email], mail.outbox[0].to) - self.assertEqual(str(mail.outbox[0].subject), subject) - body = mail.outbox[0].body.strip() - page_path = reverse('publisher:publisher_course_run_detail', kwargs={'pk': course_run.id}) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=page_path) - self.assertIn(page_url, body) - self.assertIn('A preview is now available for the', body) - - def test_preview_available_email_with_error(self): - """ Verify that exception raised on email failure.""" - - with self.assertRaises(Exception) as ex: - emails.send_email_preview_page_is_available(self.run_state.course_run, self.site) - error_message = 'Failed to send email notifications for preview available of course-run {}'.format( - self.run_state.course_run.id - ) - self.assertEqual(ex.message, error_message) - - def test_preview_available_email_with_notification_disabled(self): - """ Verify that email not sent if notification disabled by user.""" - factories.UserAttributeFactory(user=self.course.course_team_admin, enable_email_notification=False) - emails.send_email_preview_page_is_available(self.run_state.course_run, self.site) - - self.assertEqual(len(mail.outbox), 0) - - def test_preview_accepted_email_with_notification_disabled(self): - """ Verify that preview accepted email not sent if notification disabled by user.""" - factories.UserAttributeFactory(user=self.course.publisher, enable_email_notification=False) - emails.send_email_preview_accepted(self.run_state.course_run, self.site) - - self.assertEqual(len(mail.outbox), 0) - - -class CourseRunPublishedEmailTests(SiteMixin, TestCase): - def setUp(self): - super(CourseRunPublishedEmailTests, self).setUp() - self.user = UserFactory() - - self.run_state = factories.CourseRunStateFactory() - self.course_run = self.run_state.course_run - self.course = self.course_run.course - - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.CourseTeam, user=self.user) - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.Publisher, user=UserFactory()) - - toggle_switch('enable_publisher_email_notifications', True) - - def test_course_published_email(self): - """ - Verify that course published email functionality works fine. - """ - project_coordinator = UserFactory() - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator, - user=project_coordinator) - self.course_run.lms_course_id = 'course-v1:testX+test45+2017T2' - self.course_run.save() - person = PersonFactory() - DiscoveryCourseRunFactory(key=self.course_run.lms_course_id, staff=[person]) - emails.send_course_run_published_email(self.course_run, self.site) - - course_key = CourseKey.from_string(self.course_run.lms_course_id) - subject = 'Publication complete: About page for {course_name} {run_number}'.format( - course_name=self.course_run.course.title, - run_number=course_key.run - ) - assert len(mail.outbox) == 1 - - message = mail.outbox[0] - assert message.to == [self.user.email] - assert message.cc == [project_coordinator.email] - - self.assertEqual(str(mail.outbox[0].subject), subject) - body = mail.outbox[0].body.strip() - self.assertIn(self.course_run.preview_url, body) - self.assertIn('has been published', body) - - def test_course_published_email_with_error(self): - """ Verify that email failure log error message.""" - - message = 'Failed to send email notifications for course published of course-run [{}]'.format( - self.course_run.id - ) - with mock.patch('django.core.mail.message.EmailMessage.send', side_effect=TypeError): - with self.assertRaises(Exception) as ex: - emails.send_course_run_published_email(self.course_run, self.site) - self.assertEqual(str(ex.exception), message) - - -class CourseChangeRoleAssignmentEmailTests(SiteMixin, TestCase): - """ - Tests email functionality for course role assignment changed. - """ - - def setUp(self): - super(CourseChangeRoleAssignmentEmailTests, self).setUp() - self.user = UserFactory() - - self.marketing_role = factories.CourseUserRoleFactory(role=PublisherUserRole.MarketingReviewer, user=self.user) - self.course = self.marketing_role.course - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.Publisher) - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator) - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.CourseTeam) - - toggle_switch('enable_publisher_email_notifications', True) - - def test_change_role_assignment_email(self): - """ - Verify that course role assignment chnage email functionality works fine. - """ - emails.send_change_role_assignment_email(self.marketing_role, self.user, self.site) - expected_subject = '{role_name} changed for {course_title}'.format( - role_name=self.marketing_role.get_role_display().lower(), - course_title=self.course.title - ) - - expected_emails = set(self.course.get_course_users_emails()) - expected_emails.remove(self.course.course_team_admin.email) - - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(expected_emails, set(mail.outbox[0].to)) - self.assertEqual(str(mail.outbox[0].subject), expected_subject) - body = mail.outbox[0].body.strip() - page_path = reverse('publisher:publisher_course_detail', kwargs={'pk': self.course.id}) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=page_path) - self.assertIn(page_url, body) - self.assertIn('has changed.', body) - - def test_change_role_assignment_email_with_error(self): - """ - Verify that email failure raises exception. - """ - - message = 'Failed to send email notifications for change role assignment of role: [{role_id}]'.format( - role_id=self.marketing_role.id - ) - with mock.patch('django.core.mail.message.EmailMessage.send', side_effect=TypeError): - with self.assertRaises(Exception) as ex: - emails.send_change_role_assignment_email(self.marketing_role, self.user, self.site) - self.assertEqual(str(ex.exception), message) - - -class SEOReviewEmailTests(SiteMixin, TestCase): - """ Tests for the seo review email functionality. """ - - def setUp(self): - super(SEOReviewEmailTests, self).setUp() - self.user = UserFactory() - self.course_state = factories.CourseStateFactory() - self.course = self.course_state.course - self.course.organizations.add(OrganizationFactory()) - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.CourseTeam, user=self.user) - self.legal_user = UserFactory() - self.legal_user.groups.add(Group.objects.get(name=LEGAL_TEAM_GROUP_NAME)) - - UserAttributeFactory(user=self.user, enable_email_notification=True) - - def test_email_with_error(self): - """ Verify that email failure logs error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_seo_review(self.course, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications for legal review requested of course {}'.format( - self.course.id - ) - ) - ) - - def test_seo_review_email(self): - """ - Verify that seo review email functionality works fine. - """ - factories.CourseUserRoleFactory(course=self.course, role=PublisherUserRole.ProjectCoordinator) - emails.send_email_for_seo_review(self.course, self.site) - expected_subject = 'Legal review requested: {title}'.format(title=self.course.title) - - self.assertEqual(len(mail.outbox), 1) - legal_team_users = User.objects.filter(groups__name=LEGAL_TEAM_GROUP_NAME) - expected_addresses = [user.email for user in legal_team_users] # pylint: disable=not-an-iterable - self.assertEqual(expected_addresses, mail.outbox[0].to) - self.assertEqual(str(mail.outbox[0].subject), expected_subject) - body = mail.outbox[0].body.strip() - page_path = reverse('publisher:publisher_course_detail', kwargs={'pk': self.course.id}) - page_url = 'https://{host}{path}'.format(host=self.site.domain.strip('/'), path=page_path) - self.assertIn(page_url, body) - self.assertIn('determine OFAC status', body) - - -class CourseRunPublishedEditEmailTests(CourseRunPublishedEmailTests): - """ - Tests for published course-run editing email functionality. - """ - - def test_published_course_run_editing_email(self): - """ - Verify that on edit the published course-run email send to publisher. - """ - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user - ) - self.course_run.lms_course_id = 'course-v1:testX+test45+2017T2' - self.course_run.save() - emails.send_email_for_published_course_run_editing(self.course_run, self.site) - - course_key = CourseKey.from_string(self.course_run.lms_course_id) - - subject = 'Changes to published course run: {title} {run_number}'.format( - title=self.course_run.course.title, - run_number=course_key.run - ) - - self.assertEqual(len(mail.outbox), 1) - self.assertEqual([self.course.publisher.email], mail.outbox[0].to) - self.assertEqual(str(mail.outbox[0].subject), subject) - body = mail.outbox[0].body.strip() - self.assertIn('has made changes to the following published course run.', body) - page_path = reverse('publisher:publisher_course_run_detail', kwargs={'pk': self.run_state.course_run.id}) - self.assertIn(page_path, body) - - def test_email_with_error(self): - """ Verify that email failure logs error message.""" - - with LogCapture(emails.logger.name) as l: - emails.send_email_for_published_course_run_editing(self.course_run, self.site) - l.check( - ( - emails.logger.name, - 'ERROR', - 'Failed to send email notifications for publisher course-run [{}] editing.'.format( - self.course_run.id - ) - ) - ) diff --git a/course_discovery/apps/publisher/tests/test_forms.py b/course_discovery/apps/publisher/tests/test_forms.py deleted file mode 100644 index d62b58e67c..0000000000 --- a/course_discovery/apps/publisher/tests/test_forms.py +++ /dev/null @@ -1,531 +0,0 @@ -from datetime import datetime, timedelta - -import ddt -import pytest -from django.core.exceptions import ValidationError -from django.test import TestCase -from guardian.shortcuts import assign_perm -from pytz import timezone -from waffle.testutils import override_switch - -from course_discovery.apps.core.models import User -from course_discovery.apps.core.tests.factories import UserFactory -from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory -from course_discovery.apps.publisher.choices import CourseRunStateChoices, PublisherUserRole -from course_discovery.apps.publisher.constants import ( - ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME, PUBLISHER_ENABLE_READ_ONLY_FIELDS -) -from course_discovery.apps.publisher.forms import ( - CourseEntitlementForm, CourseForm, CourseRunForm, CourseRunStateAdminForm, CourseSearchForm, CourseStateAdminForm, - PublisherUserCreationForm, SeatForm -) -from course_discovery.apps.publisher.models import Group, OrganizationExtension, Seat -from course_discovery.apps.publisher.tests.factories import ( - CourseFactory, CourseUserRoleFactory, OrganizationExtensionFactory, SeatFactory -) - - -class UserModelChoiceFieldTests(TestCase): - """ - Tests for the publisher model "UserModelChoiceField". - """ - - def setUp(self): - super(UserModelChoiceFieldTests, self).setUp() - self.course_form = CourseForm() - - def test_course_form(self): - """ - Verify that UserModelChoiceField returns `full_name` as choice label. - """ - user = UserFactory(username='test_user', full_name='Test Full Name') - self._assert_choice_label(user.full_name) - - def test_team_admin_without_full_name(self): - """ - Verify that UserModelChoiceField returns `username` if `full_name` is empty. - """ - user = UserFactory(username='test_user', full_name='', first_name='', last_name='') - self._assert_choice_label(user.username) - - def _assert_choice_label(self, expected_name): - self.course_form.fields['team_admin'].queryset = User.objects.all() - self.course_form.fields['team_admin'].empty_label = None - - # we need to loop through choices because it is a ModelChoiceIterator - for __, choice_label in self.course_form.fields['team_admin'].choices: - self.assertEqual(choice_label, expected_name) - - -class PublisherUserCreationFormTests(TestCase): - """ - Tests for the publisher `PublisherUserCreationForm`. - """ - - def test_clean_groups(self): - """ - Verify that `clean` raises `ValidationError` error if no group is selected. - """ - user_form = PublisherUserCreationForm() - user_form.cleaned_data = {'username': 'test_user', 'groups': []} - with self.assertRaises(ValidationError): - user_form.clean() - - user_form.cleaned_data['groups'] = ['test_group'] - self.assertEqual(user_form.clean(), user_form.cleaned_data) - - -@ddt.ddt -class CourseRunStateAdminFormTests(TestCase): - """ - Tests for the publisher 'CourseRunStateAdminForm'. - """ - - @ddt.data( - CourseRunStateChoices.Draft, - CourseRunStateChoices.Review, - ) - def test_clean_with_validation_error(self, course_run_state): - """ - Verify that 'clean' raises 'ValidationError' for invalid course run state - """ - run_state_form = CourseRunStateAdminForm() - run_state_form.cleaned_data = {'name': course_run_state, 'owner_role': PublisherUserRole.Publisher} - with self.assertRaises(ValidationError): - run_state_form.clean() - - def test_clean_without_validation_error(self): - """ - Verify that 'clean' does not raise 'ValidationError' for valid course run state - """ - run_state_form = CourseRunStateAdminForm() - run_state_form.cleaned_data = { - 'name': CourseRunStateChoices.Approved, - 'owner_role': PublisherUserRole.Publisher - } - self.assertEqual(run_state_form.clean(), run_state_form.cleaned_data) - - -class CourseStateAdminFormTests(TestCase): - """ - Tests for the publisher "CourseStateAdminForm". - """ - - def test_clean_with_invalid_owner_role(self): - """ - Test that 'clean' raises 'ValidationError' if the user role that has been assigned owner does not exist - """ - course_state_form = CourseStateAdminForm() - course_state_form.cleaned_data = { - 'owner_role': PublisherUserRole.CourseTeam - } - with self.assertRaises(ValidationError): - course_state_form.clean() - - def test_clean_with_valid_owner_role(self): - """ - Test that 'clean' does not raise 'ValidationError' if the user role that has been assigned owner does exist - """ - course = CourseFactory() - user = UserFactory() - CourseUserRoleFactory(course=course, user=user, role=PublisherUserRole.CourseTeam) - course_state_form = CourseStateAdminForm() - course_state_form.cleaned_data = { - 'owner_role': PublisherUserRole.CourseTeam, - 'course': course - } - self.assertEqual(course_state_form.clean(), course_state_form.cleaned_data) - - -@ddt.ddt -class PublisherCourseRunEditFormTests(TestCase): - """ - Tests for the publisher 'CourseRunForm'. - """ - - def test_minimum_effort(self): - """ - Verify that 'clean' raises 'ValidationError' error if Minimum effort is greater - than Maximum effort. - """ - run_form = CourseRunForm() - run_form.cleaned_data = {'min_effort': 4, 'max_effort': 2} - with self.assertRaises(ValidationError): - run_form.clean() - - run_form.cleaned_data['min_effort'] = 1 - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - def test_minimum_maximum_effort_equality(self): - """ - Verify that 'clean' raises 'ValidationError' error if Minimum effort and - Maximum effort are equal. - """ - run_form = CourseRunForm() - run_form.cleaned_data = {'min_effort': 4, 'max_effort': 4} - with self.assertRaises(ValidationError) as err: - run_form.clean() - - self.assertEqual(str(err.exception), "{'min_effort': ['Minimum effort and Maximum effort can not be same']}") - run_form.cleaned_data['min_effort'] = 2 - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - def test_minimum__effort_is_not_empty(self): - """ - Verify that 'clean' raises 'ValidationError' error if Maximum effort is - empty. - """ - run_form = CourseRunForm() - run_form.cleaned_data = {'min_effort': 4} - with self.assertRaises(ValidationError) as err: - run_form.clean() - - self.assertEqual(str(err.exception), "{'max_effort': ['Maximum effort can not be empty']}") - run_form.cleaned_data['max_effort'] = 5 - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - def test_course_run_dates(self): - """ - Verify that 'clean' raises 'ValidationError' if the Start date is in the past - Or if the Start date is after the End date - """ - run_form = CourseRunForm() - current_datetime = datetime.now(timezone('US/Central')) - run_form.cleaned_data = {'start': current_datetime + timedelta(days=3), - 'end': current_datetime + timedelta(days=1)} - with self.assertRaises(ValidationError): - run_form.clean() - - run_form.cleaned_data['start'] = current_datetime + timedelta(days=1) - run_form.cleaned_data['end'] = current_datetime + timedelta(days=3) - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - def test_course_run_xseries(self): - """ - Verify that 'clean' raises 'ValidationError' if the is_xseries is checked - but no xseries_name has been entered - """ - run_form = CourseRunForm() - run_form.cleaned_data = {'is_xseries': True, 'xseries_name': ''} - with self.assertRaises(ValidationError): - run_form.clean() - - run_form.cleaned_data['xseries_name'] = "Test Name" - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - def test_course_run_micromasters(self): - """ - Verify that 'clean' raises 'ValidationError' if the is_micromasters is checked - but no micromasters_name has been entered - """ - run_form = CourseRunForm() - run_form.cleaned_data = {'is_micromasters': True, 'micromasters_name': ''} - with self.assertRaises(ValidationError): - run_form.clean() - - run_form.cleaned_data['micromasters_name'] = "Test Name" - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - def test_course_run_professional_certificate(self): - """ - Verify that 'clean' raises 'ValidationError' if the is_professional_certificate is checked - but no professional_certificate_name has been entered - """ - run_form = CourseRunForm() - run_form.cleaned_data = {'is_professional_certificate': True, 'professional_certificate_name': ''} - with self.assertRaises(ValidationError): - run_form.clean() - - run_form.cleaned_data['professional_certificate_name'] = "Test Name" - self.assertEqual(run_form.clean(), run_form.cleaned_data) - - @ddt.data(True, False) - def test_date_fields_are_hidden_when_switch_enabled(self, is_switch_enabled): - with override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=is_switch_enabled): - run_form = CourseRunForm( - hide_start_date_field=is_switch_enabled, - hide_end_date_field=is_switch_enabled - ) - self.assertEqual(run_form.fields['start'].widget.is_hidden, is_switch_enabled) - self.assertEqual(run_form.fields['end'].widget.is_hidden, is_switch_enabled) - - -@ddt.ddt -class PublisherCustomCourseFormTests(TestCase): - """ - Tests for publisher 'CourseForm' - """ - - def setUp(self): - super(PublisherCustomCourseFormTests, self).setUp() - self.course_form = CourseForm() - self.organization = OrganizationFactory() - self.course = CourseFactory(title='Test', number='a123', organizations=[self.organization]) - - def setup_course(self, **course_kwargs): - """ - Creates the course and add organization and admin to this course. - - Returns: - course: a course object - course_admin: a user object - """ - organization_extension = OrganizationExtensionFactory() - defaults = { - 'organizations': [organization_extension.organization], - } - defaults.update(course_kwargs) - course = CourseFactory(**defaults) - - course_admin = UserFactory() - course_admin.groups.add(organization_extension.group) - - return course, course_admin - - def test_duplicate_title(self): - """ - Verify that clean raises 'ValidationError' if the course title is a duplicate of another course title - within the same organization - """ - course_form = CourseForm() - course_form.cleaned_data = {'title': 'Test', 'number': '123a', 'organization': self.organization} - with self.assertRaises(ValidationError): - course_form.clean() - - course_form.cleaned_data['title'] = "Test2" - self.assertEqual(course_form.clean(), course_form.cleaned_data) - - def test_duplicate_course_number(self): - """ - Verify that clean raises 'ValidationError' if the course number is a duplicate of another course number - within the same organization - """ - course_form = CourseForm() - course_form.cleaned_data = {'title': 'Test2', 'number': 'a123', 'organization': self.organization} - with self.assertRaises(ValidationError): - course_form.clean() - - course_form.cleaned_data['number'] = "123a" - self.assertEqual(course_form.clean(), course_form.cleaned_data) - - @ddt.data( - [" ", ",", "@", "(", "!", "#", "$", "%", "^", "&", "*", "+", "=", "{", "[", "ó"] - ) - def test_invalid_course_number(self, invalid_char_list): - """ - Verify that clean_number raises 'ValidationError' if the course number consists of special characters - or spaces other than underscore,hyphen or period - """ - course_form = CourseForm() - for invalid_char in invalid_char_list: - course_form.cleaned_data = {'number': 'course_num{}'.format(invalid_char)} - with self.assertRaises(ValidationError): - course_form.clean_number() - - @ddt.data( - ["123a", "123_a", "123.a", "123-a", "XYZ123"] - ) - def test_valid_course_number(self, valid_number_list): - """ - Verify that clean_number allows alphanumeric(a-zA-Z0-9) characters, period, underscore and hyphen - in course number - """ - course_form = CourseForm() - for valid_number in valid_number_list: - course_form.cleaned_data = {'number': valid_number} - self.assertEqual(course_form.clean_number(), valid_number) - - def test_course_title_formatting(self): - """ - Verify that course_title is properly escaped and saved in database while - updating the course - """ - course, course_admin = self.setup_course(image=None) - assert course.title != 'áçã' - - organization = course.organizations.first().id - course_from_data = { - 'title': 'áçã', - 'number': course.number, - 'organization': organization, - 'team_admin': course_admin.id - } - course_form = CourseForm( - **{'data': course_from_data, 'instance': course, 'user': course_admin, - 'organization': organization} - ) - assert course_form.is_valid() - course_form.save() - course.refresh_from_db() - assert course.title == 'áçã' - - -@ddt.ddt -class PublisherCourseEntitlementFormTests(TestCase): - without_price_error = 'Price is required.' - negative_price_error = 'Price must be greater than or equal to 0.01' - - @ddt.data( - (CourseEntitlementForm.VERIFIED_MODE, None, without_price_error), - (CourseEntitlementForm.PROFESSIONAL_MODE, None, without_price_error), - (CourseEntitlementForm.VERIFIED_MODE, -0.05, negative_price_error), - (CourseEntitlementForm.PROFESSIONAL_MODE, -0.05, negative_price_error), - ) - @ddt.unpack - def test_invalid_price(self, mode, price, error_message): - """ - Verify that form raises an error if the price is None or in -ive format - """ - form_data = {'mode': mode, 'price': price} - entitlement_form = CourseEntitlementForm(data=form_data) - self.assertFalse(entitlement_form.is_valid()) - self.assertEqual(entitlement_form.errors, {'price': [error_message]}) - - @ddt.data( - (None, None), - (None, 0), - (CourseEntitlementForm.AUDIT_MODE, None), - (CourseEntitlementForm.AUDIT_MODE, 0), - (CourseEntitlementForm.VERIFIED_MODE, 50), - (CourseEntitlementForm.PROFESSIONAL_MODE, 50), - (CourseEntitlementForm.CREDIT_MODE, None), - (CourseEntitlementForm.CREDIT_MODE, 0), - ) - @ddt.unpack - def test_valid_data(self, mode, price): - """ - Verify that is_valid returns True for valid mode/price combos - """ - entitlement_form = CourseEntitlementForm({'mode': mode, 'price': price}) - self.assertTrue(entitlement_form.is_valid()) - - @ddt.data( - (CourseEntitlementForm.AUDIT_MODE, 0, None), - (CourseEntitlementForm.VERIFIED_MODE, 50, CourseEntitlementForm.VERIFIED_MODE), - (CourseEntitlementForm.PROFESSIONAL_MODE, 50, CourseEntitlementForm.PROFESSIONAL_MODE), - (CourseEntitlementForm.CREDIT_MODE, 0, None), - ) - @ddt.unpack - def test_clean_mode(self, raw_mode, raw_price, cleaned_mode): - """ - Verify that mode is cleaned properly and that NOOP_MODES are set to None. - """ - entitlement_form = CourseEntitlementForm({'mode': raw_mode, 'price': raw_price}) - self.assertTrue(entitlement_form.is_valid()) - self.assertEqual(entitlement_form.cleaned_data['mode'], cleaned_mode) - - def test_include_blank_mode(self): - """ - Verify that when the include_blank_mode option is passed to the constructor, the mode field includes - a blank option. - """ - entitlement_form = CourseEntitlementForm(include_blank_mode=True) - self.assertEqual([('', '')] + CourseEntitlementForm.MODE_CHOICES, entitlement_form.fields['mode'].choices) - - -@pytest.mark.django_db -class TestSeatForm: - @override_switch('publisher_create_audit_seats_for_verified_course_runs', active=True) - @pytest.mark.parametrize('seat_type', (Seat.NO_ID_PROFESSIONAL, Seat.PROFESSIONAL,)) - def test_remove_audit_seat_for_professional_course_runs(self, seat_type): - seat = SeatFactory(type=seat_type) - audit_seat = SeatFactory(type=Seat.AUDIT, course_run=seat.course_run) - form = SeatForm(instance=seat) - form.save() - assert list(seat.course_run.seats.all()) == [seat] - assert not Seat.objects.filter(pk=audit_seat.pk).exists() - - @override_switch('publisher_create_audit_seats_for_verified_course_runs', active=True) - def test_audit_only_seat_not_modified(self): - seat = SeatFactory(type=Seat.AUDIT) - form = SeatForm(instance=seat) - form.save() - assert list(seat.course_run.seats.all()) == [seat] - - @override_switch('publisher_create_audit_seats_for_verified_course_runs', active=True) - @pytest.mark.parametrize('seat_type', (Seat.CREDIT, Seat.VERIFIED,)) - def test_create_audit_seat_for_credit_and_verified_course_runs(self, seat_type): - seat = SeatFactory(type=seat_type) - form = SeatForm(instance=seat) - form.save() - assert seat.course_run.seats.count() == 2 - assert seat.course_run.seats.filter(type=Seat.AUDIT, price=0).exists() - - -@ddt.ddt -class CourseSearchFormTests(TestCase): - """ - Tests for publisher 'CourseSearchForm' - """ - - def setUp(self): - super().setUp() - self.organization = OrganizationFactory() - self.organization_extension = OrganizationExtensionFactory() - self.user = UserFactory() - self.user.groups.add(self.organization_extension.group) - self.course = CourseFactory(title='Test course') - assign_perm( - OrganizationExtension.VIEW_COURSE, self.organization_extension.group, self.organization_extension - ) - - def test_no_user(self): - course_form = CourseSearchForm() - course_form.full_clean() - self.assertFalse(course_form.is_valid()) - self.assertEqual(0, course_form.fields['course'].queryset.count()) - - def _check_form(self): - course_form = CourseSearchForm(user=self.user, data={'course': self.course.id}) - course_form.full_clean() - return course_form.is_valid() - - def test_unrelated_course(self): - """ Verify course search doesn't allow courses unrelated to the user. """ - self.assertFalse(self._check_form()) - - def test_with_course_team(self): - """ Verify course search allows courses in the user's organizations. """ - self.course.organizations.add(self.organization_extension.organization) # pylint: disable=no-member - self.assertTrue(self._check_form()) - - def test_with_admin_user(self): - """ Verify course search lets an admin access courses they aren't associated with. """ - self.user.groups.add(Group.objects.get(name=ADMIN_GROUP_NAME)) - self.assertTrue(self._check_form()) - - def test_with_internal_user(self): - """ Verify course search only lets an internal user access courses with a role for them. """ - self.user.groups.add(Group.objects.get(name=INTERNAL_USER_GROUP_NAME)) - - # Confirm that internal users aren't granted blanket access - self.assertFalse(self._check_form()) - - # But it *will* work if we add a role for this user - CourseUserRoleFactory(course=self.course, user=self.user, role=PublisherUserRole.MarketingReviewer) - self.assertTrue(self._check_form()) - - -class SeatFormTests(TestCase): - """ - Tests for Seat Form - """ - def test_negative_price(self): - """ - Verify that form raises an error when price is in -ive format - """ - form_data = {'type': Seat.VERIFIED, 'price': -0.05} - seat_form = SeatForm(data=form_data) - self.assertFalse(seat_form.is_valid()) - self.assertEqual(seat_form.errors, {'price': ['Price must be greater than or equal to 0.01']}) - - def test_type_is_required(self): - """ - Verify that form raises an error when type is not given - """ - seat_form = SeatForm(data={}) - self.assertFalse(seat_form.is_valid()) - self.assertEqual(seat_form.errors, {'type': ['This field is required.']}) - - seat_form_with_type = SeatForm(data={'type': Seat.AUDIT}) - self.assertTrue(seat_form_with_type.is_valid()) diff --git a/course_discovery/apps/publisher/tests/test_models.py b/course_discovery/apps/publisher/tests/test_models.py index 22791355eb..ec8ad76f9f 100644 --- a/course_discovery/apps/publisher/tests/test_models.py +++ b/course_discovery/apps/publisher/tests/test_models.py @@ -1,906 +1,29 @@ -# pylint: disable=no-member -import datetime -import random - -import ddt -import pytest -import responses from django.db import IntegrityError from django.test import TestCase -from django.urls import reverse -from django_fsm import TransitionNotAllowed -from factory.fuzzy import FuzzyDateTime -from guardian.shortcuts import assign_perm -from pytz import UTC -from waffle.testutils import override_switch -from course_discovery.apps.core.tests.factories import PartnerFactory, SiteFactory, UserFactory -from course_discovery.apps.core.tests.helpers import make_image_file -from course_discovery.apps.course_metadata.choices import CourseRunStatus -from course_discovery.apps.course_metadata.publishers import CourseRunMarketingSitePublisher -from course_discovery.apps.course_metadata.tests.factories import CourseFactory as DiscoveryCourseFactory -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory as DiscoveryCourseRunFactory -from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory, PersonFactory -from course_discovery.apps.course_metadata.tests.mixins import MarketingSitePublisherTestMixin -from course_discovery.apps.ietf_language_tags.models import LanguageTag -from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole -from course_discovery.apps.publisher.constants import PUBLISHER_ENABLE_READ_ONLY_FIELDS -from course_discovery.apps.publisher.exceptions import CourseRunEditException -from course_discovery.apps.publisher.mixins import check_course_organization_permission -from course_discovery.apps.publisher.models import ( - Course, CourseUserRole, OrganizationExtension, OrganizationUserRole, Seat -) +from course_discovery.apps.publisher.choices import InternalUserRole +from course_discovery.apps.publisher.models import OrganizationExtension, OrganizationUserRole from course_discovery.apps.publisher.tests import factories -@ddt.ddt -class CourseRunTests(TestCase): - @classmethod - def setUpClass(cls): - super(CourseRunTests, cls).setUpClass() - cls.course_run = factories.CourseRunFactory() - - def test_str(self): - """ Verify casting an instance to a string returns a string containing the course title and start date. """ - self.assertEqual( - str(self.course_run), - '{title}: {date}'.format( - title=self.course_run.course.title, date=self.course_run.start_date_temporary - ) - ) - - def test_post_back_url(self): - self.assertEqual( - self.course_run.post_back_url, - reverse('publisher:publisher_course_runs_edit', kwargs={'pk': self.course_run.id}) - ) - - def test_created_by(self): - """ Verify that property returns created_by. """ - self.assertIsNone(self.course_run.created_by) - - user = UserFactory() - history_object = self.course_run.history.order_by('history_date').first() - history_object.history_user = user - history_object.save() - - assert self.course_run.created_by == user.get_full_name() - - def test_studio_url(self): - assert self.course_run.studio_url is None - - self.course_run.lms_course_id = 'test' - self.course_run.save() - organization = OrganizationFactory() - self.course_run.course.organizations.add(organization) - assert self.course_run.course.partner == organization.partner - - actual = '{url}/course/{id}'.format(url=self.course_run.course.partner.studio_url.strip('/'), - id=self.course_run.lms_course_id) - assert actual == self.course_run.studio_url - - @ddt.data( - (None, False), - ('absent', False), - ('testX/test/1', True), - ) - @ddt.unpack - def test_preview_url(self, course_id, has_preview_url): - person = PersonFactory() - run = DiscoveryCourseRunFactory(key='testX/test/1', staff=[person]) - self.course_run.lms_course_id = course_id - self.assertEqual(self.course_run.preview_url, run.marketing_url if has_preview_url else None) - - def test_studio_schedule_and_details_url(self): - assert self.course_run.studio_schedule_and_details_url is None - - self.course_run.lms_course_id = 'test' - self.course_run.save() - organization = OrganizationFactory() - self.course_run.course.organizations.add(organization) - assert self.course_run.course.partner == organization.partner - - actual = '{url}/settings/details/{id}'.format(url=self.course_run.course.partner.studio_url.strip('/'), - id=self.course_run.lms_course_id) - assert actual == self.course_run.studio_schedule_and_details_url - - def test_has_valid_staff(self): - """ Verify that property returns True if course-run must have a staff member - with bio and image. - """ - self.assertFalse(self.course_run.has_valid_staff) - staff = PersonFactory() - self.course_run.staff.add(staff) - self.assertTrue(self.course_run.has_valid_staff) - - @ddt.data('bio', 'profile_image') - def test_with_in_valid_staff(self, field): - """ Verify that property returns False staff has bio or image is missing.""" - staff = PersonFactory(profile_image=None) - self.course_run.staff.add(staff) - - setattr(staff, field, None) - staff.save() - self.assertFalse(self.course_run.has_valid_staff) - - def test_is_valid_micromasters(self): - """ Verify that property returns bool if both fields have value. """ - self.assertTrue(self.course_run.is_valid_micromasters) - - self.course_run.is_micromasters = True - self.course_run.micromasters_name = 'test' - self.course_run.save() - self.assertTrue(self.course_run.is_valid_micromasters) - - self.course_run.micromasters_name = None - self.course_run.save() - self.assertFalse(self.course_run.is_valid_micromasters) - - def test_is_professional_certificate(self): - """ Verify that property returns bool if both fields have value. """ - self.assertTrue(self.course_run.is_valid_professional_certificate) - - self.course_run.is_professional_certificate = True - self.course_run.professional_certificate_name = 'test' - self.course_run.save() - self.assertTrue(self.course_run.is_valid_professional_certificate) - - self.course_run.professional_certificate_name = None - self.course_run.save() - self.assertFalse(self.course_run.is_valid_professional_certificate) - - def test_is_valid_xseries(self): - """ Verify that property returns bool if both fields have value. """ - self.assertTrue(self.course_run.is_valid_xseries) - - self.course_run.is_xseries = True - self.course_run.xseries_name = 'test' - self.course_run.save() - self.assertTrue(self.course_run.is_valid_xseries) - - self.course_run.xseries_name = None - self.course_run.save() - self.assertFalse(self.course_run.is_valid_xseries) - - def test_has_valid_seats(self): - """ Verify that property returns True if seats are valid. """ - factories.SeatFactory(course_run=self.course_run, type=Seat.AUDIT, price=0) - invalid_seat = factories.SeatFactory(course_run=self.course_run, type=Seat.VERIFIED, price=0) - self.assertFalse(self.course_run.has_valid_seats) - - invalid_seat.price = 200 - invalid_seat.save() - - self.assertTrue(self.course_run.has_valid_seats) - - credit_seat = factories.SeatFactory(course_run=self.course_run, type=Seat.CREDIT, price=0, credit_price=0) - self.assertFalse(self.course_run.has_valid_seats) - - credit_seat.price = 200 - credit_seat.credit_price = 200 - credit_seat.save() - - self.assertTrue(self.course_run.has_valid_seats) - - def test_get_absolute_url(self): - course_run = factories.CourseRunFactory() - expected = reverse('publisher:publisher_course_run_detail', kwargs={'pk': course_run.id}) - assert course_run.get_absolute_url() == expected - - def test_discovery_counterpart_success(self): - """ - Verify that CourseRun discovery_counterpart property returns - corresponding Discovery CourseRun object. - """ - pacing_type_test_value = 'test_pacing_type_value' - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - discovery_course_run = self.create_discovery_course_run_with_metadata( - discovery_course, - {'pacing_type': pacing_type_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - # make sure Publisher course run key and Course Metadata course run key match - course_run.lms_course_id = discovery_course_run.key - - assert course_run.discovery_counterpart == discovery_course_run - - def test_discovery_counterpart_failure_without_course_run(self): - """ - Verify that CourseRun discovery_counterpart property returns None if the - discovery_counterpart course has no course run associated with it. - """ - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - - assert course_run.discovery_counterpart is None - - def test_discovery_counterpart_failure_without_course(self): - """ - Verify that CourseRun discovery_counterpart property returns None if the - discovery_counterpart does not exist. - """ - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - self.add_organization_to_course(course_run.course, organization) - - assert course_run.discovery_counterpart is None - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_pacing_type_temporary_without_switch(self): - """ - Verify that pacing_type_temporary property returns the value of the pacing_type field - when waffle switch is disabled. - """ - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - assert course_run.pacing_type_temporary == course_run.pacing_type - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_pacing_type_temporary_success_with_switch(self): - """ - Verify that pacing_type_temporary property returns the value of the corresponding Discovery - course run's pacing type when waffle switch is enabled. - """ - pacing_type_test_value = 'test_pacing_type_value' - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - discovery_course_run = self.create_discovery_course_run_with_metadata( - discovery_course, - {'pacing_type': pacing_type_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - # make sure Publisher course run key and Course Metadata course run key match - course_run.lms_course_id = discovery_course_run.key - - assert course_run.pacing_type_temporary == course_run.discovery_counterpart.pacing_type - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_pacing_type_temporary_failure_with_switch(self): - """ - Verify that pacing_type_temporary property returns the default value for course run pacing - type when corresponding Discovery course run does not exist and waffle switch is enabled. - """ - pacing_type_test_value = None - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - self.create_discovery_course_run_with_metadata( - discovery_course, - {'pacing_type': pacing_type_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - - assert course_run.pacing_type_temporary == 'instructor_paced' - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_pacing_type_temporary_setter(self): - """ - Verify that modifying the pacing_type_temporary property also modifies the pacing_type field - when waffle switch is disabled. - """ - pacing_type_test_value = 'test_pacing_type_value' - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - course_run.pacing_type_temporary = pacing_type_test_value - - assert course_run.pacing_type_temporary == course_run.pacing_type - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_pacing_type_temporary_setter_with_switch(self): - """ - Verify that modifying the pacing_type_temporary property throws CourseRunEditException - when waffle switch is enabled. - """ - pacing_type_test_value = 'test_pacing_type_value' - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - with self.assertRaises(CourseRunEditException): - course_run.pacing_type_temporary = pacing_type_test_value - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_pacing_type_temporary_display(self): - """ - Verify that pacing_type_temporary display function returns the - value of the pacing_type field display function when waffle switch is disabled. - """ - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - assert course_run.get_pacing_type_temporary_display() == course_run.get_pacing_type_display() - - def test_start_date_temporary(self): - """ Verify that start_date_temporary property returns the value of the start field. """ - course_run = factories.CourseRunFactory() - - assert course_run.start_date_temporary == course_run.start - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_pacing_type_temporary_display_success_with_switch(self): - """ - Verify that pacing_type_temporary display function returns the - value of the corresponding Discovery course run's pacing_type display function when waffle switch is enabled. - """ - pacing_type_test_value = 'test_pacing_type_value' - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - discovery_course_run = self.create_discovery_course_run_with_metadata( - discovery_course, - {'pacing_type': pacing_type_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - # make sure Publisher course run key and Course Metadata course run key match - course_run.lms_course_id = discovery_course_run.key - - assert course_run.get_pacing_type_temporary_display() == discovery_course_run.get_pacing_type_display() - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_pacing_type_temporary_display_failure_with_switch(self): - """ - Verify that pacing_type_temporary display function returns the - value of the default course runs's pacing_type display function when corresponding Discovery - course run does not exist and waffle switch is enabled. - """ - pacing_type_test_value = None - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - self.create_discovery_course_run_with_metadata( - discovery_course, - {'pacing_type': pacing_type_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - - assert course_run.get_pacing_type_temporary_display() == 'Instructor-paced' - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_start_date_temporary_without_switch(self): - """ - Verify that start_date_temporary property returns the value of the start field - when waffle switch is disabled. - """ - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - assert course_run.start_date_temporary == course_run.start - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_start_date_temporary_success_with_switch(self): - """ - Verify that start_date_temporary property returns the value of the corresponding Discovery - course run's start when waffle switch is enabled. - """ - start_date_test_value = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - discovery_course_run = self.create_discovery_course_run_with_metadata( - discovery_course, - {'start': start_date_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - # make sure Publisher course run key and Course Metadata course run key match - course_run.lms_course_id = discovery_course_run.key - - assert course_run.start_date_temporary == course_run.discovery_counterpart.start - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_start_date_temporary_failure_with_switch(self): - """ - Verify that start_date_temporary property returns the initial value for Publisher course run start - when corresponding Discovery course run does not exist and waffle switch is enabled. - """ - start_date_test_value = None - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - self.create_discovery_course_run_with_metadata( - discovery_course, - {'start': start_date_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - - assert course_run.start_date_temporary == course_run.start - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_start_date_temporary_setter(self): - """ - Verify that modifying the start_date_temporary property also modifies the start field - when waffle switch is disabled. - """ - start_date_test_value = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - course_run.start_date_temporary = start_date_test_value - - assert course_run.start_date_temporary == course_run.start - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_start_date_temporary_setter_with_switch(self): - """ - Verify that modifying the start_date_temporary property throws CourseRunEditException - when waffle switch is enabled. - """ - start_date_test_value = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - with self.assertRaises(CourseRunEditException): - course_run.start_date_temporary = start_date_test_value - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_end_date_temporary_without_switch(self): - """ - Verify that end_date_temporary property returns the value of the end field - when waffle switch is disabled. - """ - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - assert course_run.end_date_temporary == course_run.end - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_end_date_temporary_success_with_switch(self): - """ - Verify that end_date_temporary property returns the value of the corresponding Discovery - course run's end when waffle switch is enabled. - """ - end_date_test_value = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - discovery_course_run = self.create_discovery_course_run_with_metadata( - discovery_course, - {'end': end_date_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - # make sure Publisher course run key and Course Metadata course run key match - course_run.lms_course_id = discovery_course_run.key - - assert course_run.end_date_temporary == course_run.discovery_counterpart.end - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_end_date_temporary_failure_with_switch(self): - """ - Verify that end_date_temporary property returns the initial value for Publisher course run end - when corresponding Discovery course run does not exist and waffle switch is enabled. - """ - end_date_test_value = None - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - organization = OrganizationFactory() - - discovery_course = self.create_discovery_course_with_partner(organization.partner) - self.create_discovery_course_run_with_metadata( - discovery_course, - {'end': end_date_test_value} - ) - - self.add_organization_to_course(course_run.course, organization) - - # make sure Publisher course key and Course Metadata course key match - course_run.course.key = discovery_course.key - - assert course_run.end_date_temporary == course_run.end - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=False) - def test_end_date_temporary_setter(self): - """ - Verify that modifying the end_date_temporary property also modifies the end field - when waffle switch is disabled. - """ - end_date_test_value = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - course_run.end_date_temporary = end_date_test_value - - assert course_run.end_date_temporary == course_run.end - - @override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) - def test_end_date_temporary_setter_with_switch(self): - """ - Verify that modifying the end_date_temporary property throws CourseRunEditException - when waffle switch is enabled. - """ - end_date_test_value = FuzzyDateTime(datetime.datetime(2014, 1, 1, tzinfo=UTC)) - - # create a fresh course run object to avoid issues with caching of discovery_counterpart property - course_run = factories.CourseRunFactory() - - with self.assertRaises(CourseRunEditException): - course_run.end_date_temporary = end_date_test_value - - @staticmethod - def create_discovery_course_with_partner(partner): - """ - Creates and returns a Discovery Course object with a partner field. - - Arguments: - partner: a Partner object to assign to the created Discovery Course.partner field - - Returns: - a Discovery Course object - """ - discovery_course = DiscoveryCourseFactory(partner=partner) - discovery_course.save() - return discovery_course - - @staticmethod - def create_discovery_course_run_with_metadata(course, metadata): - """ - Creates and returns a Discovery CourseRun object with course and fields specified in metadata dictionary. - - Arguments: - course: a Course object to assign to the created Discovery CourseRun.course field - metadata: a dictionary where the keys are field names and values are field values - - For example, metadata could be {'pacing_type': 'Instructor-paced'}. - - Returns: - a Discovery CourseRun object - """ - discovery_course_run = DiscoveryCourseRunFactory(course=course, **metadata) - discovery_course_run.save() - return discovery_course_run - - @staticmethod - def add_organization_to_course(course, organization): - """ - Add an organization to a Course's organization field - - Arguments: - course: a Course object to which to assign an organization the Course.organizations field - organization: an Organization object to assign to the Course.organizations field - """ - course.organizations.add(organization) - course.save() - - -class CourseTests(TestCase): - def setUp(self): - super(CourseTests, self).setUp() - self.org_extension_1 = factories.OrganizationExtensionFactory() - self.org_extension_2 = factories.OrganizationExtensionFactory() - - self.course = factories.CourseFactory(organizations=[self.org_extension_1.organization]) - self.course2 = factories.CourseFactory(organizations=[self.org_extension_2.organization]) - - self.user1 = UserFactory() - self.user2 = UserFactory() - self.user3 = UserFactory() - - self.user1.groups.add(self.org_extension_1.group) - self.user2.groups.add(self.org_extension_2.group) - - # add user in course-user-role table - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.ProjectCoordinator, user=self.user1 - ) - - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.MarketingReviewer, user=self.user2 - ) - - factories.CourseUserRoleFactory( - course=self.course, role=PublisherUserRole.Publisher, user=self.user3 - ) - - def test_uses_entitlements(self): - """ Verify that uses_entitlements is True when version is set to ENTITLEMENT_VERSION, and False otherwise. """ - self.course.version = Course.SEAT_VERSION - assert not self.course.uses_entitlements - - self.course.version = Course.ENTITLEMENT_VERSION - assert self.course.uses_entitlements - - def test_str(self): - """ Verify casting an instance to a string returns a string containing the course title. """ - self.assertEqual(str(self.course), self.course.title) - - def test_post_back_url(self): - self.assertEqual( - self.course.post_back_url, - reverse('publisher:publisher_courses_edit', kwargs={'pk': self.course.id}) - ) - - def test_assign_permission_organization_extension(self): - """ Verify that permission can be assigned using the organization extension. """ - self.assert_user_cannot_view_course(self.user1, self.course, OrganizationExtension.VIEW_COURSE) - self.assert_user_cannot_view_course(self.user2, self.course2, OrganizationExtension.VIEW_COURSE) - - self.course.organizations.add(self.org_extension_1.organization) - self.course2.organizations.add(self.org_extension_2.organization) - - assign_perm(OrganizationExtension.VIEW_COURSE, self.org_extension_1.group, self.org_extension_1) - assign_perm(OrganizationExtension.VIEW_COURSE, self.org_extension_2.group, self.org_extension_2) - - self.assert_user_can_view_course(self.user1, self.course, OrganizationExtension.VIEW_COURSE) - self.assert_user_can_view_course(self.user2, self.course2, OrganizationExtension.VIEW_COURSE) - - self.assert_user_cannot_view_course(self.user1, self.course2, OrganizationExtension.VIEW_COURSE) - self.assert_user_cannot_view_course(self.user2, self.course, OrganizationExtension.VIEW_COURSE) - - self.assertEqual(self.course.organizations.first().organization_extension.group, self.org_extension_1.group) - self.assertEqual(self.course2.organizations.first().organization_extension.group, self.org_extension_2.group) - - def assert_user_cannot_view_course(self, user, course, permission): - """ Asserts the user can NOT view the course. """ - self.assertFalse(check_course_organization_permission(user, course, permission)) - - def assert_user_can_view_course(self, user, course, permission): - """ Asserts the user can view the course. """ - self.assertTrue(check_course_organization_permission(user, course, permission)) - - def test_get_course_users_emails(self): - """ Verify the method returns the email addresses of users who are - permitted to access the course AND have not disabled email notifications. - """ - self.assertListEqual( - self.course.get_course_users_emails(), - [self.user1.email, self.user2.email, self.user3.email] - ) - - # The email addresses of users who have disabled email notifications should NOT be returned. - factories.UserAttributeFactory(user=self.user1, enable_email_notification=False) - self.assertListEqual(self.course.get_course_users_emails(), [self.user2.email, self.user3.email]) - - def test_keywords_data(self): - """ Verify that the property returns the keywords as comma separated string. """ - self.assertFalse(self.course.keywords_data) - self.course.keywords.add('abc') - self.assertEqual(self.course.keywords_data, 'abc') - - self.course.keywords.add('def') - self.assertIn('abc', self.course.keywords_data) - self.assertIn('def', self.course.keywords_data) - - def test_get_user_role(self): - """ - Verify that method 'get_user_role' returns the correct role if it exists - """ - self.assertEqual(self.course.get_user_role(user=self.user1), PublisherUserRole.ProjectCoordinator) - self.assertEqual(self.course2.get_user_role(user=self.user1), None) - - def test_project_coordinator(self): - """ Verify that the project_coordinator property returns user if exist. """ - self.assertIsNone(self.course2.project_coordinator) - - factories.CourseUserRoleFactory( - course=self.course2, user=self.user1, role=PublisherUserRole.ProjectCoordinator - ) - - self.assertEqual(self.user1, self.course2.project_coordinator) - - def test_assign_roles(self): - """ - Verify that method `assign_organization_role' assign course-user-roles except - CourseTeam role for the organization against a course. - """ - self.assertFalse(self.course2.course_user_roles.all()) - - # create default roles for organization - factories.OrganizationUserRoleFactory( - role=PublisherUserRole.ProjectCoordinator, organization=self.org_extension_2.organization - ) - factories.OrganizationUserRoleFactory( - role=PublisherUserRole.MarketingReviewer, organization=self.org_extension_2.organization - ) - - factories.OrganizationUserRoleFactory( - role=PublisherUserRole.CourseTeam, organization=self.org_extension_2.organization - ) - - self.course2.assign_organization_role(self.org_extension_2.organization) - self.assertEqual(len(self.course2.course_user_roles.all()), 2) - - self.assertNotIn(PublisherUserRole.CourseTeam, self.course2.course_user_roles.all()) - - def test_assign_roles_without_default_roles(self): - """ - Verify that method `assign_organization_role' works fine even if no - default roles exists. - """ - self.course2.assign_organization_role(self.org_extension_2.organization) - self.assertFalse(self.course2.course_user_roles.all()) - - def test_course_runs(self): - """ Verify that property returns queryset of course runs. """ - self.assertEqual(self.course.course_runs.count(), 0) - - factories.CourseRunFactory(course=self.course) - - self.assertEqual(self.course.course_runs.count(), 1) - - def test_course_team_admin(self): - """ Verify that the course_team_admin property returns user if exist. """ - self.assertIsNone(self.course2.course_team_admin) - - factories.CourseUserRoleFactory( - course=self.course2, user=self.user1, role=PublisherUserRole.CourseTeam - ) - - self.assertEqual(self.user1, self.course2.course_team_admin) - - def test_partner(self): - """ Verify that the partner property returns organization partner if exist. """ - self.assertEqual(self.course.partner, self.org_extension_1.organization.partner) - - def test_marketing_reviewer(self): - """ Verify that the marketing_reviewer property returns user if exist. """ - self.assertIsNone(self.course2.marketing_reviewer) - - factories.CourseUserRoleFactory( - course=self.course2, user=self.user1, role=PublisherUserRole.MarketingReviewer - ) - - self.assertEqual(self.user1, self.course2.marketing_reviewer) - - def test_publisher(self): - """ Verify that the publisher property returns user if exist. """ - self.assertIsNone(self.course2.publisher) - - factories.CourseUserRoleFactory( - course=self.course2, user=self.user1, role=PublisherUserRole.Publisher - ) - - self.assertEqual(self.user1, self.course2.publisher) - - def test_short_description_override(self): - """ Verify that the property returns the short_description. """ - self.assertEqual(self.course.short_description, self.course.course_short_description) - - course_run = factories.CourseRunFactory(course=self.course) - factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published) - self.assertEqual(self.course.course_short_description, course_run.short_description_override) - - def test_full_description_override(self): - """ Verify that the property returns the full_description. """ - self.assertEqual(self.course.full_description, self.course.course_full_description) - - course_run = factories.CourseRunFactory(course=self.course) - - factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published) - self.assertEqual(self.course.course_full_description, course_run.full_description_override) - - def test_title_override(self): - """ Verify that the property returns the title. """ - self.assertEqual(self.course.title, self.course.course_title) - - course_run = factories.CourseRunFactory(course=self.course) - factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published) - self.assertEqual(self.course.course_title, course_run.title_override) - - -@pytest.mark.django_db -class TestSeatModel: - def test_str(self): - seat = factories.SeatFactory() - assert str(seat) == '{course}: {type}'.format(course=seat.course_run.course.title, type=seat.type) - - @pytest.mark.parametrize( - 'seat_type', [choice[0] for choice in Seat.SEAT_TYPE_CHOICES if choice[0] != Seat.VERIFIED]) - def test_calculated_upgrade_deadline_with_nonverified_seat(self, seat_type): - seat = factories.SeatFactory(type=seat_type) - assert seat.calculated_upgrade_deadline is None - - def test_calculated_upgrade_deadline_with_verified_seat(self, settings): - settings.PUBLISHER_UPGRADE_DEADLINE_DAYS = random.randint(1, 21) - now = datetime.datetime.utcnow() - seat = factories.SeatFactory(type=Seat.VERIFIED, upgrade_deadline=None, course_run__end=now) - expected = now - datetime.timedelta(days=settings.PUBLISHER_UPGRADE_DEADLINE_DAYS) - expected = expected.replace(hour=23, minute=59, second=59, microsecond=99999) - assert seat.calculated_upgrade_deadline == expected - - seat = factories.SeatFactory(type=Seat.VERIFIED) - assert seat.calculated_upgrade_deadline is not None - assert seat.calculated_upgrade_deadline == seat.upgrade_deadline - - class UserAttributeTests(TestCase): - """ Tests for the publisher `UserAttribute` model. """ - - def setUp(self): - super(UserAttributeTests, self).setUp() - self.user_attr = factories.UserAttributeFactory() - def test_str(self): """ Verify casting an instance to a string returns a string containing the user name and current enable status. """ + user_attr = factories.UserAttributeFactory() self.assertEqual( - str(self.user_attr), + str(user_attr), '{user}: {enable_email_notification}'.format( - user=self.user_attr.user, - enable_email_notification=self.user_attr.enable_email_notification + user=user_attr.user, + enable_email_notification=user_attr.enable_email_notification ) ) class OrganizationUserRoleTests(TestCase): - """Tests of the OrganizationUserRole model.""" - def setUp(self): - super(OrganizationUserRoleTests, self).setUp() - self.org_user_role = factories.OrganizationUserRoleFactory(role=PublisherUserRole.ProjectCoordinator) + super().setUp() + self.org_user_role = factories.OrganizationUserRoleFactory(role=InternalUserRole.ProjectCoordinator) def test_str(self): """Verify that a OrganizationUserRole is properly converted to a str.""" @@ -922,62 +45,9 @@ def test_unique_constraint(self): ) -class CourseUserRoleTests(TestCase): - """Tests of the CourseUserRole model.""" - - def setUp(self): - super(CourseUserRoleTests, self).setUp() - self.course_user_role = factories.CourseUserRoleFactory(role=PublisherUserRole.ProjectCoordinator) - self.course = factories.CourseFactory() - self.user = UserFactory() - self.marketing_reviewer_role = PublisherUserRole.MarketingReviewer - - def test_str(self): - """Verify that a CourseUserRole is properly converted to a str.""" - expected_str = '{course}: {user}: {role}'.format( - course=self.course_user_role.course, user=self.course_user_role.user, role=self.course_user_role.role - ) - self.assertEqual(str(self.course_user_role), expected_str) - - def test_unique_constraint(self): - """ Verify a user cannot have multiple rows for the same course-role combination.""" - with self.assertRaises(IntegrityError): - CourseUserRole.objects.create( - course=self.course_user_role.course, user=self.course_user_role.user, role=self.course_user_role.role - ) - - def test_add_course_roles(self): - """ - Verify that method `add_course_roles` created the course user role. - """ - course_role, created = CourseUserRole.add_course_roles( - self.course, self.marketing_reviewer_role, self.user - ) - self.assertTrue(created) - self.assertEqual(course_role.course, self.course) - self.assertEqual(course_role.user, self.user) - self.assertEqual(course_role.role, self.marketing_reviewer_role) - - def test_add_course_roles_with_existing_record(self): - """ - Verify that method `add_course_roles` does not create the duplicate - course user role. - """ - __, created = CourseUserRole.add_course_roles( - self.course, self.marketing_reviewer_role, self.user - ) - self.assertTrue(created) - __, created = CourseUserRole.add_course_roles( - self.course, self.marketing_reviewer_role, self.user - ) - self.assertFalse(created) - - class GroupOrganizationTests(TestCase): - """Tests of the GroupOrganization model.""" - def setUp(self): - super(GroupOrganizationTests, self).setUp() + super().setUp() self.organization_extension = factories.OrganizationExtensionFactory() self.group_2 = factories.GroupFactory() @@ -996,348 +66,3 @@ def test_one_to_one_constraint(self): group=self.group_2, organization=self.organization_extension.organization ) - - -@ddt.ddt -class CourseStateTests(TestCase): - """ Tests for the publisher `CourseState` model. """ - - @classmethod - def setUpClass(cls): - super(CourseStateTests, cls).setUpClass() - cls.course_state = factories.CourseStateFactory(name=CourseStateChoices.Draft) - cls.user = UserFactory() - factories.CourseUserRoleFactory( - course=cls.course_state.course, role=PublisherUserRole.CourseTeam, user=cls.user - ) - - def setUp(self): - super(CourseStateTests, self).setUp() - - self.site = SiteFactory() - self.partner = PartnerFactory(site=self.site) - self.course = self.course_state.course - self.course.image = make_image_file('test_banner.jpg') - self.course.save() - - self.course.organizations.add(factories.OrganizationExtensionFactory().organization) - - def test_str(self): - """ - Verify casting an instance to a string returns a string containing the current state display name. - """ - self.assertEqual(str(self.course_state), self.course_state.get_name_display()) - - @ddt.data( - CourseStateChoices.Review, - CourseStateChoices.Approved, - CourseStateChoices.Draft - ) - def test_change_state(self, state): - """ - Verify that we can change course state according to workflow. - """ - self.assertNotEqual(self.course_state.name, state) - - self.course_state.change_state(state=state, user=self.user, site=self.site) - - self.assertEqual(self.course_state.name, state) - - def test_review_with_condition_failed(self): - """ - Verify that user cannot change state to `Review` if `can_send_for_review` failed. - """ - self.course.image = None - - self.assertEqual(self.course_state.name, CourseStateChoices.Draft) - - with self.assertRaises(TransitionNotAllowed): - self.course_state.change_state(state=CourseStateChoices.Review, user=self.user, site=self.site) - - def test_can_send_for_review(self): - """ - Verify `can_send_for_review` return False if minimum required fields are empty or None. - """ - self.assertTrue(self.course_state.can_send_for_review()) - - self.course.image = None - - self.assertFalse(self.course_state.can_send_for_review()) - - @ddt.data( - PublisherUserRole.MarketingReviewer, - PublisherUserRole.CourseTeam, - ) - def test_change_owner_role(self, role): - """ - Verify that method change_owner_role updates the role. - """ - self.course_state.change_owner_role(role) - self.assertEqual(self.course_state.owner_role, role) - - def _change_state_and_owner(self, course_state): - """ - Change course state to review and ownership to marketing. - """ - course_state.name = CourseStateChoices.Review - course_state.change_owner_role(PublisherUserRole.MarketingReviewer) - - def test_course_team_status(self): - course_state = factories.CourseStateFactory(owner_role=PublisherUserRole.CourseTeam) - assert course_state.course_team_status == 'Draft' - - self._change_state_and_owner(course_state) - assert course_state.course_team_status == 'Submitted for Marketing Review' - - course_state.marketing_reviewed = True - course_state.change_owner_role(PublisherUserRole.CourseTeam) - assert course_state.course_team_status == 'Awaiting Course Team Review' - - course_state.approved() - course_state.save() - assert course_state.course_team_status == 'Approved by Course Team' - - def test_internal_user_status(self): - course_state = factories.CourseStateFactory(owner_role=PublisherUserRole.CourseTeam) - assert course_state.internal_user_status == 'N/A' - - self._change_state_and_owner(course_state) - assert course_state.internal_user_status == 'Awaiting Marketing Review' - - course_state.marketing_reviewed = True - course_state.change_owner_role(PublisherUserRole.CourseTeam) - assert course_state.internal_user_status == 'Approved by Marketing' - - -@ddt.ddt -class CourseRunStateTests(MarketingSitePublisherTestMixin): - """ Tests for the publisher `CourseRunState` model. """ - - @classmethod - def setUpClass(cls): - super(CourseRunStateTests, cls).setUpClass() - cls.seat = factories.SeatFactory(type=Seat.VERIFIED, price=100) - cls.course_run_state = factories.CourseRunStateFactory( - course_run=cls.seat.course_run, name=CourseRunStateChoices.Draft - ) - cls.course_run = cls.course_run_state.course_run - cls.course = cls.course_run.course - cls.user = UserFactory() - - factories.CourseStateFactory( - name=CourseStateChoices.Approved, course=cls.course - ) - factories.CourseUserRoleFactory( - course=cls.course_run.course, role=PublisherUserRole.CourseTeam, user=cls.user - ) - factories.CourseUserRoleFactory( - course=cls.course_run.course, role=PublisherUserRole.MarketingReviewer, user=UserFactory() - ) - - def setUp(self): - super(CourseRunStateTests, self).setUp() - - language_tag = LanguageTag(code='te-st', name='Test Language') - language_tag.save() - - self.site = SiteFactory() - self.partner = PartnerFactory(site=self.site) - self.course_run.transcript_languages.add(language_tag) - self.course_run.language = language_tag - self.course_run.is_micromasters = True - self.course_run.micromasters_name = 'test' - self.course_run.lms_course_id = 'course-v1:edX+DemoX+Demo_Course' - self.course_run.save() - self.course.course_state.name = CourseStateChoices.Approved - self.course.save() - self.course_run.staff.add(PersonFactory()) - self.course_run_state.preview_accepted = False - self.course_run_state.save() - self.assertTrue(self.course_run_state.can_send_for_review()) - - self.publisher = CourseRunMarketingSitePublisher(self.partner) - self.api_root = self.publisher.client.api_url - self.username = self.publisher.client.username - - def test_str(self): - """ - Verify casting an instance to a string returns a string containing the current state display name. - """ - self.assertEqual(str(self.course_run_state), self.course_run_state.get_name_display()) - - @ddt.data( - CourseRunStateChoices.Review, - CourseRunStateChoices.Approved, - CourseRunStateChoices.Published, - CourseRunStateChoices.Draft - ) - def test_change_state(self, state): - """ - Verify that we can change course-run state according to workflow. - """ - self.assertNotEqual(self.course_run_state.name, state) - self.course_run_state.change_state(state=state, user=self.user, site=self.site) - self.assertEqual(self.course_run_state.name, state) - - @responses.activate - def test_published(self): - person = PersonFactory() - primary = DiscoveryCourseRunFactory(key=self.course_run.lms_course_id, staff=[person], - status=CourseRunStatus.Unpublished, announcement=None) - second = DiscoveryCourseRunFactory(course=primary.course, status=CourseRunStatus.Published, end=None) - third = DiscoveryCourseRunFactory(course=primary.course, status=CourseRunStatus.Published, - end=datetime.datetime(2010, 1, 1, tzinfo=UTC)) - - user = UserFactory() - self.mock_api_client() - - lookup_value = getattr(primary, self.publisher.unique_field) - self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value) - lookup_value = getattr(third, self.publisher.unique_field) - self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value) - - self.mock_get_redirect_form() - self.mock_add_redirect() - - self.course_run.course_run_state.name = CourseRunStateChoices.Approved - self.course_run.course_run_state.change_state(CourseRunStateChoices.Published, user, self.site) - primary.refresh_from_db() - second.refresh_from_db() - third.refresh_from_db() - - assert responses.calls[-1].request.url.endswith('/admin/config/search/redirect/add') - self.assertIsNotNone(primary.announcement) - self.assertEqual(primary.status, CourseRunStatus.Published) - self.assertEqual(second.status, CourseRunStatus.Published) # doesn't change end=None runs - self.assertEqual(third.status, CourseRunStatus.Unpublished) # does change archived runs - - def test_with_invalid_parent_course_state(self): - """ - Verify that method return False if parent course is not approved. - """ - self.course.course_state.name = CourseStateChoices.Review - self.course.save() - self.assertFalse(self.course_run_state.can_send_for_review()) - - def test_can_send_for_review_with_invalid_program_type(self): - """ - Verify that method return False if program type is invalid. - """ - self.course_run.micromasters_name = None - self.course_run.save() - self.assertFalse(self.course_run_state.can_send_for_review()) - - def test_can_send_for_review_with_invalid_seat(self): - """ - Verify that method return False if data is missing. - """ - # seat type is verified but its price is 0 - self.seat.price = 0 - self.seat.save() - self.assertFalse(self.course_run_state.can_send_for_review()) - - def test_can_send_for_review_with_no_seat(self): - """ - Verify that method return False if data is missing. - """ - self.course_run.seats.all().first().delete() - self.assertFalse(self.course_run_state.can_send_for_review()) - - def test_can_send_for_review_without_language(self): - """ - Verify that method return False if data is missing. - """ - self.course_run.language = None - self.course_run.save() - self.assertFalse(self.course_run_state.can_send_for_review()) - - def test_can_send_for_review_without_transcript_language(self): - """ - Verify that method return False if data is missing. - """ - self.course_run.transcript_languages = [] - self.course_run.save() - self.assertFalse(self.course_run_state.can_send_for_review()) - - def test_preview_accepted(self): - """ - Verify that property is_preview_accepted return Boolean. - """ - self.assertFalse(self.course_run_state.is_preview_accepted) - self.course_run_state.preview_accepted = True - self.course_run_state.save() - self.assertTrue(self.course_run_state.is_preview_accepted) - - @ddt.data( - PublisherUserRole.Publisher, - PublisherUserRole.CourseTeam, - ) - def test_change_owner_role(self, role): - """ - Verify that method change_owner_role updates the role. - """ - self.course_run_state.change_owner_role(role) - self.assertEqual(self.course_run_state.owner_role, role) - - def test_is_approved(self): - """ - Verify that method return is_approved status. - """ - self.assertFalse(self.course_run_state.is_approved) - self.course_run_state.name = CourseRunStateChoices.Approved - self.course_run_state.save() - self.assertTrue(self.course_run_state.is_approved) - - def test_is_ready_to_publish(self): - """ - Verify that method return is_ready_to_publish status. - """ - self.assertFalse(self.course_run_state.is_ready_to_publish) - self.course_run_state.name = CourseRunStateChoices.Approved - self.course_run_state.preview_accepted = True - self.course_run_state.save() - self.assertTrue(self.course_run_state.is_ready_to_publish) - - def test_is_published(self): - """ - Verify that method return is_published status. - """ - self.assertFalse(self.course_run_state.is_published) - self.course_run_state.name = CourseRunStateChoices.Published - self.course_run_state.save() - self.assertTrue(self.course_run_state.is_published) - - def test_preview_status_for_publisher(self): - """ - Verify that the method returns the correct status - """ - self.course_run_state.owner_role = PublisherUserRole.CourseTeam - self.course_run_state.save() - self.assertEqual(self.course_run_state.preview_status_for_publisher, 'Submitted for review') - - self.course_run_state.owner_role = PublisherUserRole.Publisher - self.course_run_state.preview_accepted = True - self.course_run_state.save() - self.assertEqual(self.course_run_state.preview_status_for_publisher, 'Preview Accepted') - - self.course_run_state.preview_accepted = False - self.course_run_state.save() - self.assertEqual(self.course_run_state.preview_status_for_publisher, 'Preview Declined') - - def test_is_draft(self): - """ - Verify that method return is_draft status. - """ - self.assertFalse(self.course_run_state.is_draft) - self.course_run_state.name = CourseRunStateChoices.Draft - self.course_run_state.save() - self.assertTrue(self.course_run_state.is_draft) - - def test_is_in_review(self): - """ - Verify that method return is_in_review status. - """ - self.assertFalse(self.course_run_state.is_in_review) - self.course_run_state.name = CourseRunStateChoices.Review - self.course_run_state.save() - self.assertTrue(self.course_run_state.is_in_review) diff --git a/course_discovery/apps/publisher/tests/test_signals.py b/course_discovery/apps/publisher/tests/test_signals.py index 05164a4690..d06bf4487d 100644 --- a/course_discovery/apps/publisher/tests/test_signals.py +++ b/course_discovery/apps/publisher/tests/test_signals.py @@ -1,254 +1,10 @@ -import datetime -import json - -import mock -import pytest -import responses from django.contrib.auth.models import Permission -from freezegun import freeze_time -from slumber.exceptions import HttpServerError -from waffle.testutils import override_switch - -from course_discovery.apps.core.models import Partner -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory as DiscoveryCourseRunFactory -from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory -from course_discovery.apps.publisher.studio_api_utils import StudioAPI -from course_discovery.apps.publisher.tests.factories import CourseRunFactory, OrganizationExtensionFactory - - -@freeze_time('2017-01-01T00:00:00Z') -@pytest.mark.django_db -class TestCreateCourseRunInStudio: - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio_without_partner(self): - with mock.patch('course_discovery.apps.publisher.signals.logger.error') as mock_logger: - publisher_course_run = CourseRunFactory(course__organizations=[]) - - assert publisher_course_run.course.partner is None - mock_logger.assert_called_with( - 'Failed to publish course run [%d] to Studio. Related course [%d] has no associated Partner.', - publisher_course_run.id, - publisher_course_run.course.id - ) - - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio_with_organization_opt_out(self): - with mock.patch('course_discovery.apps.publisher.signals.logger.warning') as mock_logger: - course_organization = OrganizationFactory() - OrganizationExtensionFactory( - organization=course_organization, - auto_create_in_studio=False - ) - publisher_course_run = CourseRunFactory(course__organizations=[course_organization]) - - mock_logger.assert_called_with( - ('Course run [%d] will not be automatically created in studio.' - 'Organization [%s] has opted out of this feature.'), - publisher_course_run.course.id, - course_organization.key, - ) - - @responses.activate - @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio(self, mock_access_token): # pylint: disable=unused-argument - organization = OrganizationFactory() - partner = organization.partner - start = datetime.datetime.utcnow() - course_run_key = 'course-v1:TestX+Testing101x+1T2017' - - body = {'id': course_run_key} - studio_url_root = partner.studio_url.strip('/') - url = '{}/api/v1/course_runs/'.format(studio_url_root) - responses.add(responses.POST, url, json=body, status=200) - - body = {'card_image': 'https://example.com/image.jpg'} - url = '{root}/api/v1/course_runs/{course_run_key}/images/'.format( - root=studio_url_root, - course_run_key=course_run_key - ) - responses.add(responses.POST, url, json=body, status=200) - with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger: - publisher_course_run = CourseRunFactory( - start=start, - lms_course_id=None, - course__organizations=[organization] - ) - - # We refresh because the signal should update the instance with the course run key from Studio - publisher_course_run.refresh_from_db() - - assert len(responses.calls) == 2 - assert publisher_course_run.lms_course_id == course_run_key - mock_logger.assert_called_with( - 'Organization [%s] does not have an associated OrganizationExtension', - organization.key, - ) - - @responses.activate - @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio_as_rerun(self, mock_access_token): # pylint: disable=unused-argument - number = 'TestX' - organization = OrganizationFactory() - partner = organization.partner - course_key = '{org}+{number}'.format(org=organization.key, number=number) - discovery_course_run = DiscoveryCourseRunFactory(course__partner=partner, course__key=course_key) - start = datetime.datetime.utcnow() - course_run_key = 'course-v1:TestX+Testing101x+1T2017' - - body = {'id': course_run_key} - studio_url_root = partner.studio_url.strip('/') - url = '{root}/api/v1/course_runs/{course_run_key}/rerun/'.format( - root=studio_url_root, - course_run_key=discovery_course_run.key - ) - responses.add(responses.POST, url, json=body, status=200) - - body = {'card_image': 'https://example.com/image.jpg'} - url = '{root}/api/v1/course_runs/{course_run_key}/images/'.format( - root=studio_url_root, - course_run_key=course_run_key - ) - responses.add(responses.POST, url, json=body, status=200) - - publisher_course_run = CourseRunFactory( - start=start, - lms_course_id=None, - course__organizations=[organization], - course__number=number - ) - - # We refresh because the signal should update the instance with the course run key from Studio - publisher_course_run.refresh_from_db() - - assert len(responses.calls) == 2 - assert publisher_course_run.lms_course_id == course_run_key - - @responses.activate - @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - @mock.patch.object(StudioAPI, 'update_course_run_image_in_studio', side_effect=Exception) - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio_with_image_failure(self, __, ___): # pylint: disable=unused-argument - organization = OrganizationFactory() - partner = organization.partner - start = datetime.datetime.utcnow() - course_run_key = 'course-v1:TestX+Testing101x+1T2017' - - body = {'id': course_run_key} - studio_url_root = partner.studio_url.strip('/') - url = '{}/api/v1/course_runs/'.format(studio_url_root) - responses.add(responses.POST, url, json=body, status=200) - - with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger: - publisher_course_run = CourseRunFactory( - start=start, - lms_course_id=None, - course__organizations=[organization] - ) - - assert len(responses.calls) == 1 - assert publisher_course_run.lms_course_id == course_run_key - - mock_logger.assert_called_with('Failed to update Studio image for course run [%s]', course_run_key) - - # pylint: disable=unused-argument - @responses.activate - @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio_with_image_api_failure(self, mock_access_token): - organization = OrganizationFactory() - partner = organization.partner - start = datetime.datetime.utcnow() - course_run_key = 'course-v1:TestX+Testing101x+1T2017' - - body = {'id': course_run_key} - studio_url_root = partner.studio_url.strip('/') - url = '{}/api/v1/course_runs/'.format(studio_url_root) - responses.add(responses.POST, url, json=body, status=200) - - body = {'error': 'Server error'} - url = '{root}/api/v1/course_runs/{course_run_key}/images/'.format( - root=studio_url_root, - course_run_key=course_run_key - ) - responses.add(responses.POST, url, json=body, status=500) - - with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger: - publisher_course_run = CourseRunFactory( - start=start, - lms_course_id=None, - course__organizations=[organization] - ) - - assert len(responses.calls) == 2 - assert publisher_course_run.lms_course_id == course_run_key - - mock_logger.assert_called_with( - 'Failed to update Studio image for course run [%s]: %s', course_run_key, json.dumps(body).encode('utf8') - ) - - @responses.activate - @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_in_studio_with_api_failure(self, mock_access_token): # pylint: disable=unused-argument - organization = OrganizationFactory() - partner = organization.partner - - body = {'error': 'Server error'} - studio_url_root = partner.studio_url.strip('/') - url = '{}/api/v1/course_runs/'.format(studio_url_root) - responses.add(responses.POST, url, json=body, status=500) - - with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger: - with pytest.raises(HttpServerError): - publisher_course_run = CourseRunFactory(lms_course_id=None, course__organizations=[organization]) - - assert len(responses.calls) == 1 - assert publisher_course_run.lms_course_id is None - - mock_logger.assert_called_with( - 'Failed to create course run [%d] on Studio: %s', - publisher_course_run.id, - json.dumps(body).encode('utf8') - ) - - @responses.activate - @mock.patch.object(Partner, 'access_token', return_value='JWT fake') - @override_switch('enable_publisher_create_course_run_in_studio', active=True) - def test_create_course_run_error_with_discovery_run(self, mock_access_token): # pylint: disable=unused-argument - """ - Tests that course run creations raises exception and logs expected exception message - """ - number = 'TestX' - organization = OrganizationFactory() - partner = organization.partner - course_key = '{org}+{number}'.format(org=organization.key, number=number) - discovery_course_run = DiscoveryCourseRunFactory(course__partner=partner, course__key=course_key) - - body = {'error': 'Server error'} - studio_url_root = partner.studio_url.strip('/') - - url = '{root}/api/v1/course_runs/{course_run_key}/rerun/'.format( - root=studio_url_root, - course_run_key=discovery_course_run.key - ) - responses.add(responses.POST, url, json=body, status=500) - - with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger: - with pytest.raises(HttpServerError): - CourseRunFactory(lms_course_id=None, course__organizations=[organization], course__number=number) +from django.test import TestCase - assert len(responses.calls) == 1 - mock_logger.assert_called_with( - 'Failed to create course re-run [%s] on Studio: %s', - discovery_course_run.key, - json.dumps(body).encode('utf8') - ) +from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory -@pytest.mark.django_db -class TestCreateOrganizations: +class TestCreateOrganizations(TestCase): def test_create_organizations_added_permissions(self): # Make sure created organization automatically have people permissions organization = OrganizationExtensionFactory() diff --git a/course_discovery/apps/publisher/tests/test_studio_api_utils.py b/course_discovery/apps/publisher/tests/test_studio_api_utils.py deleted file mode 100644 index aca6c0ebe0..0000000000 --- a/course_discovery/apps/publisher/tests/test_studio_api_utils.py +++ /dev/null @@ -1,108 +0,0 @@ -import datetime -from itertools import product - -import mock -import pytest -from waffle.testutils import override_switch - -from course_discovery.apps.core.utils import serialize_datetime -from course_discovery.apps.course_metadata.tests.factories import CourseFactory as DiscoveryCourseFactory -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory as DiscoveryCourseRunFactory -from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory -from course_discovery.apps.publisher.choices import PublisherUserRole -from course_discovery.apps.publisher.constants import PUBLISHER_ENABLE_READ_ONLY_FIELDS -from course_discovery.apps.publisher.studio_api_utils import StudioAPI -from course_discovery.apps.publisher.tests.factories import CourseRunFactory, CourseUserRoleFactory - -test_data = ( - list(product(range(1, 5), ['1T2017'])) + - list(product(range(5, 8), ['2T2017'])) + - list(product(range(9, 13), ['3T2017'])) -) - - -@pytest.mark.django_db -@pytest.mark.parametrize('month,expected', test_data) -def test_calculate_course_run_key_run_value(month, expected): - course_run = CourseRunFactory(start=datetime.datetime(2017, month, 1)) - assert StudioAPI.calculate_course_run_key_run_value(course_run) == expected - - -@pytest.mark.django_db -def test_calculate_course_run_key_run_value_with_multiple_runs_per_trimester(): - number = 'TestX' - organization = OrganizationFactory() - partner = organization.partner - course_key = '{org}+{number}'.format(org=organization.key, number=number) - discovery_course = DiscoveryCourseFactory(partner=partner, key=course_key) - DiscoveryCourseRunFactory(key='course-v1:TestX+Testing101x+1T2017', course=discovery_course) - course_run = CourseRunFactory( - start=datetime.datetime(2017, 2, 1), - lms_course_id=None, - course__organizations=[organization], - course__number=number - ) - assert StudioAPI.calculate_course_run_key_run_value(course_run) == '1T2017a' - - DiscoveryCourseRunFactory(key='course-v1:TestX+Testing101x+1T2017a', course=discovery_course) - assert StudioAPI.calculate_course_run_key_run_value(course_run) == '1T2017b' - - -def assert_data_generated_correctly(course_run, expected_team_data): - course = course_run.course - expected = { - 'title': course_run.title_override or course.title, - 'org': course.organizations.first().key, - 'number': course.number, - 'run': StudioAPI.calculate_course_run_key_run_value(course_run), - 'schedule': { - 'start': serialize_datetime(course_run.start_date_temporary), - 'end': serialize_datetime(course_run.end_date_temporary), - }, - 'team': expected_team_data, - 'pacing_type': course_run.pacing_type_temporary, - } - assert StudioAPI.generate_data_for_studio_api(course_run) == expected - - -@pytest.mark.django_db -@override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) -def test_generate_data_for_studio_api(): - course_run = CourseRunFactory(course__organizations=[OrganizationFactory()]) - course = course_run.course - role = CourseUserRoleFactory(course=course, role=PublisherUserRole.CourseTeam) - team = [ - { - 'user': role.user.username, - 'role': 'instructor', - }, - ] - assert_data_generated_correctly(course_run, team) - - -@pytest.mark.django_db -@override_switch(PUBLISHER_ENABLE_READ_ONLY_FIELDS, active=True) -def test_generate_data_for_studio_api_without_team(): - course_run = CourseRunFactory(course__organizations=[OrganizationFactory()]) - - with mock.patch('course_discovery.apps.publisher.studio_api_utils.logger.warning') as mock_logger: - assert_data_generated_correctly(course_run, []) - mock_logger.assert_called_with( - 'No course team admin specified for course [%s]. This may result in a Studio course run ' - 'being created without a course team.', - course_run.course.number - ) - - -@pytest.mark.django_db -def test_update_course_run_image_in_studio_without_course_image(): - publisher_course_run = CourseRunFactory(course__image=None) - api = StudioAPI(None) - - with mock.patch('course_discovery.apps.publisher.studio_api_utils.logger') as mock_logger: - api.update_course_run_image_in_studio(publisher_course_run) - mock_logger.warning.assert_called_with( - 'Card image for course run [%d] cannot be updated. The related course [%d] has no image defined.', - publisher_course_run.id, - publisher_course_run.course.id - ) diff --git a/course_discovery/apps/publisher/tests/test_utils.py b/course_discovery/apps/publisher/tests/test_utils.py index 433603988a..17966e00bb 100644 --- a/course_discovery/apps/publisher/tests/test_utils.py +++ b/course_discovery/apps/publisher/tests/test_utils.py @@ -1,39 +1,14 @@ -""" Tests publisher.utils""" -from datetime import datetime - -import ddt -from django.contrib.auth.models import Group -from django.test import RequestFactory, TestCase -from django.urls import reverse -from guardian.shortcuts import assign_perm -from mock import Mock +from django.test import TestCase from course_discovery.apps.core.tests.factories import UserFactory -from course_discovery.apps.publisher.constants import ( - ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME, REVIEWER_GROUP_NAME -) -from course_discovery.apps.publisher.mixins import ( - check_course_organization_permission, check_roles_access, publisher_user_required -) -from course_discovery.apps.publisher.models import OrganizationExtension from course_discovery.apps.publisher.tests import factories -from course_discovery.apps.publisher.utils import ( - get_internal_users, has_role_for_course, is_email_notification_enabled, is_internal_user, - is_project_coordinator_user, is_publisher_admin, is_publisher_user, make_bread_crumbs, parse_datetime_field -) +from course_discovery.apps.publisher.utils import is_email_notification_enabled, is_publisher_user -@ddt.ddt class PublisherUtilsTests(TestCase): - """ Tests for the publisher utils. """ - def setUp(self): - super(PublisherUtilsTests, self).setUp() + super().setUp() self.user = UserFactory() - self.organization_extension = factories.OrganizationExtensionFactory() - self.course = factories.CourseFactory(organizations=[self.organization_extension.organization]) - self.admin_group = Group.objects.get(name=ADMIN_GROUP_NAME) - self.internal_user_group = Group.objects.get(name=INTERNAL_USER_GROUP_NAME) def test_email_notification_enabled_by_default(self): """ Test email notification is enabled for the user by default.""" @@ -54,179 +29,15 @@ def test_is_email_notification_enabled(self): # Disabled email notification user_attribute.enable_email_notification = False - user_attribute.save() # pylint: disable=no-member + user_attribute.save() # Verify that email notifications are disabled for the user self.assertEqual(is_email_notification_enabled(self.user), False) - def test_is_publisher_admin(self): - """ - Verify the function returns a boolean indicating if the user is a member of the administrative group. - """ - self.assertFalse(self.user.groups.filter(name=ADMIN_GROUP_NAME).exists()) - self.assertFalse(is_publisher_admin(self.user)) - - admin_group = Group.objects.get(name=ADMIN_GROUP_NAME) - self.user.groups.add(admin_group) - self.assertTrue(is_publisher_admin(self.user)) - - def test_is_internal_user(self): - """ - Verify the function returns a boolean indicating if the user is a member of the internal user group. - """ - self.assertFalse(is_internal_user(self.user)) - - internal_user_group = Group.objects.get(name=INTERNAL_USER_GROUP_NAME) - self.user.groups.add(internal_user_group) - self.assertTrue(is_internal_user(self.user)) - - def test_get_internal_user(self): - """ Verify the function returns all internal users. """ - internal_user_group = Group.objects.get(name=INTERNAL_USER_GROUP_NAME) - self.assertEqual(get_internal_users(), []) - - self.user.groups.add(internal_user_group) - self.assertEqual(get_internal_users(), [self.user]) - - def test_is_project_coordinator_user(self): - """ - Verify the function returns a boolean indicating if the user is a member of the project coordinator group. - """ - self.assertFalse(is_project_coordinator_user(self.user)) - - project_coordinator_group = Group.objects.get(name=PROJECT_COORDINATOR_GROUP_NAME) - self.user.groups.add(project_coordinator_group) - self.assertTrue(is_project_coordinator_user(self.user)) - - def test_check_roles_access_with_admin(self): - """ Verify the function returns True if user is in an admin group, otherwise False. """ - self.assertFalse(check_roles_access(self.user)) - self.user.groups.add(self.admin_group) - self.assertTrue(check_roles_access(self.user)) - - def test_check_roles_access_with_internal_user(self): - """ Verify the function returns True if user is in an internal group, otherwise False. """ - self.assertFalse(check_roles_access(self.user)) - self.user.groups.add(self.internal_user_group) - self.assertTrue(check_roles_access(self.user)) - - def test_check_organization_permission_without_org(self): - """ - Verify the function returns True if the user has organization permission on given course, otherwise False. - """ - self.assertFalse( - check_course_organization_permission(self.user, self.course, OrganizationExtension.VIEW_COURSE) - ) - - self.user.groups.add(self.organization_extension.group) - assign_perm( - OrganizationExtension.VIEW_COURSE, self.organization_extension.group, self.organization_extension - ) - - self.assertTrue( - check_course_organization_permission(self.user, self.course, OrganizationExtension.VIEW_COURSE) - ) - - def test_check_user_access_with_roles(self): - """ - Verify the function returns a boolean indicating if the user - organization permission on given course or user is internal or admin user. - """ - self.assertFalse(check_roles_access(self.user)) - self.user.groups.add(self.admin_group) - self.assertTrue(check_roles_access(self.user)) - self.user.groups.remove(self.admin_group) - self.assertFalse(check_roles_access(self.user)) - self.user.groups.add(self.internal_user_group) - self.assertTrue(check_roles_access(self.user)) - - def test_check_user_access_with_permission(self): - """ - Verify the function returns True if the user has organization permission on given course, otherwise False. - """ - self.assertFalse( - check_course_organization_permission(self.user, self.course, OrganizationExtension.VIEW_COURSE) - ) - - self.user.groups.add(self.organization_extension.group) - assign_perm( - OrganizationExtension.VIEW_COURSE, self.organization_extension.group, self.organization_extension - ) - - self.assertTrue( - check_course_organization_permission(self.user, self.course, OrganizationExtension.VIEW_COURSE) - ) - def test_is_publisher_user(self): """ Verify the function returns a boolean indicating if the user is part of any publisher app group. """ self.assertFalse(is_publisher_user(self.user)) - self.user.groups.add(Group.objects.get(name=REVIEWER_GROUP_NAME)) + self.user.groups.add(factories.GroupFactory()) self.assertTrue(is_publisher_user(self.user)) - - def test_require_is_publisher_user_without_group(self): - """ - Verify that decorator returns the error message if user is not part of any publisher group. - """ - func = Mock() - decorated_func = publisher_user_required(func) - request = RequestFactory() - request.user = self.user - - response = decorated_func(request, self.user) - self.assertContains(response, "Must be Publisher user to perform this action.", status_code=403) - self.assertFalse(func.called) - - def test_is_publisher_user_with_publisher_group(self): - """ - Verify that decorator works fine with user is part of publisher app group. - """ - func = Mock() - decorated_func = publisher_user_required(func) - request = RequestFactory() - request.user = self.user - self.user.groups.add(self.internal_user_group) - - decorated_func(request, self.user) - self.assertTrue(func.called) - - def test_make_bread_crumbs(self): - """ Verify the function parses the list of tuples and returns a list of corresponding dicts.""" - links = [(reverse('publisher:publisher_courses_new'), 'Courses'), (None, 'Testing')] - self.assertEqual( - [{'url': '/publisher/courses/new/', 'slug': 'Courses'}, {'url': None, 'slug': 'Testing'}], - make_bread_crumbs(links) - ) - - def test_has_role_for_course(self): - """ - Verify the function returns a boolean indicating if the user has a role for course. - """ - - self.assertFalse(has_role_for_course(self.course, self.user)) - factories.CourseUserRoleFactory(course=self.course, user=self.user) - self.assertTrue(has_role_for_course(self.course, self.user)) - - @ddt.data( - 'april 20, 2017', - 'aug 20 2019', - '2020 may 20', - '09 04 2018', - 'jan 20 2020' - ) - def test_parse_datetime_field(self, date): - """ Verify that function return datetime after parsing different possible date format. """ - parsed_date = parse_datetime_field(date) - self.assertTrue(isinstance(parsed_date, datetime)) - - @ddt.data( - None, - 'jan 20 20203' - 'invalid-date-string' - 'jan 20' - ) - def test_parse_datetime_field_with_invalid_date_format(self, invalid_date): - """ Verify that function return None if date string does not match any possible date format. """ - parsed_date = parse_datetime_field(invalid_date) - self.assertIsNone(parsed_date) diff --git a/course_discovery/apps/publisher/tests/test_validators.py b/course_discovery/apps/publisher/tests/test_validators.py deleted file mode 100644 index e2a1790fb2..0000000000 --- a/course_discovery/apps/publisher/tests/test_validators.py +++ /dev/null @@ -1,26 +0,0 @@ -import ddt -from django.core.exceptions import ValidationError -from django.test import TestCase - -from course_discovery.apps.publisher.validators import validate_text_count - - -@ddt.ddt -class ValidatorTests(TestCase): - """ - Tests for form validators - """ - @ddt.data( - ('MODULE 0: ', 'MODULE 0:'), - (' MODULE 0: ', 'MODULE 0:'), - ('\n\nMODULE 0: \n\n\n\n', 'MODULE 0:') - ) - @ddt.unpack - def test_validate_text_count(self, text_to_validate, expected_clean_text): - """Tests that validate text count work as expected""" - max_length_allowed = len(expected_clean_text) - validate_text_count(max_length_allowed)(text_to_validate) - - # Verify you get a Validation Error if try go below the max. - with self.assertRaises(ValidationError): - validate_text_count(max_length_allowed - 1)(text_to_validate) diff --git a/course_discovery/apps/publisher/tests/test_views.py b/course_discovery/apps/publisher/tests/test_views.py deleted file mode 100644 index dbe73e7f5a..0000000000 --- a/course_discovery/apps/publisher/tests/test_views.py +++ /dev/null @@ -1,4362 +0,0 @@ -# pylint: disable=no-member -import json -import random -from datetime import datetime, timedelta - -import ddt -import factory -import mock -from bs4 import BeautifulSoup -from django.conf import settings -from django.contrib.auth.models import Group -from django.contrib.sites.models import Site -from django.core import mail -from django.core.exceptions import ObjectDoesNotExist -from django.db import IntegrityError -from django.forms import model_to_dict -from django.http import Http404 -from django.test import TestCase -from django.urls import reverse -from guardian.shortcuts import assign_perm -from opaque_keys.edx.keys import CourseKey -from pytz import timezone -from testfixtures import LogCapture -from waffle.testutils import override_switch - -from course_discovery.apps.api.tests.mixins import SiteMixin -from course_discovery.apps.core.models import Currency, User -from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory -from course_discovery.apps.core.tests.helpers import make_image_file -from course_discovery.apps.course_metadata.tests import toggle_switch -from course_discovery.apps.course_metadata.tests.factories import ( - CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, SubjectFactory -) -from course_discovery.apps.ietf_language_tags.models import LanguageTag -from course_discovery.apps.publisher.choices import ( - CourseRunStateChoices, CourseStateChoices, InternalUserRole, PublisherUserRole -) -from course_discovery.apps.publisher.constants import ( - ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME, - PUBLISHER_CREATE_AUDIT_SEATS_FOR_VERIFIED_COURSE_RUNS, PUBLISHER_ENABLE_READ_ONLY_FIELDS, REVIEWER_GROUP_NAME -) -from course_discovery.apps.publisher.forms import CourseEntitlementForm -from course_discovery.apps.publisher.models import ( - Course, CourseEntitlement, CourseRun, CourseRunState, CourseState, OrganizationExtension, Seat -) -from course_discovery.apps.publisher.tests import factories -from course_discovery.apps.publisher.tests.utils import create_non_staff_user_and_login -from course_discovery.apps.publisher.utils import is_email_notification_enabled -from course_discovery.apps.publisher.views import logger as publisher_views_logger -from course_discovery.apps.publisher.views import ( - COURSE_ROLES, COURSES_ALLOWED_PAGE_SIZES, CourseRunDetailView, get_course_role_widgets_data -) -from course_discovery.apps.publisher.wrappers import CourseRunWrapper -from course_discovery.apps.publisher_comments.models import CommentTypeChoices -from course_discovery.apps.publisher_comments.tests.factories import CommentFactory - - -@ddt.ddt -class CreateCourseViewTests(SiteMixin, TestCase): - """ Tests for the publisher `CreateCourseView`. """ - - def setUp(self): - super(CreateCourseViewTests, self).setUp() - self.user = UserFactory() - # add user to internal group - self.internal_user_group = Group.objects.get(name=INTERNAL_USER_GROUP_NAME) - self.user.groups.add(self.internal_user_group) - - # add user to external group e.g. a course team group - self.organization_extension = factories.OrganizationExtensionFactory() - self.group = self.organization_extension.group - self.user.groups.add(self.group) - - self.course = factories.CourseFactory(organizations=[self.organization_extension.organization]) - - self.client.login(username=self.user.username, password=USER_PASSWORD) - - # creating default organizations roles - factories.OrganizationUserRoleFactory( - role=PublisherUserRole.ProjectCoordinator, organization=self.organization_extension.organization - ) - factories.OrganizationUserRoleFactory( - role=PublisherUserRole.MarketingReviewer, organization=self.organization_extension.organization - ) - - def test_course_form_without_login(self): - """ Verify that user can't access new course form page when not logged in. """ - self.client.logout() - response = self.client.get(reverse('publisher:publisher_courses_new')) - - self.assertRedirects( - response, - expected_url='{url}?next={next}'.format( - url=reverse('login'), - next=reverse('publisher:publisher_courses_new') - ), - status_code=302, - target_status_code=302 - ) - - def test_page_without_publisher_group_access(self): - """ - Verify that user can't access new course form page if user is not the part of any group. - """ - self.client.logout() - self.client.login(username=UserFactory().username, password=USER_PASSWORD) - response = self.client.get(reverse('publisher:publisher_courses_new')) - self.assertContains( - response, "Must be Publisher user to perform this action.", status_code=403 - ) - - def test_create_course_with_errors(self): - """ - Verify that without providing required data course cannot be created. - """ - course_dict = model_to_dict(self.course) - course_dict['number'] = '' - course_dict['image'] = '' - course_dict['lms_course_id'] = '' - response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict) - self.assertEqual(response.status_code, 400) - - @ddt.data( - make_image_file('test_cover00.jpg', width=2120, height=1192), - make_image_file('test_cover01.jpg', width=1134, height=675), - make_image_file('test_cover02.jpg', width=378, height=225), - ) - def test_create_course_valid_image(self, image): - """ - Verify a new course with valid image of acceptable image sizes can be saved properly - """ - data = {'title': 'Test valid', 'number': 'testX453', 'image': image} - course_dict = self._post_data(data, self.course) - response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict) - course = Course.objects.get(number=course_dict['number']) - self.assertRedirects( - response, - expected_url=reverse('publisher:publisher_course_detail', kwargs={'pk': course.id}), - status_code=302, - target_status_code=200 - ) - self.assertEqual(course.number, data['number']) - self._assert_image(course) - - @ddt.data( - make_image_file('test_banner00.jpg', width=2120, height=1191), - make_image_file('test_banner01.jpg', width=2120, height=1193), - make_image_file('test_banner02.jpg', width=2119, height=1192), - make_image_file('test_banner03.jpg', width=2121, height=1192), - make_image_file('test_banner04.jpg', width=2121, height=1191), - make_image_file('test_cover01.jpg', width=1600, height=1100), - make_image_file('test_cover01.jpg', width=300, height=220), - ) - def test_create_course_invalid_image(self, image): - """ - Verify that a new course with an invalid image shows the proper error. - """ - image_error = [ - 'Invalid image size. The recommended image size is 1134 X 675 pixels. ' + - 'Older courses also support image sizes of ' + - '2120 X 1192 px or 378 X 225 px.', - ] - self.user.groups.add(Group.objects.get(name=ADMIN_GROUP_NAME)) - self._assert_records(1) - course_dict = self._post_data({'image': image}, self.course) - response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict, files=image) - self.assertEqual(response.context['course_form'].errors['image'], image_error) - self._assert_records(1) - - def test_create_with_fail_transaction(self): - """ - Verify that in case of any error transactions rollback and no object is created in db. - """ - self._assert_records(1) - data = {'number': 'course_2', 'image': make_image_file('test_banner.jpg')} - course_dict = self._post_data(data, self.course) - with mock.patch.object(Course, "save") as mock_method: - mock_method.side_effect = IntegrityError - response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict, files=data['image']) - - self.assertEqual(response.status_code, 400) - self._assert_records(1) - - def test_create_with_exception(self): - """ - Verify that in case of any error transactions rollback and no object is created in db. - """ - self._assert_records(1) - data = {'number': 'course_2', 'image': make_image_file('test_banner.jpg')} - course_dict = self._post_data(data, self.course) - with mock.patch.object(Course, "save") as mock_method: - mock_method.side_effect = Exception('test') - response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict, files=data['image']) - - self.assertEqual(response.status_code, 400) - self.assertRaises(Exception) - self._assert_records(1) - - def test_create_form_with_single_organization(self): - """Verify that if there is only one organization then that organization will be shown as text. """ - response = self.client.get(reverse('publisher:publisher_courses_new')) - self.assertContains(response, '{key}: {name}'.format( - value=new_organization_extension.organization.id, - key=new_organization_extension.organization.key, - name=new_organization_extension.organization.name - ) - ) - - self.user.groups.add(self.internal_user_group) - response = self.client.get(reverse('publisher:publisher_courses_new')) - - # Verify that internal user can see newly created organization in options. - self.assertContains( - response, - ''.format( - value=new_organization_extension.organization.id, - key=new_organization_extension.organization.key, - name=new_organization_extension.organization.name - ) - ) - - def test_create_course_without_course_number(self): - """ - Verify that without course number course cannot be created. - """ - course_dict = self._post_data({'image': ''}, self.course) - course_dict.pop('number') - response = self.client.post(reverse('publisher:publisher_courses_new'), course_dict) - self.assertEqual(response.status_code, 400) - - def test_page_with_pilot_switch_enable(self): - """ Verify that about page information panel is not visible on new course page.""" - response = self.client.get(reverse('publisher:publisher_courses_new')) - self.assertNotIn( - '