diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 30c44535a..d5ff75903 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,15 +9,23 @@ updates: directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 999 - package-ecosystem: "npm" directory: "/codewof/" schedule: interval: "daily" + open-pull-requests-limit: 999 - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 999 + ignore: + # Ignore updates to Django package until LTS version + - dependency-name: "django" + versions: ["4.0.X", "4.1.X", "5.0.X", "5.1.X", "6.0.X", "6.1.X", "7.0.X", "7.1.X"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 999 diff --git a/.github/workflows/auto-merge-dependency-updates.yaml b/.github/workflows/auto-merge-dependency-updates.yaml new file mode 100644 index 000000000..53f271eaa --- /dev/null +++ b/.github/workflows/auto-merge-dependency-updates.yaml @@ -0,0 +1,30 @@ +name: Auto-merge minor dependency updates + +on: pull_request + +permissions: + pull-requests: write + contents: write + +jobs: + check-pull-request: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.3.3 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Approve + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr review --body "Automatic approval for minor dependency update." --approve "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Merge + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/test-and-deploy.yaml b/.github/workflows/test-and-deploy.yaml index 99a858023..12ff83c42 100644 --- a/.github/workflows/test-and-deploy.yaml +++ b/.github/workflows/test-and-deploy.yaml @@ -3,6 +3,7 @@ name: Test and deploy on: workflow_dispatch: push: + pull_request: release: types: [published] @@ -16,39 +17,39 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Create Docker network - run: docker network create uccser-development-proxy + run: docker network create uccser-development-stack - name: Start systems - run: docker-compose -f docker-compose.local.yml up -d + run: docker compose -f docker-compose.local.yml up -d - name: Run Django system check - run: docker-compose -f docker-compose.local.yml run --rm django python ./manage.py check --fail-level WARNING + run: docker compose -f docker-compose.local.yml run --rm django python ./manage.py check --fail-level WARNING test-content: name: Tests - Content runs-on: ubuntu-20.04 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Create Docker network - run: docker network create uccser-development-proxy + run: docker network create uccser-development-stack - name: Start systems - run: docker-compose -f docker-compose.local.yml up -d + run: docker compose -f docker-compose.local.yml up -d - name: Create static files - run: docker-compose -f docker-compose.local.yml run --rm --user="root" node npm run dev + run: docker compose -f docker-compose.local.yml run --rm --user="root" node npm run generate-assets - name: Migrate database - run: docker-compose -f docker-compose.local.yml run --rm django python ./manage.py migrate + run: docker compose -f docker-compose.local.yml run --rm django python ./manage.py migrate - name: Run update_data command - run: docker-compose -f docker-compose.local.yml run --rm django python ./manage.py update_data + run: docker compose -f docker-compose.local.yml run --rm django python ./manage.py update_data - name: Collect static files - run: docker-compose -f docker-compose.local.yml run --rm --user="root" django python ./manage.py collectstatic --no-input + run: docker compose -f docker-compose.local.yml run --rm --user="root" django python ./manage.py collectstatic --no-input test-general: name: Tests - General runs-on: ubuntu-20.04 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Run general tests run: ./dev ci test_general @@ -57,16 +58,17 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Run style tests run: ./dev ci style create-static-files: name: Create static files if: | - (github.ref == 'refs/heads/develop') + (github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/research-study-') - || github.event_name == 'release' + || github.event_name == 'release') + && github.event_name != 'pull_request' runs-on: ubuntu-20.04 needs: [ test-django-system-check, @@ -76,25 +78,25 @@ jobs: ] steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Create Docker network - run: docker network create uccser-development-proxy + run: docker network create uccser-development-stack - name: Start system - run: docker-compose -f docker-compose.local.yml up -d + run: docker compose -f docker-compose.local.yml up -d - name: Create production static files - run: docker-compose -f docker-compose.local.yml run --rm --user="root" node npm run build + run: docker compose -f docker-compose.local.yml run --rm --user="root" node npm run generate-production-assets - name: Collect staticfiles - run: docker-compose -f docker-compose.local.yml run --rm --user="root" django python manage.py collectstatic --no-input + run: docker compose -f docker-compose.local.yml run --rm --user="root" django python manage.py collectstatic --no-input - name: Archive static files run: tar czf static-files.tar.gz --directory codewof/staticfiles/ . - name: Upload artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: static-files path: static-files.tar.gz @@ -103,19 +105,20 @@ jobs: publish-docker-image: name: Create and publish Docker image if: | - (github.ref == 'refs/heads/develop') + (github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/research-study-') - || github.event_name == 'release' + || github.event_name == 'release') + && github.event_name != 'pull_request' runs-on: ubuntu-20.04 needs: [ create-static-files, ] steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Download all workflow run artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: path: artifacts/ @@ -128,7 +131,7 @@ jobs: tar -xz --file artifacts/static-files/static-files.tar.gz --directory codewof/staticfiles - name: Log in to the Container registry - uses: docker/login-action@v1.10.0 + uses: docker/login-action@v2.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -136,7 +139,7 @@ jobs: - name: Setup Docker metadata id: meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v4 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -144,7 +147,7 @@ jobs: type=ref,event=branch,priority=2 - name: Build and push Docker image - uses: docker/build-push-action@v2.7.0 + uses: docker/build-push-action@v3.1.1 with: file: ./infrastructure/production/django/Dockerfile context: . diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2d032d83f..000000000 --- a/.travis.yml +++ /dev/null @@ -1,81 +0,0 @@ -sudo: required -services: - - docker -language: python -python: - - '3.6' -before_install: - # See https://github.com/travis-ci/travis-ci/issues/7940 - - sudo rm -f /etc/boto.cfg -install: - - sudo docker --version - - sudo docker-compose --version -jobs: - include: - - stage: test - script: ./dev ci test_suite - name: "Run test suite" - - script: ./dev ci style - name: "Run style checker" - - stage: development deployment - script: skip - name: "Deploy development website to Google App Engine" - deploy: - - provider: script - script: bash ./infrastructure/dev-deploy/deploy-app.sh - skip_cleanup: true - on: - branch: develop - - script: skip - name: "Update Google Cloud SQL database" - deploy: - - provider: script - script: bash ./infrastructure/dev-deploy/update-content.sh - skip_cleanup: true - on: - branch: develop - - script: skip - name: "Copy static files to Google Storage Bucket" - deploy: - - provider: script - script: bash ./infrastructure/dev-deploy/deploy-static-files.sh - skip_cleanup: true - on: - branch: develop - - stage: production deployment - script: skip - name: "Deploy production website to Google App Engine" - deploy: - - provider: script - script: bash ./infrastructure/prod-deploy/deploy-app.sh - skip_cleanup: true - on: - branch: master - - script: skip - name: "Update Google Cloud SQL database" - deploy: - - provider: script - script: bash ./infrastructure/prod-deploy/update-content.sh - skip_cleanup: true - on: - branch: master - - script: skip - name: "Copy static files to Google Storage Bucket" - deploy: - - provider: script - script: bash ./infrastructure/prod-deploy/deploy-static-files.sh - skip_cleanup: true - on: - branch: master -notifications: - email: false - slack: - rooms: deptfunstuff:abJKvzApk5SKtcEyAgtswXAv - on_success: change - on_failure: change -stages: - - name: test - - name: development deployment - if: branch = develop - - name: production deployment - if: branch = master diff --git a/CHANGELOG.md b/CHANGELOG.md index 948694675..57c47a4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## 5.0.0 + +- Add question recommendations to users. + - Track proficiency of users' programming skills, using this information to suggest exercises for the user to perform. +- Add descriptive tags for each programming question. +- Add filtering of questions by question type, difficulty, contexts, and concepts. +- Add 15 new Python 3 questions. +- Contact emails show which domain they were sent from. +- Add URL for website status information. +- Allow 'likes' to be viewed in the admin interface. +- Disable API on production website. +- Use HTML templates for emails, replacing current plaintext emails. +- Add `update_data` management command for consistency across UCCSER repositories. +- Enable CORS headers for providing version information across domains. +- Set Gulp to watch for changed files and rebuild them as required. +- Update project to use uccser-development-stack v3. +- Auto-merge minor dependency updates (this includes minor and patch updates). +- Allow all dependency update pull requests to be created (remove open limit on Dependabot). +- Add OCI labels to Django Docker image. +- Ignore updates to non-LTS Django packages. +- Remove outdated helper script. +- Remove unused Travis CI configuration file. +- Remove unused Browser Sync functionality within Gulpfile. +- Dependency updates: + - Add django-cors-headers 3.13.0. + - Update ansi-colors from 4.1.1 to 4.1.3. + - Update argon2-cffi from 19.2.0 to 21.3.0. + - Update autoprefixer from 10.3.1 to 10.4.12. + - Update bootstrap from 4.6.0 to 4.6.2. + - Update clipboard from 2.0.8 to 2.0.11. + - Update codemirror from 5.63.1 to 5.65.1. + - Update coverage from 6.0.2 to 6.4.4. + - Update cssnano from 5.0.8 to 5.1.13. + - Update django from 3.2.6 to 3.2.15. + - Update django-allauth from 0.45.0 to 0.51.0. + - Update django-anymail[mailgun] from 7.0.0 to 8.6. + - Update django-ckeditor from 6.1.0 to 6.5.1. + - Update django-crispy-forms from 1.9.0 to 1.14.0. + - Update django-debug-toolbar from 3.2.2 to 3.6.0. + - Update django-environ from 0.4.5 to 0.9.0. + - Update django-extensions from 3.0.8 to 3.2.1. + - Update django-filter from 21.1 to 22.1. + - Update django-model-utils from 4.1.1 to 4.2.0. + - Update django-modeltranslation from 0.17.3 to 0.18.4. + - Update django-recaptcha from 2.0.6 to 3.0.0. + - Update djangorestframework from 3.12.4 to 3.14.0. + - Update factory-boy from 2.12.0 to 3.2.1. + - Update fancy-log from 1.3.3 to 2.0.0. + - Update flake8-docstrings from 1.5.0 to 1.6.0. + - Update flake8-quotes from 2.1.1 to 3.3.1. + - Update fuse.js from 6.4.6 to 6.6.2. + - Update gulp-postcss from 9.0.0 to 9.0.1. + - Update gulp-sass from 5.0.0 to 5.1.0. + - Update intro.js from 4.2.2 to 6.0.0. + - Update jquery from 3.6.0 to 3.6.1. + - Update mypy from 0.782 to 0.971. + - Update postcss from 8.3.6 to 8.4.16. + - Update psycopg2 from 2.9.1 to 2.9.3. + - Update pytest from 6.2.5 to 7.1.3. + - Update pytest-django 3.9.0 to 4.5.2. + - Update pytest-sugar from 0.9.4 to 0.9.5. + - Update pytz from 2019.3 to 2022.2.1. + - Update sass from 1.42.1 to 1.55.0. + - Update sortablejs from 1.8.4 to 1.15.0. + - Update whitenoise from 5.3.0 to 6.2.0. + - Update yargs from 7.1.1 to 7.5.1. + - Remove browser-sync 2.27.5. + ## 4.0.0 - Add user groups, including invite functionality. diff --git a/codewof/config/__init__.py b/codewof/config/__init__.py index f2eb548ca..b3d6e3190 100644 --- a/codewof/config/__init__.py +++ b/codewof/config/__init__.py @@ -1,6 +1,6 @@ """Configuration for Django system.""" -__version__ = "4.0.0" +__version__ = "5.0.0" __version_info__ = tuple( [ int(num) if num.isdigit() else num diff --git a/codewof/config/context_processors/deployed.py b/codewof/config/context_processors/deployed.py index 09da1201f..7958172c1 100644 --- a/codewof/config/context_processors/deployed.py +++ b/codewof/config/context_processors/deployed.py @@ -4,13 +4,14 @@ def deployed(request): - """Return a dictionary containing booleans regarding deployed environment. + """Return a dictionary containing booleans and other info regarding deployed environment. Returns: - Dictionary containing deployed booleans to add to context. + Dictionary containing deployed booleans and other info to add to context. """ return { "DEPLOYED": settings.DEPLOYED, "PRODUCTION_ENVIRONMENT": settings.PRODUCTION_ENVIRONMENT, "STAGING_ENVIRONMENT": settings.STAGING_ENVIRONMENT, + "DOMAIN": settings.CODEWOF_DOMAIN } diff --git a/codewof/config/context_processors/version_number.py b/codewof/config/context_processors/version_number.py index 64d9b1aa9..6214c6720 100644 --- a/codewof/config/context_processors/version_number.py +++ b/codewof/config/context_processors/version_number.py @@ -12,5 +12,5 @@ def version_number(request): """ return { "VERSION_NUMBER": __version__, - "GIT_SHA": settings.GIT_SHA, + "GIT_SHA": settings.GIT_SHA } diff --git a/codewof/config/settings/base.py b/codewof/config/settings/base.py index d8b966d09..ba899a1a0 100644 --- a/codewof/config/settings/base.py +++ b/codewof/config/settings/base.py @@ -93,6 +93,7 @@ 'django.contrib.admin', ] THIRD_PARTY_APPS = [ + 'corsheaders', 'anymail', 'mail_templated', 'crispy_forms', @@ -105,6 +106,7 @@ 'ckeditor', 'captcha', 'django_bootstrap_breadcrumbs', + 'django_filters', ] LOCAL_APPS = [ 'general.apps.GeneralAppConfig', @@ -168,6 +170,7 @@ # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -380,13 +383,12 @@ # ------------------------------------------------------------------------------ DEPLOYED = env.bool('DEPLOYED') GIT_SHA = env('GIT_SHA', default=None) -if GIT_SHA: - GIT_SHA = GIT_SHA[:8] -else: +if not GIT_SHA: GIT_SHA = "local development" CODEWOF_DOMAIN = env('CODEWOF_DOMAIN', default='https://codewof.localhost') PRODUCTION_ENVIRONMENT = False STAGING_ENVIRONMENT = True +TESTING = False BREADCRUMBS_TEMPLATE = 'django_bootstrap_breadcrumbs/bootstrap4.html' QUESTIONS_BASE_PATH = os.path.join(str(ROOT_DIR.path('programming')), 'content') CUSTOM_VERTO_TEMPLATES = os.path.join(str(ROOT_DIR.path('utils')), 'custom_converter_templates', '') @@ -426,6 +428,12 @@ def fizzbuzz(): DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:8000", + "http://localhost:8000", + "https://canterbury.ac.nz" +] + # Sample Data # ------------------------------------------------------------------------------ SAMPLE_DATA_ADMIN_PASSWORD = 'password' diff --git a/codewof/config/settings/testing.py b/codewof/config/settings/testing.py index 2c564090c..9d549bedc 100644 --- a/codewof/config/settings/testing.py +++ b/codewof/config/settings/testing.py @@ -18,6 +18,8 @@ "test_without_migrations", ] +TESTING = True + # DATABASE CONFIGURATION # ---------------------------------------------------------------------------- # See: https://docs.djangoproject.com/en/dev/ref/settings/#databases diff --git a/codewof/config/urls.py b/codewof/config/urls.py index 1b5343ee1..6941ab97b 100644 --- a/codewof/config/urls.py +++ b/codewof/config/urls.py @@ -9,6 +9,7 @@ from programming.urls import router as programming_router from research.urls import router as research_router from users.urls import router as users_router +from . import views admin.site.login = login_required(admin.site.login) admin.site.site_header = 'CodeWOF' @@ -27,6 +28,7 @@ path('accounts/', include('allauth.urls')), path('', include('programming.urls', namespace='programming'),), path('api/', include(router.urls)), + path('status/', view=views.get_release_and_commit, name="get-release-and-commit") ] if settings.DEBUG: diff --git a/codewof/config/views.py b/codewof/config/views.py new file mode 100644 index 000000000..49524a3f0 --- /dev/null +++ b/codewof/config/views.py @@ -0,0 +1,16 @@ +"""Views for the config application.""" + +from django.conf import settings +from django.http import JsonResponse +from config import __version__ + +from django.views.decorators.http import require_http_methods + + +@require_http_methods(["GET"]) +def get_release_and_commit(request): + """Return JSON containing the version number and Git commit hash.""" + return JsonResponse({ + "VERSION_NUMBER": __version__, + "GIT_SHA": settings.GIT_SHA, + }) diff --git a/codewof/general/forms.py b/codewof/general/forms.py index 6fcdf0885..b9d807e25 100644 --- a/codewof/general/forms.py +++ b/codewof/general/forms.py @@ -3,13 +3,13 @@ from django import forms from django.conf import settings from django.core.mail import send_mail, mail_managers -from django.template.loader import render_to_string +from django.template.loader import render_to_string, get_template from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Submit, HTML from captcha.fields import ReCaptchaField from captcha.widgets import ReCaptchaV3 -MESSAGE_TEMPLATE = "{}\n\n-----\nMessage sent from {} <{}>\n" +MESSAGE_TEMPLATE = "{}\n\n-----\nMessage sent from {} <{}>\n\n{}\n" class ContactForm(forms.Form): @@ -28,19 +28,37 @@ def send_email(self): subject = self.cleaned_data['subject'] from_email = self.cleaned_data['from_email'] message = self.cleaned_data['message'] + plain = MESSAGE_TEMPLATE.format(message, name, from_email, settings.CODEWOF_DOMAIN) + html = self.build_email_html(name, subject, message, from_email) mail_managers( subject, - MESSAGE_TEMPLATE.format(message, name, from_email), + plain, + html_message=html ) if self.cleaned_data.get('cc_sender'): send_mail( subject, - MESSAGE_TEMPLATE.format(message, name, from_email), + plain, settings.DEFAULT_FROM_EMAIL, [from_email], fail_silently=False, + html_message=html ) + def build_email_html(self, name, subject, message, email): + """ + Construct HTML for the email body using the contact-email.html template. + + :param name: The string name to insert in the template. + :param subject: The string subject to insert in the template. + :param message: The string message to insert in the template. + :param email: The string email to insert in the template. + :return: The rendered HTML. + """ + email_template = get_template("general/contact-email.html") + return email_template.render({"name": name, "subject": subject, "message": message, "email": email, + "DOMAIN": settings.CODEWOF_DOMAIN}) + def __init__(self, *args, **kwargs): """Add crispyform helper to form.""" super().__init__(*args, **kwargs) diff --git a/codewof/gulpfile.js b/codewof/gulpfile.js index 87f5e55f5..72e6728ec 100644 --- a/codewof/gulpfile.js +++ b/codewof/gulpfile.js @@ -9,7 +9,6 @@ const pjson = require('./package.json') // Plugins const autoprefixer = require('autoprefixer') const browserify = require('browserify') -const browserSync = require('browser-sync').create() const buffer = require('vinyl-buffer'); const c = require('ansi-colors') const concat = require('gulp-concat') @@ -23,7 +22,6 @@ const log = require('fancy-log') const pixrem = require('pixrem') const postcss = require('gulp-postcss') const postcssFlexbugFixes = require('postcss-flexbugs-fixes') -const reload = browserSync.reload const sass = require('gulp-sass')(require('sass')); const sourcemaps = require('gulp-sourcemaps') const tap = require('gulp-tap') @@ -173,34 +171,13 @@ function svg() { .pipe(dest(paths.svg_output)) } -// Browser sync server for live reload -function initBrowserSync() { - browserSync.init( - [ - // `${paths.css}/*.css`, - `${paths.js}/*.js` - ], { - // https://www.browsersync.io/docs/options/#option-proxy - proxy: { - target: 'codewof.localhost/:80', - proxyReq: [ - function (proxyReq, req) { - // Assign proxy "host" header same as current request at Browsersync server - proxyReq.setHeader('Host', req.headers.host) - } - ] - }, - // https://www.browsersync.io/docs/options/#option-open - // Disable as it doesn't work from inside a container - open: false - } - ) -} - // Watch function watchPaths() { // watch(`${paths.sass}/*.scss`, scss) - watch([`${paths.js_source}/*.js`, `!${paths.js_source}/*.min.js`], js).on("change", reload) + watch([`${paths.js_source}/**/*.js`, `!${paths.js_source}/*.min.js`], js) + watch([`${paths.css_source}/**/*.css`], css) + watch([`${paths.scss_source}/**/*.scss`], scss) + watch([`${paths.images_source}/**/*`], img) } // Generate all assets @@ -215,11 +192,9 @@ const generateAssets = parallel( // Set up dev environment const dev = parallel( - initBrowserSync, watchPaths ) exports["generate-assets"] = generateAssets exports["dev"] = dev // TODO: Look at cleaning build folder -exports.default = generateAssets -// exports.default = series(generateAssets, dev) +exports.default = series(generateAssets, dev) diff --git a/codewof/package.json b/codewof/package.json index c4f576ad9..b29fd5ea2 100644 --- a/codewof/package.json +++ b/codewof/package.json @@ -4,40 +4,39 @@ "private": true, "dependencies": {}, "devDependencies": { - "ansi-colors": "4.1.1", - "autoprefixer": "10.3.1", - "bootstrap": "4.6.0", - "browser-sync": "2.27.5", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.12", + "bootstrap": "4.6.2", "browserify": "17.0.0", "child_process": "1.0.2", - "clipboard": "2.0.8", - "codemirror": "5.63.1", - "cssnano": "5.0.8", + "clipboard": "2.0.11", + "codemirror": "5.65.1", + "cssnano": "5.1.13", "details-element-polyfill": "2.4.0", - "fancy-log": "1.3.3", - "fuse.js": "6.4.6", + "fancy-log": "2.0.0", + "fuse.js": "6.6.2", "gulp-concat": "2.6.1", "gulp-error-handle": "1.0.1", "gulp-filter": "7.0.0", "gulp-if": "3.0.0", "gulp-imagemin": "5.0.3", - "gulp-postcss": "9.0.0", - "gulp-sass": "5.0.0", + "gulp-postcss": "9.0.1", + "gulp-sass": "5.1.0", "gulp-sourcemaps": "3.0.0", "gulp-tap": "2.0.0", "gulp-terser": "2.1.0", "gulp": "4.0.2", - "intro.js": "4.2.2", - "jquery": "3.6.0", + "intro.js": "6.0.0", + "jquery": "3.6.1", "pixrem": "5.0.0", "popper.js": "1.16.1", - "postcss": "8.3.6", + "postcss": "8.4.16", "postcss-flexbugs-fixes": "5.0.2", - "sass": "1.42.1", + "sass": "1.55.0", "skulpt": "0.11.1", - "sortablejs": "1.8.4", + "sortablejs": "1.15.0", "vinyl-buffer": "1.0.1", - "yargs": "17.1.1" + "yargs": "17.5.1" }, "engines": { "node": ">=8" @@ -47,6 +46,7 @@ ], "scripts": { "dev": "gulp", - "build": "gulp generate-assets --production" + "generate-assets": "gulp generate-assets", + "generate-production-assets": "gulp generate-assets --production" } } diff --git a/codewof/programming/admin.py b/codewof/programming/admin.py index 71ee28edd..841805eb3 100644 --- a/codewof/programming/admin.py +++ b/codewof/programming/admin.py @@ -12,6 +12,7 @@ Profile, Achievement, Earned, + Like, ) User = get_user_model() @@ -82,3 +83,4 @@ class Media: admin.site.register(Profile, ProfileAdmin) admin.site.register(Achievement, AchievementAdmin) admin.site.register(Earned, EarnedAdmin) +admin.site.register(Like) diff --git a/codewof/programming/codewof_utils.py b/codewof/programming/codewof_utils.py index 368148c58..a94393e5e 100644 --- a/codewof/programming/codewof_utils.py +++ b/codewof/programming/codewof_utils.py @@ -107,14 +107,19 @@ def get_days_consecutively_answered(profile, user_attempts=None): return highest_streak +def filter_attempts_in_past_month(attempts): + """Filter the given attempts by only returning those within the past month.""" + today = datetime.datetime.now().replace(tzinfo=None) + relativedelta(days=1) + last_month = today - relativedelta(months=1) + solved = attempts.filter(datetime__gte=last_month.date()) + return solved + + def get_questions_answered_in_past_month(profile, user_attempts=None): """Get the number questions successfully answered in the past month.""" if user_attempts is None: - user_attempts = Attempt.objects.filter(profile=profile) - - today = datetime.datetime.now().replace(tzinfo=None) + relativedelta(days=1) - last_month = today - relativedelta(months=1) - solved = user_attempts.filter(datetime__gte=last_month.date(), passed_tests=True) + user_attempts = Attempt.objects.filter(profile=profile, passed_tests=True) + solved = filter_attempts_in_past_month(user_attempts) return len(solved) diff --git a/codewof/programming/content/en/address/question.md b/codewof/programming/content/en/address/question.md new file mode 100644 index 000000000..12399668c --- /dev/null +++ b/codewof/programming/content/en/address/question.md @@ -0,0 +1,9 @@ +# Address + +Write a program that asks for four inputs; a person's name, street address, city and postcode, and **prints** out a 'letter address' for them in the following form: + +- First line: name with first letters capitalized +- Second line: street address with first letters capitalized +- Third line: city in UPPERCASE followed by postcode with one space between + +*Hint: the methods* `str.upper()` *and* `str.title()` *may be helpful.* diff --git a/codewof/programming/content/en/address/solution.py b/codewof/programming/content/en/address/solution.py new file mode 100644 index 000000000..25c7ecbd5 --- /dev/null +++ b/codewof/programming/content/en/address/solution.py @@ -0,0 +1,7 @@ +name = input("Name: ").title() +address = input("Street Address: ").title() +city = input("City: ").upper() +postcode = input("Postcode: ") +print(name) +print(address) +print(city + " " + postcode) diff --git a/codewof/programming/content/en/address/test-case-1-input.txt b/codewof/programming/content/en/address/test-case-1-input.txt new file mode 100644 index 000000000..0978c9f6c --- /dev/null +++ b/codewof/programming/content/en/address/test-case-1-input.txt @@ -0,0 +1,4 @@ +John Smith +34 Westernway Tce +CHRISTCHURCH +8043 diff --git a/codewof/programming/content/en/address/test-case-1-output.txt b/codewof/programming/content/en/address/test-case-1-output.txt new file mode 100644 index 000000000..272a68ad8 --- /dev/null +++ b/codewof/programming/content/en/address/test-case-1-output.txt @@ -0,0 +1,3 @@ +John Smith +34 Westernway Tce +CHRISTCHURCH 8043 diff --git a/codewof/programming/content/en/address/test-case-2-input.txt b/codewof/programming/content/en/address/test-case-2-input.txt new file mode 100644 index 000000000..c0fe67ffd --- /dev/null +++ b/codewof/programming/content/en/address/test-case-2-input.txt @@ -0,0 +1,4 @@ +GOLDILOCKS +9 IMAGINATION PL +fairyland +9032 diff --git a/codewof/programming/content/en/address/test-case-2-output.txt b/codewof/programming/content/en/address/test-case-2-output.txt new file mode 100644 index 000000000..1b661ac7e --- /dev/null +++ b/codewof/programming/content/en/address/test-case-2-output.txt @@ -0,0 +1,3 @@ +Goldilocks +9 Imagination Pl +FAIRYLAND 9032 diff --git a/codewof/programming/content/en/address/test-case-3-input.txt b/codewof/programming/content/en/address/test-case-3-input.txt new file mode 100644 index 000000000..65ec7282f --- /dev/null +++ b/codewof/programming/content/en/address/test-case-3-input.txt @@ -0,0 +1,4 @@ +santa claus +1 pole rd +the north pole +0001 diff --git a/codewof/programming/content/en/address/test-case-3-output.txt b/codewof/programming/content/en/address/test-case-3-output.txt new file mode 100644 index 000000000..9306434e0 --- /dev/null +++ b/codewof/programming/content/en/address/test-case-3-output.txt @@ -0,0 +1,3 @@ +Santa Claus +1 Pole Rd +THE NORTH POLE 0001 diff --git a/codewof/programming/content/en/address/test-case-4-input.txt b/codewof/programming/content/en/address/test-case-4-input.txt new file mode 100644 index 000000000..95f3927a1 --- /dev/null +++ b/codewof/programming/content/en/address/test-case-4-input.txt @@ -0,0 +1,4 @@ +santa claus +1 pole rd +the north pole +000a diff --git a/codewof/programming/content/en/address/test-case-4-output.txt b/codewof/programming/content/en/address/test-case-4-output.txt new file mode 100644 index 000000000..7fcd07a19 --- /dev/null +++ b/codewof/programming/content/en/address/test-case-4-output.txt @@ -0,0 +1,3 @@ +Santa Claus +1 Pole Rd +THE NORTH POLE 000a diff --git a/codewof/programming/content/en/beep-boop/question.md b/codewof/programming/content/en/beep-boop/question.md new file mode 100644 index 000000000..216f44c7f --- /dev/null +++ b/codewof/programming/content/en/beep-boop/question.md @@ -0,0 +1,5 @@ +# Beep Boop + +Write a function `beep_boop(binary)` that takes a string, `binary`, representing output from a computer and **prints** a series sounds for a speaker to make. + +Ones `1` should be translated to beeps. Zeroes `0` should be translated to boops. Each beep/boop should be printed on a new line. diff --git a/codewof/programming/content/en/beep-boop/solution.py b/codewof/programming/content/en/beep-boop/solution.py new file mode 100644 index 000000000..c5961ec16 --- /dev/null +++ b/codewof/programming/content/en/beep-boop/solution.py @@ -0,0 +1,6 @@ +def beep_boop(binary): + for digit in binary: + if digit == '1': + print('beep') + if digit == '0': + print('boop') diff --git a/codewof/programming/content/en/beep-boop/test-case-1-code.txt b/codewof/programming/content/en/beep-boop/test-case-1-code.txt new file mode 100644 index 000000000..9afe977df --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-1-code.txt @@ -0,0 +1 @@ +beep_boop('11001') diff --git a/codewof/programming/content/en/beep-boop/test-case-1-output.txt b/codewof/programming/content/en/beep-boop/test-case-1-output.txt new file mode 100644 index 000000000..898658cbf --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-1-output.txt @@ -0,0 +1,5 @@ +beep +beep +boop +boop +beep diff --git a/codewof/programming/content/en/beep-boop/test-case-2-code.txt b/codewof/programming/content/en/beep-boop/test-case-2-code.txt new file mode 100644 index 000000000..cf5e2da04 --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-2-code.txt @@ -0,0 +1 @@ +beep_boop('1111') diff --git a/codewof/programming/content/en/beep-boop/test-case-2-output.txt b/codewof/programming/content/en/beep-boop/test-case-2-output.txt new file mode 100644 index 000000000..eb314dea1 --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-2-output.txt @@ -0,0 +1,4 @@ +beep +beep +beep +beep diff --git a/codewof/programming/content/en/beep-boop/test-case-3-code.txt b/codewof/programming/content/en/beep-boop/test-case-3-code.txt new file mode 100644 index 000000000..2ac540072 --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-3-code.txt @@ -0,0 +1 @@ +beep_boop('0000') diff --git a/codewof/programming/content/en/beep-boop/test-case-3-output.txt b/codewof/programming/content/en/beep-boop/test-case-3-output.txt new file mode 100644 index 000000000..227088545 --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-3-output.txt @@ -0,0 +1,4 @@ +boop +boop +boop +boop diff --git a/codewof/programming/content/en/beep-boop/test-case-4-code.txt b/codewof/programming/content/en/beep-boop/test-case-4-code.txt new file mode 100644 index 000000000..9df31d68f --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-4-code.txt @@ -0,0 +1 @@ +beep_boop('1abc1') diff --git a/codewof/programming/content/en/beep-boop/test-case-4-output.txt b/codewof/programming/content/en/beep-boop/test-case-4-output.txt new file mode 100644 index 000000000..e718933c9 --- /dev/null +++ b/codewof/programming/content/en/beep-boop/test-case-4-output.txt @@ -0,0 +1,2 @@ +beep +beep diff --git a/codewof/programming/content/en/calculate-mean/question.md b/codewof/programming/content/en/calculate-mean/question.md new file mode 100644 index 000000000..54f9504b9 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/question.md @@ -0,0 +1,7 @@ +# Calculate Mean + +Write a function `mean(items)` that **returns** the average of `items`, a list of numbers. + +The mean is the sum of the items divided by the number of items. Your function will not be tested against an empty list. + +*Hint: the built in functions `sum()` and `len()` may come in handy.* diff --git a/codewof/programming/content/en/calculate-mean/solution.py b/codewof/programming/content/en/calculate-mean/solution.py new file mode 100644 index 000000000..98dfe01ff --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/solution.py @@ -0,0 +1,2 @@ +def mean(items): + return sum(items) / len(items) diff --git a/codewof/programming/content/en/calculate-mean/test-case-1-code.txt b/codewof/programming/content/en/calculate-mean/test-case-1-code.txt new file mode 100644 index 000000000..366ed2975 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-1-code.txt @@ -0,0 +1 @@ +print(mean([2, 2, 2, 2])) diff --git a/codewof/programming/content/en/calculate-mean/test-case-1-output.txt b/codewof/programming/content/en/calculate-mean/test-case-1-output.txt new file mode 100644 index 000000000..cd5ac039d --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-1-output.txt @@ -0,0 +1 @@ +2.0 diff --git a/codewof/programming/content/en/calculate-mean/test-case-2-code.txt b/codewof/programming/content/en/calculate-mean/test-case-2-code.txt new file mode 100644 index 000000000..4151612b3 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-2-code.txt @@ -0,0 +1 @@ +print(mean([0, 5, 0, 5])) diff --git a/codewof/programming/content/en/calculate-mean/test-case-2-output.txt b/codewof/programming/content/en/calculate-mean/test-case-2-output.txt new file mode 100644 index 000000000..95e3ba819 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-2-output.txt @@ -0,0 +1 @@ +2.5 diff --git a/codewof/programming/content/en/calculate-mean/test-case-3-code.txt b/codewof/programming/content/en/calculate-mean/test-case-3-code.txt new file mode 100644 index 000000000..85ccc1b99 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-3-code.txt @@ -0,0 +1 @@ +print(mean([1])) diff --git a/codewof/programming/content/en/calculate-mean/test-case-3-output.txt b/codewof/programming/content/en/calculate-mean/test-case-3-output.txt new file mode 100644 index 000000000..d3827e75a --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-3-output.txt @@ -0,0 +1 @@ +1.0 diff --git a/codewof/programming/content/en/calculate-mean/test-case-4-code.txt b/codewof/programming/content/en/calculate-mean/test-case-4-code.txt new file mode 100644 index 000000000..b9110b9c3 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-4-code.txt @@ -0,0 +1,3 @@ +one_to_twenty = list(range(1, 21)) +print(one_to_twenty) +print(mean(one_to_twenty)) diff --git a/codewof/programming/content/en/calculate-mean/test-case-4-output.txt b/codewof/programming/content/en/calculate-mean/test-case-4-output.txt new file mode 100644 index 000000000..5e5be3d23 --- /dev/null +++ b/codewof/programming/content/en/calculate-mean/test-case-4-output.txt @@ -0,0 +1,2 @@ +[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] +10.5 diff --git a/codewof/programming/content/en/change-dogs/question.md b/codewof/programming/content/en/change-dogs/question.md new file mode 100644 index 000000000..9d172b640 --- /dev/null +++ b/codewof/programming/content/en/change-dogs/question.md @@ -0,0 +1,5 @@ +# Change Dogs + +Write a function `change_dogs(sentence)` that takes a string as an argument and returns a new string with all occurrences of `'dog'` replaced with `'cat'`. + +*Hint: the string method `str.replace` might be useful.* diff --git a/codewof/programming/content/en/change-dogs/solution.py b/codewof/programming/content/en/change-dogs/solution.py new file mode 100644 index 000000000..319654b4b --- /dev/null +++ b/codewof/programming/content/en/change-dogs/solution.py @@ -0,0 +1,2 @@ +def change_dogs(sentence): + return sentence.replace("dog", "cat") diff --git a/codewof/programming/content/en/change-dogs/test-case-1-code.txt b/codewof/programming/content/en/change-dogs/test-case-1-code.txt new file mode 100644 index 000000000..fb3a7e85a --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-1-code.txt @@ -0,0 +1 @@ +print(change_dogs("That dog is adorable!")) diff --git a/codewof/programming/content/en/change-dogs/test-case-1-output.txt b/codewof/programming/content/en/change-dogs/test-case-1-output.txt new file mode 100644 index 000000000..79bdc4a4a --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-1-output.txt @@ -0,0 +1 @@ +That cat is adorable! diff --git a/codewof/programming/content/en/change-dogs/test-case-2-code.txt b/codewof/programming/content/en/change-dogs/test-case-2-code.txt new file mode 100644 index 000000000..0e5886712 --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-2-code.txt @@ -0,0 +1 @@ +print(change_dogs("That cat is adorable!")) diff --git a/codewof/programming/content/en/change-dogs/test-case-2-output.txt b/codewof/programming/content/en/change-dogs/test-case-2-output.txt new file mode 100644 index 000000000..79bdc4a4a --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-2-output.txt @@ -0,0 +1 @@ +That cat is adorable! diff --git a/codewof/programming/content/en/change-dogs/test-case-3-code.txt b/codewof/programming/content/en/change-dogs/test-case-3-code.txt new file mode 100644 index 000000000..893f20789 --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-3-code.txt @@ -0,0 +1 @@ +print(change_dogs("cat dog cat dog")) diff --git a/codewof/programming/content/en/change-dogs/test-case-3-output.txt b/codewof/programming/content/en/change-dogs/test-case-3-output.txt new file mode 100644 index 000000000..4c7bfc9b8 --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-3-output.txt @@ -0,0 +1 @@ +cat cat cat cat diff --git a/codewof/programming/content/en/change-dogs/test-case-4-code.txt b/codewof/programming/content/en/change-dogs/test-case-4-code.txt new file mode 100644 index 000000000..c14be48da --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-4-code.txt @@ -0,0 +1,3 @@ +my_sentence = 'The quick brown fox jumps over the lazy dog!' +print(my_sentence) +print(change_dogs(my_sentence)) diff --git a/codewof/programming/content/en/change-dogs/test-case-4-output.txt b/codewof/programming/content/en/change-dogs/test-case-4-output.txt new file mode 100644 index 000000000..9a92b50a3 --- /dev/null +++ b/codewof/programming/content/en/change-dogs/test-case-4-output.txt @@ -0,0 +1,2 @@ +The quick brown fox jumps over the lazy dog! +The quick brown fox jumps over the lazy cat! diff --git a/codewof/programming/content/en/days-of-coffee/initial.py b/codewof/programming/content/en/days-of-coffee/initial.py new file mode 100644 index 000000000..2f1c9e7ea --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/initial.py @@ -0,0 +1,6 @@ +def days_of_coffee(money, coffee_price): + days = 0 + while money >= 0: + money -= coffee_price + days += 1 + print('I can get coffee {} days in a row with ${:.2f} leftover.'.format(days, money)) diff --git a/codewof/programming/content/en/days-of-coffee/question.md b/codewof/programming/content/en/days-of-coffee/question.md new file mode 100644 index 000000000..f801a4457 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/question.md @@ -0,0 +1,12 @@ +# Days of Coffee + +The following function `days_of_coffee(money, coffee_price)` takes two arguments: + +- A float, `money`, your coffee budget +- A float, `coffee_price`, the price of a coffee + +It prints how many days in a row you can get coffee along with the leftover change. The printed message should be this in format: `I can get coffee {number of days} days in a row with ${spare change} leftover.` + +There is a bug! It currently adds one extra coffee and gives us negative change. + +Find the bug (or bugs) to fix this function so that it passes all of the tests. diff --git a/codewof/programming/content/en/days-of-coffee/solution.py b/codewof/programming/content/en/days-of-coffee/solution.py new file mode 100644 index 000000000..3bd7ad1c4 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/solution.py @@ -0,0 +1,6 @@ +def days_of_coffee(money, coffee_price): + days = 0 + while money >= coffee_price: + money -= coffee_price + days += 1 + print('I can get coffee {} days in a row with ${:.2f} leftover.'.format(days, money)) diff --git a/codewof/programming/content/en/days-of-coffee/test-case-1-code.txt b/codewof/programming/content/en/days-of-coffee/test-case-1-code.txt new file mode 100644 index 000000000..904bc89b7 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-1-code.txt @@ -0,0 +1 @@ +days_of_coffee(8.00, 4.00) diff --git a/codewof/programming/content/en/days-of-coffee/test-case-1-output.txt b/codewof/programming/content/en/days-of-coffee/test-case-1-output.txt new file mode 100644 index 000000000..72828acff --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-1-output.txt @@ -0,0 +1 @@ +I can get coffee 2 days in a row with $0.00 leftover. diff --git a/codewof/programming/content/en/days-of-coffee/test-case-2-code.txt b/codewof/programming/content/en/days-of-coffee/test-case-2-code.txt new file mode 100644 index 000000000..6d260b9af --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-2-code.txt @@ -0,0 +1 @@ +days_of_coffee(12.49, 2.50) diff --git a/codewof/programming/content/en/days-of-coffee/test-case-2-output.txt b/codewof/programming/content/en/days-of-coffee/test-case-2-output.txt new file mode 100644 index 000000000..d8ccc53ce --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-2-output.txt @@ -0,0 +1 @@ +I can get coffee 4 days in a row with $2.49 leftover. diff --git a/codewof/programming/content/en/days-of-coffee/test-case-3-code.txt b/codewof/programming/content/en/days-of-coffee/test-case-3-code.txt new file mode 100644 index 000000000..bdea44cd8 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-3-code.txt @@ -0,0 +1 @@ +days_of_coffee(12.50, 2.50) diff --git a/codewof/programming/content/en/days-of-coffee/test-case-3-output.txt b/codewof/programming/content/en/days-of-coffee/test-case-3-output.txt new file mode 100644 index 000000000..ee1b5e1df --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-3-output.txt @@ -0,0 +1 @@ +I can get coffee 5 days in a row with $0.00 leftover. diff --git a/codewof/programming/content/en/days-of-coffee/test-case-4-code.txt b/codewof/programming/content/en/days-of-coffee/test-case-4-code.txt new file mode 100644 index 000000000..0a4d63fee --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-4-code.txt @@ -0,0 +1 @@ +days_of_coffee(12.51, 2.50) diff --git a/codewof/programming/content/en/days-of-coffee/test-case-4-output.txt b/codewof/programming/content/en/days-of-coffee/test-case-4-output.txt new file mode 100644 index 000000000..03bc6bad2 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-4-output.txt @@ -0,0 +1 @@ +I can get coffee 5 days in a row with $0.01 leftover. diff --git a/codewof/programming/content/en/days-of-coffee/test-case-5-code.txt b/codewof/programming/content/en/days-of-coffee/test-case-5-code.txt new file mode 100644 index 000000000..2bd15c078 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-5-code.txt @@ -0,0 +1 @@ +days_of_coffee(0, 2.50) diff --git a/codewof/programming/content/en/days-of-coffee/test-case-5-output.txt b/codewof/programming/content/en/days-of-coffee/test-case-5-output.txt new file mode 100644 index 000000000..a9a910413 --- /dev/null +++ b/codewof/programming/content/en/days-of-coffee/test-case-5-output.txt @@ -0,0 +1 @@ +I can get coffee 0 days in a row with $0.00 leftover. diff --git a/codewof/programming/content/en/difficulty-levels.yaml b/codewof/programming/content/en/difficulty-levels.yaml new file mode 100644 index 000000000..ac08d4492 --- /dev/null +++ b/codewof/programming/content/en/difficulty-levels.yaml @@ -0,0 +1,11 @@ +difficulty-0: + name: "Easy" + +difficulty-1: + name: "Moderate" + +difficulty-2: + name: "Difficult" + +difficulty-3: + name: "Complex" diff --git a/codewof/programming/content/en/farewell/question.md b/codewof/programming/content/en/farewell/question.md new file mode 100644 index 000000000..78994c908 --- /dev/null +++ b/codewof/programming/content/en/farewell/question.md @@ -0,0 +1,4 @@ +# Farewell +Write a function `farewell(name)` that takes a name as an argument and **prints** `Goodbye {name}!`. For example, if the +function was called using `farewell("Alex")` then your function should **print** `Goodbye Alex!`. You can assume that `name` +will be a string. diff --git a/codewof/programming/content/en/farewell/solution.py b/codewof/programming/content/en/farewell/solution.py new file mode 100644 index 000000000..35ace980a --- /dev/null +++ b/codewof/programming/content/en/farewell/solution.py @@ -0,0 +1,2 @@ +def farewell(name): + print("Goodbye " + name + "!") diff --git a/codewof/programming/content/en/farewell/test-case-1-code.txt b/codewof/programming/content/en/farewell/test-case-1-code.txt new file mode 100644 index 000000000..bc17d8270 --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-1-code.txt @@ -0,0 +1 @@ +farewell("Alex") diff --git a/codewof/programming/content/en/farewell/test-case-1-output.txt b/codewof/programming/content/en/farewell/test-case-1-output.txt new file mode 100644 index 000000000..4b27e332e --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-1-output.txt @@ -0,0 +1 @@ +Goodbye Alex! diff --git a/codewof/programming/content/en/farewell/test-case-2-code.txt b/codewof/programming/content/en/farewell/test-case-2-code.txt new file mode 100644 index 000000000..f092d4576 --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-2-code.txt @@ -0,0 +1 @@ +farewell("Jenny") diff --git a/codewof/programming/content/en/farewell/test-case-2-output.txt b/codewof/programming/content/en/farewell/test-case-2-output.txt new file mode 100644 index 000000000..00203b582 --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-2-output.txt @@ -0,0 +1 @@ +Goodbye Jenny! diff --git a/codewof/programming/content/en/farewell/test-case-3-code.txt b/codewof/programming/content/en/farewell/test-case-3-code.txt new file mode 100644 index 000000000..ee1976f22 --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-3-code.txt @@ -0,0 +1 @@ +farewell('Santa Claus') diff --git a/codewof/programming/content/en/farewell/test-case-3-output.txt b/codewof/programming/content/en/farewell/test-case-3-output.txt new file mode 100644 index 000000000..42ad8ac0b --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-3-output.txt @@ -0,0 +1 @@ +Goodbye Santa Claus! diff --git a/codewof/programming/content/en/farewell/test-case-4-code.txt b/codewof/programming/content/en/farewell/test-case-4-code.txt new file mode 100644 index 000000000..9dba78c49 --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-4-code.txt @@ -0,0 +1 @@ +farewell('user_123') diff --git a/codewof/programming/content/en/farewell/test-case-4-output.txt b/codewof/programming/content/en/farewell/test-case-4-output.txt new file mode 100644 index 000000000..5431789b5 --- /dev/null +++ b/codewof/programming/content/en/farewell/test-case-4-output.txt @@ -0,0 +1 @@ +Goodbye user_123! diff --git a/codewof/programming/content/en/forest/initial.py b/codewof/programming/content/en/forest/initial.py new file mode 100644 index 000000000..73c2d1409 --- /dev/null +++ b/codewof/programming/content/en/forest/initial.py @@ -0,0 +1,4 @@ +def is_forest(items): + tree_count = items.count("tree") + tree_count = items.count("Tree") + return tree_count > 1 diff --git a/codewof/programming/content/en/forest/question.md b/codewof/programming/content/en/forest/question.md new file mode 100644 index 000000000..b466f3cb2 --- /dev/null +++ b/codewof/programming/content/en/forest/question.md @@ -0,0 +1,8 @@ +# Forest + +Write a function `is_forest(items)` that takes a Python list of strings, `items`, +and **returns** a boolean (True/False) telling us if the list contents form a forest. + +In this scenario any group of items with more than one tree is a forest. A tree can be lowercase or start with a capital letter. + +This function looks like it should work, but there are problems with it. Find the bug (or bugs) to fix the function. diff --git a/codewof/programming/content/en/forest/solution.py b/codewof/programming/content/en/forest/solution.py new file mode 100644 index 000000000..8fc698cd9 --- /dev/null +++ b/codewof/programming/content/en/forest/solution.py @@ -0,0 +1,4 @@ +def is_forest(items): + tree_count = items.count("tree") + tree_count += items.count("Tree") + return tree_count > 1 diff --git a/codewof/programming/content/en/forest/test-case-1-code.txt b/codewof/programming/content/en/forest/test-case-1-code.txt new file mode 100644 index 000000000..9af585833 --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-1-code.txt @@ -0,0 +1 @@ +print(is_forest(["bush", "river", "tree", "tree", "shrub", "tree"])) diff --git a/codewof/programming/content/en/forest/test-case-1-output.txt b/codewof/programming/content/en/forest/test-case-1-output.txt new file mode 100644 index 000000000..0ca95142b --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-1-output.txt @@ -0,0 +1 @@ +True diff --git a/codewof/programming/content/en/forest/test-case-2-code.txt b/codewof/programming/content/en/forest/test-case-2-code.txt new file mode 100644 index 000000000..3f603e746 --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-2-code.txt @@ -0,0 +1 @@ +print(is_forest(["bush", "river", "tree", "tree", "shrub"])) diff --git a/codewof/programming/content/en/forest/test-case-2-output.txt b/codewof/programming/content/en/forest/test-case-2-output.txt new file mode 100644 index 000000000..0ca95142b --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-2-output.txt @@ -0,0 +1 @@ +True diff --git a/codewof/programming/content/en/forest/test-case-3-code.txt b/codewof/programming/content/en/forest/test-case-3-code.txt new file mode 100644 index 000000000..fc2446c8e --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-3-code.txt @@ -0,0 +1 @@ +print(is_forest(["Bush", "River", "Tree", "Tree", "Shrub"])) diff --git a/codewof/programming/content/en/forest/test-case-3-output.txt b/codewof/programming/content/en/forest/test-case-3-output.txt new file mode 100644 index 000000000..0ca95142b --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-3-output.txt @@ -0,0 +1 @@ +True diff --git a/codewof/programming/content/en/forest/test-case-4-code.txt b/codewof/programming/content/en/forest/test-case-4-code.txt new file mode 100644 index 000000000..223259c6f --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-4-code.txt @@ -0,0 +1 @@ +print(is_forest(["Tree", "tree"])) diff --git a/codewof/programming/content/en/forest/test-case-4-output.txt b/codewof/programming/content/en/forest/test-case-4-output.txt new file mode 100644 index 000000000..0ca95142b --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-4-output.txt @@ -0,0 +1 @@ +True diff --git a/codewof/programming/content/en/forest/test-case-5-code.txt b/codewof/programming/content/en/forest/test-case-5-code.txt new file mode 100644 index 000000000..5488192ce --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-5-code.txt @@ -0,0 +1 @@ +print(is_forest(["bush", "river", "tree", "shrub", ])) diff --git a/codewof/programming/content/en/forest/test-case-5-output.txt b/codewof/programming/content/en/forest/test-case-5-output.txt new file mode 100644 index 000000000..bc59c12aa --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-5-output.txt @@ -0,0 +1 @@ +False diff --git a/codewof/programming/content/en/forest/test-case-6-code.txt b/codewof/programming/content/en/forest/test-case-6-code.txt new file mode 100644 index 000000000..b0a9b303b --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-6-code.txt @@ -0,0 +1 @@ +print(is_forest(["Bush", "River", "Tree", "Shrub", ])) diff --git a/codewof/programming/content/en/forest/test-case-6-output.txt b/codewof/programming/content/en/forest/test-case-6-output.txt new file mode 100644 index 000000000..bc59c12aa --- /dev/null +++ b/codewof/programming/content/en/forest/test-case-6-output.txt @@ -0,0 +1 @@ +False diff --git a/codewof/programming/content/en/form-circle/question.md b/codewof/programming/content/en/form-circle/question.md new file mode 100644 index 000000000..cbd5294cc --- /dev/null +++ b/codewof/programming/content/en/form-circle/question.md @@ -0,0 +1,7 @@ +# Form Circle + +A class is outside in a line to play duck duck goose. They must make a circle in order to start. + +Write a function `form_circle(students)` that takes a list of students, `students`, and **prints** `{first student} and {last student} should sit next to each to make a circle.` + +Your function will not be tested with less than 3 students. diff --git a/codewof/programming/content/en/form-circle/solution.py b/codewof/programming/content/en/form-circle/solution.py new file mode 100644 index 000000000..4285dc954 --- /dev/null +++ b/codewof/programming/content/en/form-circle/solution.py @@ -0,0 +1,2 @@ +def form_circle(students): + print(students[0] + " and " + students[-1] + " should sit next to each to make a circle.") diff --git a/codewof/programming/content/en/form-circle/test-case-1-code.txt b/codewof/programming/content/en/form-circle/test-case-1-code.txt new file mode 100644 index 000000000..fdb41e686 --- /dev/null +++ b/codewof/programming/content/en/form-circle/test-case-1-code.txt @@ -0,0 +1 @@ +form_circle(["Tom", "Tim", "Tiffany", "Tabitha", "Theo", "Tyler", "Tilly", "Toby"]) diff --git a/codewof/programming/content/en/form-circle/test-case-1-output.txt b/codewof/programming/content/en/form-circle/test-case-1-output.txt new file mode 100644 index 000000000..5a9c51731 --- /dev/null +++ b/codewof/programming/content/en/form-circle/test-case-1-output.txt @@ -0,0 +1 @@ +Tom and Toby should sit next to each to make a circle. diff --git a/codewof/programming/content/en/form-circle/test-case-2-code.txt b/codewof/programming/content/en/form-circle/test-case-2-code.txt new file mode 100644 index 000000000..37bad704a --- /dev/null +++ b/codewof/programming/content/en/form-circle/test-case-2-code.txt @@ -0,0 +1 @@ +form_circle(["Tom", "Sally", "Sarah"]) diff --git a/codewof/programming/content/en/form-circle/test-case-2-output.txt b/codewof/programming/content/en/form-circle/test-case-2-output.txt new file mode 100644 index 000000000..f03309169 --- /dev/null +++ b/codewof/programming/content/en/form-circle/test-case-2-output.txt @@ -0,0 +1 @@ +Tom and Sarah should sit next to each to make a circle. diff --git a/codewof/programming/content/en/form-circle/test-case-3-code.txt b/codewof/programming/content/en/form-circle/test-case-3-code.txt new file mode 100644 index 000000000..4fe9939b0 --- /dev/null +++ b/codewof/programming/content/en/form-circle/test-case-3-code.txt @@ -0,0 +1 @@ +form_circle(["Tim", "Tom", "Tabitha", "Trent", "Theo", "Tilly", "Tori"]) diff --git a/codewof/programming/content/en/form-circle/test-case-3-output.txt b/codewof/programming/content/en/form-circle/test-case-3-output.txt new file mode 100644 index 000000000..5fac7310f --- /dev/null +++ b/codewof/programming/content/en/form-circle/test-case-3-output.txt @@ -0,0 +1 @@ +Tim and Tori should sit next to each to make a circle. diff --git a/codewof/programming/content/en/lost-cat/question.md b/codewof/programming/content/en/lost-cat/question.md new file mode 100644 index 000000000..29febf2da --- /dev/null +++ b/codewof/programming/content/en/lost-cat/question.md @@ -0,0 +1,6 @@ +# Lost Cat + +Write a program that asks for a search location for a missing cat. If the answer is not `"food bowl"` the program should **print** `"Hmmm they are not in the {location}..."` keep asking until your cat is found. +Once the cat is found your program should **print** `"There you are!"`. + +*Hint: a* `while` *loop is a good type of loop to use when you don't know how many times the loop will run.* diff --git a/codewof/programming/content/en/lost-cat/solution.py b/codewof/programming/content/en/lost-cat/solution.py new file mode 100644 index 000000000..0dca0d6b0 --- /dev/null +++ b/codewof/programming/content/en/lost-cat/solution.py @@ -0,0 +1,5 @@ +place = input("Enter search area: ") +while place != 'food bowl': + print("Hmmm they are not in the " + place + "...") + place = input("Enter search area: ") +print("There you are!") diff --git a/codewof/programming/content/en/lost-cat/test-case-1-input.txt b/codewof/programming/content/en/lost-cat/test-case-1-input.txt new file mode 100644 index 000000000..e0e344c13 --- /dev/null +++ b/codewof/programming/content/en/lost-cat/test-case-1-input.txt @@ -0,0 +1,4 @@ +kitchen +shower +sunroom +food bowl diff --git a/codewof/programming/content/en/lost-cat/test-case-1-output.txt b/codewof/programming/content/en/lost-cat/test-case-1-output.txt new file mode 100644 index 000000000..73419a1de --- /dev/null +++ b/codewof/programming/content/en/lost-cat/test-case-1-output.txt @@ -0,0 +1,4 @@ +Hmmm they are not in the kitchen... +Hmmm they are not in the shower... +Hmmm they are not in the sunroom... +There you are! diff --git a/codewof/programming/content/en/lost-cat/test-case-2-input.txt b/codewof/programming/content/en/lost-cat/test-case-2-input.txt new file mode 100644 index 000000000..bf03f5459 --- /dev/null +++ b/codewof/programming/content/en/lost-cat/test-case-2-input.txt @@ -0,0 +1 @@ +food bowl diff --git a/codewof/programming/content/en/lost-cat/test-case-2-output.txt b/codewof/programming/content/en/lost-cat/test-case-2-output.txt new file mode 100644 index 000000000..3311fdc9c --- /dev/null +++ b/codewof/programming/content/en/lost-cat/test-case-2-output.txt @@ -0,0 +1 @@ +There you are! diff --git a/codewof/programming/content/en/lost-cat/test-case-3-input.txt b/codewof/programming/content/en/lost-cat/test-case-3-input.txt new file mode 100644 index 000000000..2f0d87cf0 --- /dev/null +++ b/codewof/programming/content/en/lost-cat/test-case-3-input.txt @@ -0,0 +1,3 @@ +FOOD BOWL +Food Bowl +food bowl diff --git a/codewof/programming/content/en/lost-cat/test-case-3-output.txt b/codewof/programming/content/en/lost-cat/test-case-3-output.txt new file mode 100644 index 000000000..4c28e9cbc --- /dev/null +++ b/codewof/programming/content/en/lost-cat/test-case-3-output.txt @@ -0,0 +1,3 @@ +Hmmm they are not in the FOOD BOWL... +Hmmm they are not in the Food Bowl... +There you are! diff --git a/codewof/programming/content/en/pen-picker/initial.py b/codewof/programming/content/en/pen-picker/initial.py new file mode 100644 index 000000000..4879ad147 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/initial.py @@ -0,0 +1,4 @@ +def pick_pens(writing_colours, is_marking): + if is_marking is True: + writing_colours.add(red) + return writing_colours diff --git a/codewof/programming/content/en/pen-picker/question.md b/codewof/programming/content/en/pen-picker/question.md new file mode 100644 index 000000000..545192696 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/question.md @@ -0,0 +1,6 @@ +# Pen Picker + +The following function `pick_pens(writing_colours, is_marking)` takes a list of strings representing pen colours for writing, `writing_colours`, and a boolean (True/False) for if we are marking a student's work. +It should **return** the list `writing_colours` with `'red'` appended to the end if we are marking. + +Find the bug (or bugs) to fix this function so that it passes all of the tests. diff --git a/codewof/programming/content/en/pen-picker/solution.py b/codewof/programming/content/en/pen-picker/solution.py new file mode 100644 index 000000000..8a5b2b1ca --- /dev/null +++ b/codewof/programming/content/en/pen-picker/solution.py @@ -0,0 +1,4 @@ +def pick_pens(writing_colours, is_marking): + if is_marking is True: + writing_colours.append('red') + return writing_colours diff --git a/codewof/programming/content/en/pen-picker/test-case-1-code.txt b/codewof/programming/content/en/pen-picker/test-case-1-code.txt new file mode 100644 index 000000000..f939c60f2 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-1-code.txt @@ -0,0 +1 @@ +print(pick_pens(['blue', 'black'], True)) diff --git a/codewof/programming/content/en/pen-picker/test-case-1-output.txt b/codewof/programming/content/en/pen-picker/test-case-1-output.txt new file mode 100644 index 000000000..6b10dea94 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-1-output.txt @@ -0,0 +1 @@ +['blue', 'black', 'red'] diff --git a/codewof/programming/content/en/pen-picker/test-case-2-code.txt b/codewof/programming/content/en/pen-picker/test-case-2-code.txt new file mode 100644 index 000000000..f9d183a24 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-2-code.txt @@ -0,0 +1 @@ +print(pick_pens(['blue', 'green'], False)) diff --git a/codewof/programming/content/en/pen-picker/test-case-2-output.txt b/codewof/programming/content/en/pen-picker/test-case-2-output.txt new file mode 100644 index 000000000..436f5d365 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-2-output.txt @@ -0,0 +1 @@ +['blue', 'green'] diff --git a/codewof/programming/content/en/pen-picker/test-case-3-code.txt b/codewof/programming/content/en/pen-picker/test-case-3-code.txt new file mode 100644 index 000000000..4847e8e3e --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-3-code.txt @@ -0,0 +1 @@ +print(pick_pens([], True)) diff --git a/codewof/programming/content/en/pen-picker/test-case-3-output.txt b/codewof/programming/content/en/pen-picker/test-case-3-output.txt new file mode 100644 index 000000000..f489e6313 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-3-output.txt @@ -0,0 +1 @@ +['red'] diff --git a/codewof/programming/content/en/pen-picker/test-case-4-code.txt b/codewof/programming/content/en/pen-picker/test-case-4-code.txt new file mode 100644 index 000000000..eb6890912 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-4-code.txt @@ -0,0 +1 @@ +print(pick_pens([], False)) diff --git a/codewof/programming/content/en/pen-picker/test-case-4-output.txt b/codewof/programming/content/en/pen-picker/test-case-4-output.txt new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/codewof/programming/content/en/pen-picker/test-case-4-output.txt @@ -0,0 +1 @@ +[] diff --git a/codewof/programming/content/en/print-footprints/question.md b/codewof/programming/content/en/print-footprints/question.md new file mode 100644 index 000000000..14beb42d6 --- /dev/null +++ b/codewof/programming/content/en/print-footprints/question.md @@ -0,0 +1,4 @@ +# Print Footprints + +Write a function `print_footprints(steps)` that takes `steps`, an integer, as an argument and **prints** a string that looks like footprints. +The string you print should be `'= '` repeated once for each step taken. diff --git a/codewof/programming/content/en/print-footprints/solution.py b/codewof/programming/content/en/print-footprints/solution.py new file mode 100644 index 000000000..324e35912 --- /dev/null +++ b/codewof/programming/content/en/print-footprints/solution.py @@ -0,0 +1,2 @@ +def print_footprints(steps): + print('= ' * steps) diff --git a/codewof/programming/content/en/print-footprints/test-case-1-code.txt b/codewof/programming/content/en/print-footprints/test-case-1-code.txt new file mode 100644 index 000000000..c831da9af --- /dev/null +++ b/codewof/programming/content/en/print-footprints/test-case-1-code.txt @@ -0,0 +1 @@ +print_footprints(10) diff --git a/codewof/programming/content/en/print-footprints/test-case-1-output.txt b/codewof/programming/content/en/print-footprints/test-case-1-output.txt new file mode 100644 index 000000000..4af177648 --- /dev/null +++ b/codewof/programming/content/en/print-footprints/test-case-1-output.txt @@ -0,0 +1 @@ += = = = = = = = = = diff --git a/codewof/programming/content/en/print-footprints/test-case-2-code.txt b/codewof/programming/content/en/print-footprints/test-case-2-code.txt new file mode 100644 index 000000000..9ad399f15 --- /dev/null +++ b/codewof/programming/content/en/print-footprints/test-case-2-code.txt @@ -0,0 +1 @@ +print_footprints(1) diff --git a/codewof/programming/content/en/print-footprints/test-case-2-output.txt b/codewof/programming/content/en/print-footprints/test-case-2-output.txt new file mode 100644 index 000000000..3134d36bf --- /dev/null +++ b/codewof/programming/content/en/print-footprints/test-case-2-output.txt @@ -0,0 +1 @@ += diff --git a/codewof/programming/content/en/print-footprints/test-case-3-code.txt b/codewof/programming/content/en/print-footprints/test-case-3-code.txt new file mode 100644 index 000000000..60526a613 --- /dev/null +++ b/codewof/programming/content/en/print-footprints/test-case-3-code.txt @@ -0,0 +1 @@ +print_footprints(0) diff --git a/codewof/programming/content/en/print-footprints/test-case-3-output.txt b/codewof/programming/content/en/print-footprints/test-case-3-output.txt new file mode 100644 index 000000000..e69de29bb diff --git a/codewof/programming/content/en/programming-concepts.yaml b/codewof/programming/content/en/programming-concepts.yaml new file mode 100644 index 000000000..78f77020e --- /dev/null +++ b/codewof/programming/content/en/programming-concepts.yaml @@ -0,0 +1,35 @@ +display-text: + name: Display Text + +functions: + name: Functions + +inputs: + name: Inputs + +conditionals: + name: Conditionals + +single-condition: + name: Single Condition + +multiple-conditions: + name: Multiple Conditions + +advanced-conditionals: + name: Advanced Conditionals + +loops: + name: Loops + +conditional-loops: + name: Conditional Loops + +range-loops: + name: Range Loops + +string-operations: + name: String Operations + +lists: + name: Lists diff --git a/codewof/programming/content/en/question-contexts.yaml b/codewof/programming/content/en/question-contexts.yaml new file mode 100644 index 000000000..b37816a44 --- /dev/null +++ b/codewof/programming/content/en/question-contexts.yaml @@ -0,0 +1,15 @@ +mathematics: + name: Mathematics +geometry: + name: Geometry +basic-geometry: + name: Basic Geometry +advanced-geometry: + name: Advanced Geometry +simple-mathematics: + name: Simple Mathematics +advanced-mathematics: + name: Advanced Mathematics + +real-world-applications: + name: Real World Applications diff --git a/codewof/programming/content/en/remove-seal/question.md b/codewof/programming/content/en/remove-seal/question.md new file mode 100644 index 000000000..3b0beeac2 --- /dev/null +++ b/codewof/programming/content/en/remove-seal/question.md @@ -0,0 +1,5 @@ +# Remove Seal + +Write a function `remove_seal(container)` that takes a list, `container`, and removes the string `seal` if it is the last item in the list. + +**Your function should not return or print anything**; *it must modify the list it is given.* diff --git a/codewof/programming/content/en/remove-seal/solution.py b/codewof/programming/content/en/remove-seal/solution.py new file mode 100644 index 000000000..1dfbe155c --- /dev/null +++ b/codewof/programming/content/en/remove-seal/solution.py @@ -0,0 +1,3 @@ +def remove_seal(container): + if container[-1] == 'seal': + container.pop() diff --git a/codewof/programming/content/en/remove-seal/test-case-1-code.txt b/codewof/programming/content/en/remove-seal/test-case-1-code.txt new file mode 100644 index 000000000..47219a4c2 --- /dev/null +++ b/codewof/programming/content/en/remove-seal/test-case-1-code.txt @@ -0,0 +1,3 @@ +test_tube = ['sediment', 'gold', 'murky water', 'seal'] +remove_seal(test_tube) +print(test_tube) diff --git a/codewof/programming/content/en/remove-seal/test-case-1-output.txt b/codewof/programming/content/en/remove-seal/test-case-1-output.txt new file mode 100644 index 000000000..c37e8cd69 --- /dev/null +++ b/codewof/programming/content/en/remove-seal/test-case-1-output.txt @@ -0,0 +1 @@ +['sediment', 'gold', 'murky water'] diff --git a/codewof/programming/content/en/remove-seal/test-case-2-code.txt b/codewof/programming/content/en/remove-seal/test-case-2-code.txt new file mode 100644 index 000000000..f3b2d5fb6 --- /dev/null +++ b/codewof/programming/content/en/remove-seal/test-case-2-code.txt @@ -0,0 +1,3 @@ +test_tube = ['sediment', 'gold', 'murky water', 'air'] +remove_seal(test_tube) +print(test_tube) diff --git a/codewof/programming/content/en/remove-seal/test-case-2-output.txt b/codewof/programming/content/en/remove-seal/test-case-2-output.txt new file mode 100644 index 000000000..b48f1a934 --- /dev/null +++ b/codewof/programming/content/en/remove-seal/test-case-2-output.txt @@ -0,0 +1 @@ +['sediment', 'gold', 'murky water', 'air'] diff --git a/codewof/programming/content/en/remove-seal/test-case-3-code.txt b/codewof/programming/content/en/remove-seal/test-case-3-code.txt new file mode 100644 index 000000000..2481e0ef5 --- /dev/null +++ b/codewof/programming/content/en/remove-seal/test-case-3-code.txt @@ -0,0 +1,3 @@ +test_tube = ['sediment', 'gold', 'murky water', 'seal'] +result = remove_seal(test_tube) +print(result == None) diff --git a/codewof/programming/content/en/remove-seal/test-case-3-output.txt b/codewof/programming/content/en/remove-seal/test-case-3-output.txt new file mode 100644 index 000000000..0ca95142b --- /dev/null +++ b/codewof/programming/content/en/remove-seal/test-case-3-output.txt @@ -0,0 +1 @@ +True diff --git a/codewof/programming/content/en/space-cargo/question.md b/codewof/programming/content/en/space-cargo/question.md new file mode 100644 index 000000000..835166ec4 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/question.md @@ -0,0 +1,9 @@ +# Space Cargo + +Make a function `eject_cargo(cargo, hull_capacity)` that takes two arguments: + +- `cargo`, a Python list of strings representing items stored in the hull of your spaceship. Each item weighs 1 unit. +- `hull_capacity`, an integer representing how much space you have on board. + +If the spaceship has more cargo than hull capacity, it should pop items from the end of `cargo` and **print** `'Ejected {item}'`, until the spaceship can carry the remaining items in it's hull. +Then it should **return** the remaining items. diff --git a/codewof/programming/content/en/space-cargo/solution.py b/codewof/programming/content/en/space-cargo/solution.py new file mode 100644 index 000000000..7f5e00236 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/solution.py @@ -0,0 +1,5 @@ +def eject_cargo(cargo, hull_capacity): + while len(cargo) > hull_capacity: + ejected_item = cargo.pop() + print('Ejected ' + ejected_item) + return cargo diff --git a/codewof/programming/content/en/space-cargo/test-case-1-code.txt b/codewof/programming/content/en/space-cargo/test-case-1-code.txt new file mode 100644 index 000000000..12c3da3b5 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-1-code.txt @@ -0,0 +1 @@ +print(eject_cargo(['Fuel Cell', 'Fuel Cell', 'Space Food', 'Rain Coat'], 2)) diff --git a/codewof/programming/content/en/space-cargo/test-case-1-output.txt b/codewof/programming/content/en/space-cargo/test-case-1-output.txt new file mode 100644 index 000000000..259b3ca8e --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-1-output.txt @@ -0,0 +1,3 @@ +Ejected Rain Coat +Ejected Space Food +['Fuel Cell', 'Fuel Cell'] diff --git a/codewof/programming/content/en/space-cargo/test-case-2-code.txt b/codewof/programming/content/en/space-cargo/test-case-2-code.txt new file mode 100644 index 000000000..cd62f1b0f --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-2-code.txt @@ -0,0 +1 @@ +print(eject_cargo(['Fuel Cell', 'Fuel Cell', 'Space Food', 'Rain Coat'], 4)) diff --git a/codewof/programming/content/en/space-cargo/test-case-2-output.txt b/codewof/programming/content/en/space-cargo/test-case-2-output.txt new file mode 100644 index 000000000..268a2a56a --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-2-output.txt @@ -0,0 +1 @@ +['Fuel Cell', 'Fuel Cell', 'Space Food', 'Rain Coat'] diff --git a/codewof/programming/content/en/space-cargo/test-case-3-code.txt b/codewof/programming/content/en/space-cargo/test-case-3-code.txt new file mode 100644 index 000000000..fe30c8638 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-3-code.txt @@ -0,0 +1 @@ +print(eject_cargo(['First Aid Kit', 'Space Plant', 'Bottled Water', 'Fuel Cell'], 0)) diff --git a/codewof/programming/content/en/space-cargo/test-case-3-output.txt b/codewof/programming/content/en/space-cargo/test-case-3-output.txt new file mode 100644 index 000000000..d64e46181 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-3-output.txt @@ -0,0 +1,5 @@ +Ejected Fuel Cell +Ejected Bottled Water +Ejected Space Plant +Ejected First Aid Kit +[] diff --git a/codewof/programming/content/en/space-cargo/test-case-4-code.txt b/codewof/programming/content/en/space-cargo/test-case-4-code.txt new file mode 100644 index 000000000..6383862a1 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-4-code.txt @@ -0,0 +1 @@ +print(eject_cargo([], 0)) diff --git a/codewof/programming/content/en/space-cargo/test-case-4-output.txt b/codewof/programming/content/en/space-cargo/test-case-4-output.txt new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-4-output.txt @@ -0,0 +1 @@ +[] diff --git a/codewof/programming/content/en/space-cargo/test-case-5-code.txt b/codewof/programming/content/en/space-cargo/test-case-5-code.txt new file mode 100644 index 000000000..8698ffda1 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-5-code.txt @@ -0,0 +1 @@ +print(eject_cargo([], 1)) diff --git a/codewof/programming/content/en/space-cargo/test-case-5-output.txt b/codewof/programming/content/en/space-cargo/test-case-5-output.txt new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/codewof/programming/content/en/space-cargo/test-case-5-output.txt @@ -0,0 +1 @@ +[] diff --git a/codewof/programming/content/en/speeders/question.md b/codewof/programming/content/en/speeders/question.md new file mode 100644 index 000000000..6a6be823f --- /dev/null +++ b/codewof/programming/content/en/speeders/question.md @@ -0,0 +1,5 @@ +# Speeders + +Write a function `speeding_tickets(car_speeds, speed_limit)` that takes a Python list of numbers, `car_speeds`, and **returns** a count of how many drivers will receive a speeding ticket. + +In this scenario anyone going faster than 4km/h over the speed-limit will be ticketed. diff --git a/codewof/programming/content/en/speeders/solution.py b/codewof/programming/content/en/speeders/solution.py new file mode 100644 index 000000000..b74d2988b --- /dev/null +++ b/codewof/programming/content/en/speeders/solution.py @@ -0,0 +1,6 @@ +def speeding_tickets(car_speeds, speed_limit): + total = 0 + for speed in car_speeds: + if speed > speed_limit + 4: + total = total + 1 + return total diff --git a/codewof/programming/content/en/speeders/test-case-1-code.txt b/codewof/programming/content/en/speeders/test-case-1-code.txt new file mode 100644 index 000000000..4db9c7afe --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-1-code.txt @@ -0,0 +1 @@ +print(speeding_tickets([95, 100, 101, 100, 120], 100)) diff --git a/codewof/programming/content/en/speeders/test-case-1-output.txt b/codewof/programming/content/en/speeders/test-case-1-output.txt new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-1-output.txt @@ -0,0 +1 @@ +1 diff --git a/codewof/programming/content/en/speeders/test-case-2-code.txt b/codewof/programming/content/en/speeders/test-case-2-code.txt new file mode 100644 index 000000000..43adefd75 --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-2-code.txt @@ -0,0 +1 @@ +print(speeding_tickets([51, 75, 52, 49, 46], 50)) diff --git a/codewof/programming/content/en/speeders/test-case-2-output.txt b/codewof/programming/content/en/speeders/test-case-2-output.txt new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-2-output.txt @@ -0,0 +1 @@ +1 diff --git a/codewof/programming/content/en/speeders/test-case-3-code.txt b/codewof/programming/content/en/speeders/test-case-3-code.txt new file mode 100644 index 000000000..36226e36d --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-3-code.txt @@ -0,0 +1 @@ +print(speeding_tickets([103, 104, 105, 106], 100)) diff --git a/codewof/programming/content/en/speeders/test-case-3-output.txt b/codewof/programming/content/en/speeders/test-case-3-output.txt new file mode 100644 index 000000000..0cfbf0888 --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-3-output.txt @@ -0,0 +1 @@ +2 diff --git a/codewof/programming/content/en/speeders/test-case-4-code.txt b/codewof/programming/content/en/speeders/test-case-4-code.txt new file mode 100644 index 000000000..922a61d35 --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-4-code.txt @@ -0,0 +1 @@ +print(speeding_tickets([], 100)) diff --git a/codewof/programming/content/en/speeders/test-case-4-output.txt b/codewof/programming/content/en/speeders/test-case-4-output.txt new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/codewof/programming/content/en/speeders/test-case-4-output.txt @@ -0,0 +1 @@ +0 diff --git a/codewof/programming/content/en/storeys-climbed/question.md b/codewof/programming/content/en/storeys-climbed/question.md new file mode 100644 index 000000000..5f5c6a279 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/question.md @@ -0,0 +1,7 @@ +# Storeys Climbed + +Write a program that asks how many metres were climbed during a tramp and converts this to building storeys. + +In this scenario each building storey is 4 metres tall. Only include whole floors, ignoring any portion a floor we may have climbed. + +*Hint: The `//` (floor operator) will round down to the closest whole number.* diff --git a/codewof/programming/content/en/storeys-climbed/solution.py b/codewof/programming/content/en/storeys-climbed/solution.py new file mode 100644 index 000000000..9a2a9b56f --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/solution.py @@ -0,0 +1,2 @@ +height = int(input("Enter mitres climbed: ")) +print(height // 4) diff --git a/codewof/programming/content/en/storeys-climbed/test-case-1-input.txt b/codewof/programming/content/en/storeys-climbed/test-case-1-input.txt new file mode 100644 index 000000000..d411bb7c1 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-1-input.txt @@ -0,0 +1 @@ +400 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-1-output.txt b/codewof/programming/content/en/storeys-climbed/test-case-1-output.txt new file mode 100644 index 000000000..29d6383b5 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-1-output.txt @@ -0,0 +1 @@ +100 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-2-input.txt b/codewof/programming/content/en/storeys-climbed/test-case-2-input.txt new file mode 100644 index 000000000..a210d23e5 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-2-input.txt @@ -0,0 +1 @@ +3724 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-2-output.txt b/codewof/programming/content/en/storeys-climbed/test-case-2-output.txt new file mode 100644 index 000000000..11d171a07 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-2-output.txt @@ -0,0 +1 @@ +931 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-3-input.txt b/codewof/programming/content/en/storeys-climbed/test-case-3-input.txt new file mode 100644 index 000000000..00750edc0 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-3-input.txt @@ -0,0 +1 @@ +3 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-3-output.txt b/codewof/programming/content/en/storeys-climbed/test-case-3-output.txt new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-3-output.txt @@ -0,0 +1 @@ +0 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-4-input.txt b/codewof/programming/content/en/storeys-climbed/test-case-4-input.txt new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-4-input.txt @@ -0,0 +1 @@ +5 diff --git a/codewof/programming/content/en/storeys-climbed/test-case-4-output.txt b/codewof/programming/content/en/storeys-climbed/test-case-4-output.txt new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/codewof/programming/content/en/storeys-climbed/test-case-4-output.txt @@ -0,0 +1 @@ +1 diff --git a/codewof/programming/content/structure/difficulty-levels.yaml b/codewof/programming/content/structure/difficulty-levels.yaml new file mode 100644 index 000000000..67a41cd33 --- /dev/null +++ b/codewof/programming/content/structure/difficulty-levels.yaml @@ -0,0 +1,11 @@ +difficulty-0: + level: 0 + +difficulty-1: + level: 1 + +difficulty-2: + level: 2 + +difficulty-3: + level: 3 diff --git a/codewof/programming/content/structure/programming-concepts.yaml b/codewof/programming/content/structure/programming-concepts.yaml new file mode 100644 index 000000000..94bc802b6 --- /dev/null +++ b/codewof/programming/content/structure/programming-concepts.yaml @@ -0,0 +1,27 @@ +display-text: + number: 1 + +functions: + number: 2 + +inputs: + number: 3 + +conditionals: + number: 4 + children: + - single-condition + - multiple-conditions + - advanced-conditionals + +loops: + number: 5 + children: + - conditional-loops + - range-loops + +string-operations: + number: 6 + +lists: + number: 7 diff --git a/codewof/programming/content/structure/question-contexts.yaml b/codewof/programming/content/structure/question-contexts.yaml new file mode 100644 index 000000000..df7b1e715 --- /dev/null +++ b/codewof/programming/content/structure/question-contexts.yaml @@ -0,0 +1,12 @@ +real-world-applications: + number: 1 + +mathematics: + number: 2 + children: + - simple-mathematics + - advanced-mathematics + - geometry: + children: + - basic-geometry + - advanced-geometry diff --git a/codewof/programming/content/structure/questions.yaml b/codewof/programming/content/structure/questions.yaml index ae2bc5c87..9bc0bf663 100644 --- a/codewof/programming/content/structure/questions.yaml +++ b/codewof/programming/content/structure/questions.yaml @@ -2,6 +2,9 @@ say-hello: type: program test-cases: 1: normal + difficulty: difficulty-0 + concepts: + - display-text # ride-share: # type: function @@ -28,6 +31,12 @@ add-10: test-cases: 1: normal 2: normal + difficulty: difficulty-0 + concepts: + - display-text + - inputs + contexts: + - simple-mathematics doubler: type: function @@ -37,6 +46,11 @@ doubler: 3: normal 4: normal 5: exceptional + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics double-evens: types: @@ -51,6 +65,12 @@ double-evens: 3: normal 4: normal 5: exceptional + difficulty: difficulty-1 + concepts: + - single-condition + - functions + contexts: + - simple-mathematics countdown: types: @@ -63,6 +83,14 @@ countdown: 1: normal 2: normal 3: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - functions + - single-condition + - conditional-loops + contexts: + - simple-mathematics repeated-add-10: types: @@ -73,6 +101,14 @@ repeated-add-10: 3: normal 4: normal 5: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops + contexts: + - simple-mathematics factorial: types: @@ -83,6 +119,13 @@ factorial: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - functions + - multiple-conditions + - conditional-loops + contexts: + - advanced-mathematics ticket-calculator: type: debugging @@ -94,6 +137,12 @@ ticket-calculator: 3: normal 4: normal 5: normal + difficulty: difficulty-0 + concepts: + - functions + - multiple-conditions + contexts: + - real-world-applications rectangle-area: types: @@ -102,6 +151,12 @@ rectangle-area: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + contexts: + - simple-mathematics where-is: types: @@ -109,6 +164,11 @@ where-is: test-cases: 1: normal 2: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - string-operations pieces-of-chocolate: types: @@ -116,6 +176,11 @@ pieces-of-chocolate: test-cases: 1: normal 2: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics print-codewof: types: @@ -126,6 +191,10 @@ print-codewof: - 'return "Welcome to codeWOF!"' test-cases: 1: normal + difficulty: difficulty-1 + concepts: + - functions + - display-text greeting: types: @@ -139,6 +208,11 @@ greeting: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - string-operations string-concatenation: types: @@ -147,6 +221,11 @@ string-concatenation: 1: normal 2: normal 3: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - string-operations bus-info: types: @@ -160,6 +239,12 @@ bus-info: 2: exceptional 3: normal 4: exceptional + difficulty: difficulty-1 + concepts: + - functions + - string-operations + contexts: + - real-world-applications voting-age: types: @@ -171,6 +256,12 @@ voting-age: 4: exceptional 5: exceptional 6: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - inputs + contexts: + - real-world-applications over-the-limit: types: @@ -188,6 +279,12 @@ over-the-limit: 5: exceptional 6: exceptional 7: exceptional + difficulty: difficulty-1 + concepts: + - functions + - single-condition + contexts: + - real-world-applications low-battery: types: @@ -204,6 +301,12 @@ low-battery: 4: normal 5: exceptional 6: exceptional + difficulty: difficulty-1 + concepts: + - functions + - single-condition + contexts: + - real-world-applications full-name: type: debugging @@ -214,6 +317,13 @@ full-name: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - string-operations + contexts: + - real-world-applications hours-to-seconds: type: debugging @@ -225,6 +335,12 @@ hours-to-seconds: 3: normal 4: normal 5: normal + difficulty: difficulty-0 + concepts: + - functions + contexts: + - simple-mathematics + - real-world-applications how-many-dozens: type: debugging @@ -236,6 +352,12 @@ how-many-dozens: 3: exceptional 4: exceptional 5: exceptional + difficulty: difficulty-2 + concepts: + - functions + contexts: + - simple-mathematics + - real-world-applications favourite-number: type: debugging @@ -250,6 +372,10 @@ favourite-number: 6: normal 7: normal 8: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition string-too-long: type: debugging @@ -263,6 +389,12 @@ string-too-long: 5: normal 6: normal 7: normal + difficulty: difficulty-1 + concepts: + - functions + - single-condition + contexts: + - real-world-applications price-in-budget: type: debugging @@ -275,6 +407,12 @@ price-in-budget: 4: normal 5: normal 6: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + contexts: + - real-world-applications find-highest-number: type: debugging @@ -286,6 +424,14 @@ find-highest-number: 3: normal 4: exceptional 5: exceptional + difficulty: difficulty-2 + concepts: + - lists + - range-loops + - single-condition + context: + - simple-mathematics + - real-world-applications shutdown-machine: type: program @@ -296,6 +442,14 @@ shutdown-machine: 4: normal 5: exceptional 6: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops + contexts: + - real-world-applications triangle-pattern: types: @@ -311,6 +465,11 @@ triangle-pattern: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions divisible-by-3: types: @@ -319,6 +478,15 @@ divisible-by-3: 1: normal 2: normal 3: normal + difficulty: difficulty-3 + concepts: + - display-text + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics reverse-string: types: @@ -332,6 +500,12 @@ reverse-string: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - range-loops + - string-operations rotate-words: types: @@ -341,6 +515,12 @@ rotate-words: 2: normal 3: exceptional 4: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - range-loops + - string-operations good-password: types: @@ -351,6 +531,14 @@ good-password: 3: normal 4: normal 5: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops + contexts: + - real-world-applications shopping-list: types: @@ -360,6 +548,15 @@ shopping-list: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - range-loops + - string-operations + - lists + contexts: + - real-world-applications find-smallest-number: types: @@ -372,6 +569,15 @@ find-smallest-number: 3: exceptional 4: normal 5: normal + difficulty: difficulty-1 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics + - real-world-applications book-titles: types: @@ -385,6 +591,15 @@ book-titles: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - range-loops + - string-operations + - lists + contexts: + - real-world-applications total-under-10: types: @@ -398,6 +613,14 @@ total-under-10: 2: normal 3: exceptional 4: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics go-tramping: types: @@ -409,6 +632,14 @@ go-tramping: 4: exceptional 5: exceptional 6: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - multiple-conditions + contexts: + - simple-mathematics + - real-world-applications make-even: types: @@ -420,6 +651,13 @@ make-even: 4: normal 5: exceptional 6: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - multiple-conditions + contexts: + - simple-mathematics print-score: types: @@ -434,6 +672,14 @@ print-score: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - string-operations + contexts: + - simple-mathematics + - real-world-applications h-words: types: @@ -443,6 +689,12 @@ h-words: 2: normal 3: normal 4: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - single-condition + - string-operations print-pet: types: @@ -456,6 +708,13 @@ print-pet: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - string-operations + contexts: + - real-world-applications print-squares: types: @@ -470,6 +729,15 @@ print-squares: 3: normal 4: exceptional 5: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - functions + - inputs + - range-loops + - lists + contexts: + - simple-mathematics drawn-out-string: types: @@ -484,6 +752,12 @@ drawn-out-string: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - range-loops + - functions + - string-operations + - display-text add-up-numbers: types: @@ -496,6 +770,14 @@ add-up-numbers: 3: exceptional 4: exceptional 5: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - range-loops + - lists + contexts: + - simple-mathematics evens-out: types: @@ -508,6 +790,14 @@ evens-out: 3: exceptional 4: exceptional 5: normal + difficulty: difficulty-1 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics happy-birthday: types: @@ -518,6 +808,14 @@ happy-birthday: 3: normal 4: normal 5: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - string-operations + contexts: + - simple-mathematics + - real-world-applications give-me-5: types: @@ -528,6 +826,12 @@ give-me-5: 3: normal 4: normal 5: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops while-away: types: @@ -539,6 +843,12 @@ while-away: 2: exceptional 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - single-condition + - conditional-loops times-2: types: @@ -552,6 +862,11 @@ times-2: 4: exceptional 5: normal 6: exceptional + difficulty: difficulty-2 + concepts: + - functions + contexts: + - simple-mathematics days-to-target: types: @@ -567,6 +882,14 @@ days-to-target: 3: exceptional 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + - conditional-loops + contexts: + - advanced-mathematics + - real-world-applications roll-call: types: @@ -581,6 +904,15 @@ roll-call: 2: normal 3: normal 4: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - functions + - range-loops + - string-operations + - lists + contexts: + - real-world-applications lunch: types: @@ -590,6 +922,14 @@ lunch: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - single-condition + - string-operations + contexts: + - real-world-applications signature: types: @@ -598,6 +938,13 @@ signature: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - string-operations + contexts: + - real-world-applications print-bigger-number: types: @@ -610,6 +957,13 @@ print-bigger-number: 3: normal 4: exceptional 5: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - functions + - single-condition + contexts: + - simple-mathematics rectangle-pattern: types: @@ -625,6 +979,14 @@ rectangle-pattern: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - single-condition + - range-loops + contexts: + - basic-geometry leap-year: types: @@ -634,6 +996,13 @@ leap-year: 2: normal 3: normal 4: exceptional + difficulty: difficulty-2 + concepts: + - functions + - advanced-conditionals + contexts: + - advanced-mathematics + - real-world-applications discounted-cost: types: @@ -645,6 +1014,12 @@ discounted-cost: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - functions + contexts: + - simple-mathematics + - real-world-applications celsius-to-fahrenheit: type: function @@ -654,6 +1029,12 @@ celsius-to-fahrenheit: 3: normal 4: normal 5: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics + - real-world-applications fahrenheit-to-celsius: type: function @@ -663,6 +1044,12 @@ fahrenheit-to-celsius: 3: normal 4: normal 5: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics + - real-world-applications rollercoaster-ride: types: @@ -673,6 +1060,14 @@ rollercoaster-ride: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions + contexts: + - simple-mathematics + - real-world-applications how-many-dogs: types: @@ -684,6 +1079,16 @@ how-many-dogs: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - functions + - single-condition + - range-loops + - string-operations + - lists + contexts: + - simple-mathematics + - real-world-applications total-evens: types: @@ -695,6 +1100,14 @@ total-evens: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics driver-speed: types: @@ -704,6 +1117,12 @@ driver-speed: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - advanced-conditionals + - functions + contexts: + - real-world-applications fizz-buzz: types: @@ -717,6 +1136,13 @@ fizz-buzz: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - advanced-conditionals + contexts: + - simple-mathematics inside-outside: type: debugging @@ -728,6 +1154,13 @@ inside-outside: 3: normal 4: normal 5: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions + contexts: + - simple-mathematics eight-is-great: types: @@ -741,6 +1174,13 @@ eight-is-great: 3: normal 4: normal 5: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions + contexts: + - simple-mathematics sum-lucky-7: types: @@ -751,6 +1191,15 @@ sum-lucky-7: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - display-text + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics duck-goose: types: @@ -760,6 +1209,12 @@ duck-goose: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - multiple-conditions + - string-operations remove-bugs: types: @@ -771,6 +1226,12 @@ remove-bugs: 2: normal 3: normal 4: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + - range-loops + - lists nth-to-last: types: @@ -781,6 +1242,10 @@ nth-to-last: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - functions + - lists factorial-debug: types: @@ -793,6 +1258,13 @@ factorial-debug: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - functions + - multiple-conditions + - conditional-loops + contexts: + - advanced-mathematics sum-unlucky-7: types: @@ -805,6 +1277,12 @@ sum-unlucky-7: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - display-text + - functions + contexts: + - simple-mathematics pass-the-parcel: types: @@ -815,6 +1293,12 @@ pass-the-parcel: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - range-loops + - lists end-of-file: types: @@ -827,6 +1311,10 @@ end-of-file: 1: normal 2: normal 3: exceptional + difficulty: difficulty-1 + concepts: + - functions + - lists is-this-the-end: types: @@ -841,6 +1329,11 @@ is-this-the-end: 2: normal 3: normal 4: exceptional + difficulty: difficulty-3 + concepts: + - functions + - multiple-conditions + - lists long-count: types: @@ -853,6 +1346,13 @@ long-count: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - functions + - range-loops + - lists + contexts: + - simple-mathematics playing-card: types: @@ -862,6 +1362,15 @@ playing-card: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops + - lists + contexts: + - real-world-applications marco-polo: types: @@ -870,12 +1379,21 @@ marco-polo: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops say-kia-ora: types: - program test-cases: 1: normal + difficulty: difficulty-0 + concepts: + - display-text favourite-food: types: @@ -884,6 +1402,11 @@ favourite-food: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - string-operations even-or-odd: types: @@ -894,6 +1417,13 @@ even-or-odd: 3: normal 4: exceptional 5: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - single-condition + contexts: + - simple-mathematics less-than-6: types: @@ -904,6 +1434,15 @@ less-than-6: 3: normal 4: exceptional 5: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics best-paper-scissors-rock: types: @@ -912,6 +1451,13 @@ best-paper-scissors-rock: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - multiple-conditions + contexts: + - real-world-applications guessing-game: types: @@ -923,6 +1469,13 @@ guessing-game: 4: normal 5: normal 6: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - multiple-conditions + contexts: + - simple-mathematics rectangle-perimeter: types: @@ -932,6 +1485,12 @@ rectangle-perimeter: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics + - basic-geometry square-perimeter: types: @@ -942,6 +1501,12 @@ square-perimeter: 3: normal 4: normal 5: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics + - basic-geometry square-area: types: @@ -952,6 +1517,12 @@ square-area: 3: normal 4: normal 5: normal + difficulty: difficulty-2 + concepts: + - functions + contexts: + - simple-mathematics + - basic-geometry simple-calculator: types: @@ -961,6 +1532,12 @@ simple-calculator: 2: normal 3: normal 4: normal + difficulty: difficulty-3 + concepts: + - functions + - advanced-conditionals + contexts: + - advanced-mathematics vertical-line: types: @@ -968,6 +1545,12 @@ vertical-line: test-cases: 1: normal 2: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + contexts: + - advanced-mathematics horizontal-line: types: @@ -975,6 +1558,12 @@ horizontal-line: test-cases: 1: normal 2: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + contexts: + - advanced-mathematics acceleration-calculator: type: debugging @@ -983,6 +1572,11 @@ acceleration-calculator: test-cases: 1: normal 2: normal + difficulty: difficulty-2 + concepts: + - functions + contexts: + - advanced-mathematics can-afford: type: debugging @@ -993,6 +1587,13 @@ can-afford: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - functions + - multiple-conditions + contexts: + - simple-mathematics + - real-world-applications mountain-climbing: types: @@ -1004,6 +1605,13 @@ mountain-climbing: 1: normal 2: normal 3: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + - conditional-loops + contexts: + - simple-mathematics first-letters: types: @@ -1014,6 +1622,11 @@ first-letters: 1: normal 2: normal 3: normal + difficulty: difficulty-3 + concepts: + - functions + - range-loops + - string-operations is-a-divisor: types: @@ -1022,6 +1635,15 @@ is-a-divisor: 1: normal 2: normal 3: normal + difficulty: difficulty-3 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops + - string-operations + contexts: + - advanced-mathematics # TODO: Make it require a for loop largest-number: @@ -1031,6 +1653,14 @@ largest-number: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - multiple-conditions + - range-loops + contexts: + - simple-mathematics print-burger: types: @@ -1039,6 +1669,12 @@ print-burger: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - range-loops + - lists shout-it: types: @@ -1047,6 +1683,11 @@ shout-it: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - string-operations fancy-divider-one: types: @@ -1056,6 +1697,11 @@ fancy-divider-one: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - string-operations fancy-divider-two: types: @@ -1065,6 +1711,11 @@ fancy-divider-two: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - string-operations driving-danger-level: types: @@ -1074,6 +1725,13 @@ driving-danger-level: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions + contexts: + - real-world-applications driving-danger-level-debug: types: @@ -1085,6 +1743,14 @@ driving-danger-level-debug: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions + - string-operations + contexts: + - real-world-applications plural-words: types: @@ -1094,6 +1760,15 @@ plural-words: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops + - string-operations + contexts: + - real-world-applications are-we-there-yet: types: @@ -1105,6 +1780,12 @@ are-we-there-yet: 4: normal 5: exceptional 6: normal + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - single-condition + - conditional-loops how-many-names: types: @@ -1116,6 +1797,13 @@ how-many-names: 2: normal 3: normal 4: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - range-loops + - string-operations + - lists triangle-perimeter: types: @@ -1124,6 +1812,12 @@ triangle-perimeter: 1: normal 2: normal 3: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - basic-geometry + - simple-mathematics add-gst: types: @@ -1133,6 +1827,11 @@ add-gst: 2: normal 3: normal 4: normal + difficulty: difficulty-1 + concepts: + - functions + contexts: + - simple-mathematics bookends: types: @@ -1141,6 +1840,11 @@ bookends: 1: normal 2: normal 3: normal + difficulty: difficulty-2 + concepts: + - functions + - range-loops + - lists clean-room: types: @@ -1154,6 +1858,14 @@ clean-room: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - real-world-applications clean-room-debug: types: @@ -1166,6 +1878,14 @@ clean-room-debug: 3: normal 4: normal 5: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - real-world-applications divisible-by-six: types: @@ -1176,6 +1896,13 @@ divisible-by-six: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - display-text + - functions + - multiple-conditions + contexts: + - advanced-mathematics teenagers: types: @@ -1187,6 +1914,13 @@ teenagers: 4: normal 5: normal 6: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + - single-condition + contexts: + - simple-mathematics storage-full: types: @@ -1199,6 +1933,12 @@ storage-full: 5: exceptional 6: exceptional 7: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + contexts: + - simple-mathematics how-many-seats: types: @@ -1213,6 +1953,15 @@ how-many-seats: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - functions + - advanced-conditionals + - range-loops + - lists + contexts: + - simple-mathematics + - real-world-applications valid-pin: types: @@ -1223,6 +1972,13 @@ valid-pin: 3: normal 4: normal 5: normal + difficulty: difficulty-2 + concepts: + - functions + - multiple-conditions + - string-operations + contexts: + - real-world-applications estimate-legs: types: @@ -1232,6 +1988,13 @@ estimate-legs: 2: normal 3: normal 4: exceptional + difficulty: difficulty-1 + concepts: + - display-text + - inputs + contexts: + - simple-mathematics + - real-world-applications does-it-ascend: types: @@ -1245,6 +2008,14 @@ does-it-ascend: 3: normal 4: normal 5: normal + difficulty: difficulty-3 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics does-it-descend: types: @@ -1255,4 +2026,252 @@ does-it-descend: 1: normal 2: normal 3: normal - 4: normal \ No newline at end of file + 4: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - simple-mathematics + +beep-boop: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + 4: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - functions + - multiple-conditions + - range-loops + - string-operations + +farewell: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - string-operations + +pen-picker: + types: + - debugging + number_of_read_only_lines_top: 2 + number_of_read_only_lines_bottom: 1 + test-cases: + 1: normal + 2: normal + 3: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - lists + +lost-cat: + types: + - program + test-cases: + 1: normal + 2: normal + 3: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - multiple-conditions + - conditional-loops + - string-operations + +storeys-climbed: + types: + - program + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + difficulty: difficulty-1 + concepts: + - display-text + - inputs + contexts: + - simple-mathematics + - real-world-applications + +print-footprints: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - string-operations + +change-dogs: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + difficulty: difficulty-2 + concepts: + - functions + - string-operations + +form-circle: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + difficulty: difficulty-1 + concepts: + - display-text + - functions + - string-operations + +days-of-coffee: + types: + - debugging + number_of_read_only_lines_top: 2 + number_of_read_only_lines_bottom: 3 + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + 5: normal + difficulty: difficulty-3 + concepts: + - display-text + - functions + - string-operations + contexts: + - advanced-mathematics + - real-world-applications + +calculate-mean: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + difficulty: difficulty-1 + concepts: + - functions + - lists + contexts: + - simple-mathematics + +address: + types: + - program + test-cases: + 1: normal + 2: normal + 3: normal + 4: exceptional + difficulty: difficulty-2 + concepts: + - display-text + - inputs + - string-operations + contexts: + - real-world-applications + +remove-seal: + types: + - function + test-cases: + 1: normal + 2: normal + 3: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - lists + +speeders: + types: + - function + - parsons + parsons-extra-lines: + - 'if speed < speed_limit + 4:' + - 'total + 1' + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + difficulty: difficulty-2 + concepts: + - functions + - single-condition + - range-loops + - lists + contexts: + - real-world-applications + +forest: + types: + - debugging + number_of_read_only_lines_top: 1 + number_of_read_only_lines_bottom: 1 + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + 5: normal + 6: normal + difficulty: difficulty-2 + concepts: + - functions + - lists + contexts: + - real-world-applications + +space-cargo: + types: + - function + - parsons + parsons-extra-lines: + - 'print(cargo)' + - 'ejected_item = cargo.remove()' + test-cases: + 1: normal + 2: normal + 3: normal + 4: normal + 5: normal + difficulty: difficulty-3 + concepts: + - display-text + - functions + - lists + contexts: + - simple-mathematics diff --git a/codewof/programming/filters.py b/codewof/programming/filters.py new file mode 100644 index 000000000..7d55b9314 --- /dev/null +++ b/codewof/programming/filters.py @@ -0,0 +1,44 @@ +"""Filters for programming application.""" + +import django_filters +from programming.models import ( + Question, + DifficultyLevel, + ProgrammingConcepts, + QuestionContexts, +) +from programming.widgets import IndentCheckbox, DifficultyCheckbox, TypeCheckbox + + +class QuestionFilter(django_filters.FilterSet): + """Filter for questions extends FilterSet. + + Allows for filtering of question type, difficulty level, concepts and contexts + """ + + difficulty_level = django_filters.filters.ModelMultipleChoiceFilter( + queryset=DifficultyLevel.objects.order_by('level'), + widget=DifficultyCheckbox, + ) + + concepts = django_filters.filters.ModelMultipleChoiceFilter( + queryset=ProgrammingConcepts.objects.prefetch_related('parent').order_by('number'), + widget=IndentCheckbox, + conjoined=False, + ) + + contexts = django_filters.filters.ModelMultipleChoiceFilter( + queryset=QuestionContexts.objects.prefetch_related('parent').order_by('number'), + widget=IndentCheckbox, + conjoined=False, + ) + + question_type = django_filters.filters.AllValuesMultipleFilter( + widget=TypeCheckbox, + ) + + class Meta: + """Meta options for Filter. Sets which model and fields are filtered.""" + + model = Question + fields = {'difficulty_level', 'concepts', 'contexts', 'question_type'} diff --git a/codewof/programming/management/commands/_DifficultiesLoader.py b/codewof/programming/management/commands/_DifficultiesLoader.py new file mode 100644 index 000000000..7a1b3bbc3 --- /dev/null +++ b/codewof/programming/management/commands/_DifficultiesLoader.py @@ -0,0 +1,61 @@ +"""Custom loader for loading difficulty levels.""" + +from django.db import transaction +from utils.TranslatableModelLoader import TranslatableModelLoader +from utils.errors import MissingRequiredFieldError +from programming.models import DifficultyLevel + +TEST_CASE_FILE_TEMPLATE = 'test-case-{id}-{type}.txt' + + +class DifficultiesLoader(TranslatableModelLoader): + """Custom loader for loading difficulties.""" + + @transaction.atomic + def load(self): + """Load difficulties. + + Raise: MissingRequiredFieldError: when no object can be found with the matching attribute. + """ + difficulties_structure = self.load_yaml_file(self.structure_file_path) + + required_translation_fields = ["name"] + difficulties_translations = self.get_yaml_translations( + self.structure_filename, + required_fields=required_translation_fields, + required_slugs=difficulties_structure.keys() + ) + + for (difficulty_slug, difficulty_data) in difficulties_structure.items(): + # Check test cases exist + try: + difficulty_level = difficulty_data['level'] + except KeyError: + raise MissingRequiredFieldError( + self.structure_file_path, + [ + 'level', + ], + 'Difficulty' + ) + difficulty_translations = difficulties_translations.get(difficulty_slug, dict()) + + defaults = dict() + defaults["level"] = difficulty_level + + difficulty, created = DifficultyLevel.objects.update_or_create( + slug=difficulty_slug, + defaults=defaults, + ) + + self.populate_translations(difficulty, difficulty_translations) + self.mark_translation_availability(difficulty, required_fields=required_translation_fields) + difficulty.save() + + if created: + verb_text = 'Added' + else: + verb_text = 'Updated' + + self.log(f'{verb_text} difficulty: {difficulty.name}') + self.log("All difficulties loaded!\n") diff --git a/codewof/programming/management/commands/_ProgrammingConceptsLoader.py b/codewof/programming/management/commands/_ProgrammingConceptsLoader.py new file mode 100644 index 000000000..47e1b94a7 --- /dev/null +++ b/codewof/programming/management/commands/_ProgrammingConceptsLoader.py @@ -0,0 +1,114 @@ +"""Custom loader for loading programming concepts.""" + +from django.db import transaction +from utils.TranslatableModelLoader import TranslatableModelLoader +from utils.errors import MissingRequiredFieldError +from programming.models import ProgrammingConcepts + +TEST_CASE_FILE_TEMPLATE = 'test-case-{id}-{type}.txt' + + +class ProgrammingConceptsLoader(TranslatableModelLoader): + """Custom loader for loading programming concepts.""" + + def __init__(self, base_path="", structure_dir="structure", content_path="", + structure_filename="", lite_loader=False): + """Create a BaseLoader object. + + Args: + base_path (str): path to content_root, eg. "topics/content/". + structure_dir (str): name of directory under base_path storing structure files. + content_path (str): path within locale/structure dir to content directory, eg. "binary-numbers/unit-plan". + structure_filename (str): name of yaml file, eg. "unit-plan.yaml". + lite_loader (bool): Boolean to state whether loader should only + be loading key content and perform minimal checks." + """ + super().__init__(base_path, structure_dir, content_path, structure_filename, lite_loader) + self.concepts_translations = dict() + self.required_translation_fields = ["name"] + + @transaction.atomic + def load(self): + """Load programming concepts. + + Raise: + MissingRequiredFieldError: when no object can be found with the matching + attribute. + """ + concept_structure = self.load_yaml_file(self.structure_file_path) + self.concepts_translations = self.get_yaml_translations( + self.structure_filename, + required_fields=self.required_translation_fields, + required_slugs=concept_structure.keys() + ) + + for (concept_slug, concept_data) in concept_structure.items(): + self.load_single_concept(concept_slug, concept_data, None, 1) + self.log("All concepts loaded!\n") + + def load_single_concept(self, concept_slug, concept_data, parent, indent_level): + """Load single concept.""" + concept_number = None + if indent_level == 1: + try: + concept_number = concept_data['number'] + except KeyError: + raise MissingRequiredFieldError( + self.structure_file_path, + [ + 'number', + ], + 'Concepts' + ) + + defaults = dict() + if concept_number: + defaults["number"] = concept_number + elif parent: + defaults["number"] = parent.number + + if parent: + defaults["parent"] = parent + + defaults["indent_level"] = indent_level + + defaults["has_children"] = "children" in concept_data + + concept, created = ProgrammingConcepts.objects.update_or_create( + slug=concept_slug, + defaults=defaults, + ) + + concept_translations = self.concepts_translations.get(concept_slug, dict()) + + self.populate_translations(concept, concept_translations) + self.mark_translation_availability(concept, required_fields=self.required_translation_fields) + concept.save() + + if created: + verb_text = 'Added' + else: + verb_text = 'Updated' + + self.log(f'{verb_text} concept: {concept.name} at level {indent_level}') + + if "children" in concept_data: + children_concepts = concept_data["children"] + if children_concepts is None: + raise MissingRequiredFieldError( + self.structure_file_path, + ["slug"], + "Child Programming Concept" + ) + for child in children_concepts: + if type(child) is dict: + child_data = dict() + for key in child.keys(): + if key == "children": + child_data["children"] = child["children"] + else: + child_slug = key + else: + child_slug = child + child_data = [] + self.load_single_concept(child_slug, child_data, concept, indent_level + 1) diff --git a/codewof/programming/management/commands/_QuestionContextsLoader.py b/codewof/programming/management/commands/_QuestionContextsLoader.py new file mode 100644 index 000000000..15284cb71 --- /dev/null +++ b/codewof/programming/management/commands/_QuestionContextsLoader.py @@ -0,0 +1,116 @@ +"""Custom loader for loading question contexts.""" + +from django.db import transaction +from utils.TranslatableModelLoader import TranslatableModelLoader +from utils.errors import ( + MissingRequiredFieldError, +) +from programming.models import QuestionContexts + +TEST_CASE_FILE_TEMPLATE = 'test-case-{id}-{type}.txt' + + +class QuestionContextsLoader(TranslatableModelLoader): + """Custom loader for loading question contexts.""" + + def __init__(self, base_path="", structure_dir="structure", content_path="", + structure_filename="", lite_loader=False): + """Create a QuestionContextLoader object. + + Args: + base_path (str): path to content_root, eg. "topics/content/". + structure_dir (str): name of directory under base_path storing structure files. + content_path (str): path within locale/structure dir to content directory, eg. "binary-numbers/unit-plan". + structure_filename (str): name of yaml file, eg. "unit-plan.yaml". + lite_loader (bool): Boolean to state whether loader should only + be loading key content and perform minimal checks." + """ + super().__init__(base_path, structure_dir, content_path, structure_filename, lite_loader) + self.contexts_translations = dict() + self.required_translation_fields = ["name"] + + @transaction.atomic + def load(self): + """Load question contexts. + + Raise: + MissingRequiredFieldError: when no object can be found with the matching + attribute. + """ + context_structure = self.load_yaml_file(self.structure_file_path) + self.contexts_translations = self.get_yaml_translations( + self.structure_filename, + required_fields=self.required_translation_fields, + required_slugs=context_structure.keys() + ) + + for (context_slug, context_data) in context_structure.items(): + self.load_single_context(context_slug, context_data, None, 1) + self.log("All contexts loaded!\n") + + def load_single_context(self, context_slug, context_data, parent, indent_level): + """Load a single context.""" + context_number = None + if indent_level == 1: + try: + context_number = context_data['number'] + except KeyError: + raise MissingRequiredFieldError( + self.structure_file_path, + [ + 'number', + ], + 'Contexts' + ) + + defaults = dict() + if context_number: + defaults["number"] = context_number + elif parent: + defaults["number"] = parent.number + + if parent: + defaults["parent"] = parent + + defaults["indent_level"] = indent_level + + defaults["has_children"] = "children" in context_data + + context, created = QuestionContexts.objects.update_or_create( + slug=context_slug, + defaults=defaults, + ) + + context_translations = self.contexts_translations.get(context_slug, dict()) + + self.populate_translations(context, context_translations) + self.mark_translation_availability(context, required_fields=self.required_translation_fields) + context.save() + + if created: + verb_text = 'Added' + else: + verb_text = 'Updated' + + self.log(f'{verb_text} context: {context.name} at level {indent_level}') + + if "children" in context_data: + children_contexts = context_data["children"] + if children_contexts is None: + raise MissingRequiredFieldError( + self.structure_file_path, + ["slug"], + "Child Question Context" + ) + for child in children_contexts: + if type(child) is dict: + child_data = dict() + for key in child.keys(): + if key == "children": + child_data["children"] = child["children"] + else: + child_slug = key + else: + child_slug = child + child_data = [] + self.load_single_context(child_slug, child_data, context, indent_level + 1) diff --git a/codewof/programming/management/commands/_QuestionsLoader.py b/codewof/programming/management/commands/_QuestionsLoader.py index 92df9609c..865fe5e75 100644 --- a/codewof/programming/management/commands/_QuestionsLoader.py +++ b/codewof/programming/management/commands/_QuestionsLoader.py @@ -2,10 +2,12 @@ from os.path import join from django.db import transaction +from django.core.exceptions import ObjectDoesNotExist from utils.TranslatableModelLoader import TranslatableModelLoader from utils.errors import ( MissingRequiredFieldError, InvalidYAMLValueError, + KeyNotFoundError ) from utils.language_utils import get_available_languages from programming.models import ( @@ -17,6 +19,9 @@ QuestionTypeParsonsTestCase, QuestionTypeDebugging, QuestionTypeDebuggingTestCase, + DifficultyLevel, + ProgrammingConcepts, + QuestionContexts ) VALID_QUESTION_TYPES = { @@ -129,6 +134,21 @@ def load(self): language, initial_code_filename), encoding='UTF-8').read() question_translations[language]['initial_code'] = initial_code + if "difficulty" in question_data: + difficulty_slug = question_data['difficulty'] + try: + difficulty_level = DifficultyLevel.objects.get( + slug=difficulty_slug + ) + except ObjectDoesNotExist: + raise KeyNotFoundError( + self.structure_file_path, + difficulty_slug, + "Difficulty Level" + ) + else: + difficulty_level = None + for question_type in question_types: slug = '{}-{}'.format(question_slug, question_type) question_class = VALID_QUESTION_TYPES[question_type]['question_class'] @@ -142,6 +162,10 @@ def load(self): defaults['read_only_lines_top'] = int(question_data.get('number_of_read_only_lines_top', 0)) defaults['read_only_lines_bottom'] = int(question_data.get('number_of_read_only_lines_bottom', 0)) + defaults['difficulty_level'] = difficulty_level + + defaults['question_type'] = question_type.title() + question, created = question_class.objects.update_or_create( slug=slug, defaults=defaults, @@ -151,6 +175,56 @@ def load(self): self.mark_translation_availability(question, required_fields=required_fields) question.save() + # Add programming concepts + concept_slugs = question_data.get("concepts", []) + concept_slugs_to_add = set() + for concept_slug in concept_slugs: + try: + concept = ProgrammingConcepts.objects.get(slug=concept_slug) + if concept.children.exists(): + raise InvalidYAMLValueError( + self.structure_file_path, + "concepts - value '{}' - added concept is invalid due to being a parent" + .format(slug), + ) + concept_slugs_to_add.add(concept_slug) + if concept.parent is not None and concept.parent not in concept_slugs: + concept_slugs_to_add.add(concept.parent.slug) + except ObjectDoesNotExist: + raise KeyNotFoundError( + self.structure_file_path, + concept_slug, + "Concepts" + ) + for concept_slug in concept_slugs_to_add: + concept = ProgrammingConcepts.objects.get(slug=concept_slug) + question.concepts.add(concept) + + # Add question contexts + context_slugs = question_data.get("contexts", []) + context_slugs_to_add = set() + for context_slug in context_slugs: + try: + context = QuestionContexts.objects.get(slug=context_slug) + if context.children.exists(): + raise InvalidYAMLValueError( + self.structure_file_path, + "contexts - value '{}' - added context is invalid due to being a parent" + .format(slug), + ) + context_slugs_to_add.add(context_slug) + if context.parent is not None and context.parent not in context_slugs: + context_slugs_to_add.add(context.parent.slug) + except ObjectDoesNotExist: + raise KeyNotFoundError( + self.structure_file_path, + context_slug, + "Contexts" + ) + for context_slug in context_slugs_to_add: + context = QuestionContexts.objects.get(slug=context_slug) + question.contexts.add(context) + test_case_class = VALID_QUESTION_TYPES[question_type]['test_case_class'] for (test_case_id, test_case_type) in question_test_cases.items(): test_case_translations = self.get_blank_translation_dictionary() diff --git a/codewof/programming/management/commands/load_questions.py b/codewof/programming/management/commands/load_questions.py index 3e78985d6..5523a726c 100644 --- a/codewof/programming/management/commands/load_questions.py +++ b/codewof/programming/management/commands/load_questions.py @@ -12,11 +12,25 @@ class Command(BaseCommand): def handle(self, *args, **options): """Automatically called when the load_questions command is given.""" - base_path = settings.QUESTIONS_BASE_PATH - questions_structure_file = 'questions.yaml' factory = LoaderFactory() + base_path = settings.QUESTIONS_BASE_PATH + + factory.difficulty_levels_loader( + structure_filename='difficulty-levels.yaml', + base_path=base_path + ).load() + + factory.programming_concepts_loader( + structure_filename='programming-concepts.yaml', + base_path=base_path + ).load() + + factory.question_contexts_loader( + structure_filename='question-contexts.yaml', + base_path=base_path + ).load() factory.create_questions_loader( - structure_filename=questions_structure_file, + structure_filename='questions.yaml', base_path=base_path ).load() diff --git a/codewof/programming/management/commands/update_data.py b/codewof/programming/management/commands/update_data.py new file mode 100644 index 000000000..650ec7957 --- /dev/null +++ b/codewof/programming/management/commands/update_data.py @@ -0,0 +1,13 @@ +"""Module for the custom Django updatedata command.""" + +from django.core import management + + +class Command(management.base.BaseCommand): + """Required command class for the custom Django updatedata command.""" + + help = "Update all data from content folders for all applications" + + def handle(self, *args, **options): + """Automatically called when the updatedata command is given.""" + management.call_command("load_questions") diff --git a/codewof/programming/migrations/0014_difficultylevel.py b/codewof/programming/migrations/0014_difficultylevel.py new file mode 100644 index 000000000..94150c545 --- /dev/null +++ b/codewof/programming/migrations/0014_difficultylevel.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.11 on 2022-04-15 03:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0013_auto_20210902_1417'), + ] + + operations = [ + migrations.CreateModel( + name='DifficultyLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField()), + ('level', models.PositiveSmallIntegerField()), + ('name', models.TextField()), + ('hint', models.TextField()), + ], + options={ + 'ordering': ['level'], + }, + ), + ] diff --git a/codewof/programming/migrations/0015_question_difficulty_level.py b/codewof/programming/migrations/0015_question_difficulty_level.py new file mode 100644 index 000000000..4191311e8 --- /dev/null +++ b/codewof/programming/migrations/0015_question_difficulty_level.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.11 on 2022-04-15 03:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0014_difficultylevel'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='difficulty_level', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='programming.difficultylevel'), + ), + ] diff --git a/codewof/programming/migrations/0016_remove_question_difficulty_level.py b/codewof/programming/migrations/0016_remove_question_difficulty_level.py new file mode 100644 index 000000000..e2d140d6f --- /dev/null +++ b/codewof/programming/migrations/0016_remove_question_difficulty_level.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.11 on 2022-04-17 01:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0015_question_difficulty_level'), + ] + + operations = [ + migrations.RemoveField( + model_name='question', + name='difficulty_level', + ), + ] diff --git a/codewof/programming/migrations/0017_question_difficulty_level.py b/codewof/programming/migrations/0017_question_difficulty_level.py new file mode 100644 index 000000000..1e4dae730 --- /dev/null +++ b/codewof/programming/migrations/0017_question_difficulty_level.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.11 on 2022-04-17 02:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0016_remove_question_difficulty_level'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='difficulty_level', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='programming.difficultylevel'), + ), + ] diff --git a/codewof/programming/migrations/0018_auto_20220417_1625.py b/codewof/programming/migrations/0018_auto_20220417_1625.py new file mode 100644 index 000000000..9ff30174a --- /dev/null +++ b/codewof/programming/migrations/0018_auto_20220417_1625.py @@ -0,0 +1,61 @@ +# Generated by Django 3.2.11 on 2022-04-17 04:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0017_question_difficulty_level'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='question_type', + field=models.CharField(default='Program', max_length=100), + ), + migrations.CreateModel( + name='QuestionContexts', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(null=True, unique=True)), + ('name', models.CharField(max_length=500)), + ('css_class', models.CharField(max_length=30)), + ('number', models.PositiveSmallIntegerField()), + ('hint', models.TextField()), + ('indent_level', models.PositiveSmallIntegerField(default=0)), + ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='programming.questioncontexts')), + ], + options={ + 'ordering': ['number', 'name'], + }, + ), + migrations.CreateModel( + name='ProgrammingConcepts', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=500)), + ('slug', models.SlugField(null=True, unique=True)), + ('css_class', models.CharField(max_length=30)), + ('number', models.PositiveSmallIntegerField()), + ('hint', models.TextField()), + ('indent_level', models.PositiveSmallIntegerField(default=1)), + ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='programming.programmingconcepts')), + ], + options={ + 'ordering': ['number', 'name'], + }, + ), + migrations.AddField( + model_name='question', + name='concepts', + field=models.ManyToManyField(related_name='concepts', to='programming.ProgrammingConcepts'), + ), + migrations.AddField( + model_name='question', + name='contexts', + field=models.ManyToManyField(related_name='contexts', to='programming.QuestionContexts'), + ), + ] diff --git a/codewof/programming/migrations/0019_alter_question_difficulty_level.py b/codewof/programming/migrations/0019_alter_question_difficulty_level.py new file mode 100644 index 000000000..264bb0198 --- /dev/null +++ b/codewof/programming/migrations/0019_alter_question_difficulty_level.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.11 on 2022-04-29 01:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0018_auto_20220417_1625'), + ] + + operations = [ + migrations.AlterField( + model_name='question', + name='difficulty_level', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questions', to='programming.difficultylevel'), + ), + ] diff --git a/codewof/programming/migrations/0020_programmingconcepts_has_children.py b/codewof/programming/migrations/0020_programmingconcepts_has_children.py new file mode 100644 index 000000000..7a47c1703 --- /dev/null +++ b/codewof/programming/migrations/0020_programmingconcepts_has_children.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-09-24 00:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0019_alter_question_difficulty_level'), + ] + + operations = [ + migrations.AddField( + model_name='programmingconcepts', + name='has_children', + field=models.BooleanField(default=False), + ), + ] diff --git a/codewof/programming/migrations/0021_questioncontexts_has_children.py b/codewof/programming/migrations/0021_questioncontexts_has_children.py new file mode 100644 index 000000000..5975589cb --- /dev/null +++ b/codewof/programming/migrations/0021_questioncontexts_has_children.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-09-24 00:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0020_programmingconcepts_has_children'), + ] + + operations = [ + migrations.AddField( + model_name='questioncontexts', + name='has_children', + field=models.BooleanField(default=False), + ), + ] diff --git a/codewof/programming/models.py b/codewof/programming/models.py index 82c5608cf..07bc3c4be 100644 --- a/codewof/programming/models.py +++ b/codewof/programming/models.py @@ -119,8 +119,6 @@ class Attempt(models.Model): passed_tests = models.BooleanField(default=False) like_users = models.ManyToManyField(User, through='Like') - # skills_hinted = models.ManyToManyField('Skill', blank=True) - def __str__(self): """Text representation of an attempt.""" return "Attempted '" + str(self.question) + "' on " + str(self.datetime) @@ -156,6 +154,90 @@ class Like(models.Model): datetime = models.DateTimeField(default=timezone.now) +# ----- Question classification ----------------------------------------------------- + +class DifficultyLevel(models.Model): + """Model for question difficulty level.""" + + slug = models.SlugField() + level = models.PositiveSmallIntegerField() + name = models.TextField() + hint = models.TextField() + + def __str__(self): + """Text representation of difficulty level. + + Returns: Difficulty level string + """ + return self.name + + class Meta: + """Meta options for class. Sort so that easiest questions appear first.""" + + ordering = ['level'] + + +class QuestionContexts(models.Model): + """Model for question context.""" + + slug = models.SlugField(unique=True, null=True) + name = models.CharField(max_length=LARGE) + css_class = models.CharField(max_length=30) + number = models.PositiveSmallIntegerField() + hint = models.TextField() + indent_level = models.PositiveSmallIntegerField(default=0) + parent = models.ForeignKey( + "self", + null=True, + related_name="children", + on_delete=models.CASCADE + ) + has_children = models.BooleanField(default=False) + + def __str__(self): + """ + To string method. + + Returns: Name of question context (str). + """ + return self.name + + class Meta: + """Set consistent ordering of question contexts.""" + + ordering = ["number", "name"] + + +class ProgrammingConcepts(models.Model): + """Model for a programming concept.""" + + name = models.CharField(max_length=LARGE) + slug = models.SlugField(unique=True, null=True) + css_class = models.CharField(max_length=30) + number = models.PositiveSmallIntegerField() + hint = models.TextField() + indent_level = models.PositiveSmallIntegerField(default=1) + parent = models.ForeignKey( + "self", + null=True, + related_name="children", + on_delete=models.CASCADE + ) + has_children = models.BooleanField(default=False) + + def __str__(self): + """Text representation of a programming concept. + + Returns: Name of programming concept (str). + """ + return self.name + + class Meta: + """Set consistent ordering of programming concepts.""" + + ordering = ["number", "name"] + + # ----- Base question classes ------------------------------------------------- class Question(TranslatableModel): @@ -169,10 +251,26 @@ class Question(TranslatableModel): slug = models.SlugField(unique=True) title = models.CharField(max_length=SMALL) + question_type = models.CharField(max_length=SMALL, default="Program", null=False) question_text = models.TextField() solution = models.TextField() - # skill_areas = models.ManyToManyField('SkillArea', related_name='questions') - # skills = models.ManyToManyField('Skill', blank=True) + + difficulty_level = models.ForeignKey( + DifficultyLevel, + related_name='questions', + on_delete=models.SET_NULL, + blank=True, + null=True + ) + concepts = models.ManyToManyField( + ProgrammingConcepts, + related_name='concepts' + ) + contexts = models.ManyToManyField( + QuestionContexts, + related_name='contexts' + ) + objects = InheritanceManager() def get_absolute_url(self): @@ -337,18 +435,3 @@ class Meta: """Meta information for class.""" verbose_name = 'Debugging Problem Question Test Case' - - -# class Skill(models.Model): -# name = models.CharField(max_length=SMALL) -# hint = models.CharField(max_length=LARGE) -# subskills = models.ManyToManyField('self', symmetrical=False, blank=True) - -# def __str__(self): -# return self.name - -# class SkillArea(models.Model): -# name = models.CharField(max_length=SMALL) - -# def __str__(self): -# return self.name diff --git a/codewof/programming/question_recommendations.py b/codewof/programming/question_recommendations.py new file mode 100644 index 000000000..0a054451e --- /dev/null +++ b/codewof/programming/question_recommendations.py @@ -0,0 +1,336 @@ +""" +Question recommendations for codeWOF. + +The two questions that are generated from the recommendations make use of the scores allocated to each category type. +Comfortable and uncomfortable categories are part of the terminology used as part of the logic for finding suitable +questions to recommend. A comfortable category refers to a category that the CodeWOF user is accustomed to. For +example, the first recommendation aims to retrieve a question with a comfortable difficulty, while also making use of +uncomfortable concepts and contexts. A similar situation applies regarding the second recommendation, but with the +opposite logic. Because each difficulty level can be ranked, in terms of easiest to hardest, and concepts or contexts +cannot be ranked in such a manner, their respective comfortable and uncomfortable properties are calculated in +different manners. +""" + +import random +import statistics + +from django.db.models import Q + +from programming.models import DifficultyLevel, ProgrammingConcepts, QuestionContexts, Question +from programming.skill_and_level_tracking import get_level_and_skill_info + + +def get_recommendation_descriptions(): + """Get recommendation descriptions, using a (heading, details) format.""" + return ( + ( + 'Want to maintain your skills?', + 'This question is likely to be at a difficulty you are accustomed to, while making use of a wide range of ' + 'programming concepts and question contexts.' + ), + ( + 'Are you up for a challenge?', + 'This question is at a higher difficulty level than what you are likely to be comfortable with, while ' + 'utilising concepts and contexts you are well versed in.' + ), + ) + + +def get_recommended_questions(profile): + """Get the recommended questions based on the user's previously answered questions.""" + level_and_skill_info = get_level_and_skill_info(profile) + scores = get_scores(level_and_skill_info) + unsolved_questions = get_unsolved_questions(profile) + recommended_questions = calculate_recommended_questions(scores, unsolved_questions) + return recommended_questions + + +def get_scores(level_and_skill_info): + """ + Return a dictionary of scores and numbers from the given tracked information. + + Creates scores based on all questions answered, and those answered within the past month. + """ + difficulty_levels = sorted(list(set([difficulty.level for difficulty in DifficultyLevel.objects.all()]))) + concept_numbers = sorted(list(set([concept.number for concept in ProgrammingConcepts.objects.all()]))) + context_numbers = sorted(list(set([context.number for context in QuestionContexts.objects.all()]))) + return { + 'difficulty': { + 'all': generate_scores(difficulty_levels, level_and_skill_info['all']['difficulty_level']), + 'month': generate_scores(difficulty_levels, level_and_skill_info['month']['difficulty_level']), + 'numbers': difficulty_levels, + }, + 'concept': { + 'all': generate_scores(concept_numbers, level_and_skill_info['all']['concept_num']), + 'month': generate_scores(concept_numbers, level_and_skill_info['month']['concept_num']), + 'numbers': concept_numbers, + }, + 'context': { + 'all': generate_scores(context_numbers, level_and_skill_info['all']['context_num']), + 'month': generate_scores(context_numbers, level_and_skill_info['month']['context_num']), + 'numbers': context_numbers, + }, + } + + +def generate_scores(numbers, info_category): + """ + Generate and return the scores based on a given information category (e.g. difficulty). + + The scores intend to yield a low or negative value when the user is performing well at answering the given type of + question. As part of this, each correctly answered question removes a value of one from the score. Whereas, if a + user is struggling with a particular question, the intent is that a high or positive score is retrieved. This is + shown by the fact that the average number of attempts taken to answer each question is added to the score. + Additionally, a value of one is removed from the score, referring to the first attempt of the question, which is + unnecessary to influence the score, as each correctly solved question takes at least one attempt. + """ + scores = [None] * len(numbers) + for index, category_num in enumerate(numbers): + if category_num in info_category: + if scores[index] is None: + scores[index] = 0 + scores[index] -= info_category[category_num]['num_solved'] + scores[index] += statistics.mean(info_category[category_num]['attempts']) - 1 + return scores + + +def get_unsolved_questions(profile): + """Get all questions unsolved by the user.""" + return ( + Question.objects.all() + .filter( + Q(attempt__isnull=True) | (Q(attempt__passed_tests=False, attempt__profile=profile)) + ) + .distinct('pk') + .select_subclasses() + .select_related('difficulty_level') + .prefetch_related( + 'concepts', + 'concepts__parent', + 'contexts', + 'contexts__parent', + ) + ) + + +def calculate_recommended_questions(scores, unsolved_questions): + """ + Get the recommended questions from calculations based on the provided scores and user. + + Retrieves the recommendation values for the difficulty, concept, and context, and uses this to calculate the + recommended questions. + """ + comfortable_difficulty_questions, comfortable_concepts_contexts_questions = get_recommendation_categories( + scores, unsolved_questions + ) + recommended_questions = get_random_recommendations( + comfortable_difficulty_questions, comfortable_concepts_contexts_questions + ) + return recommended_questions + + +def get_recommendation_categories(scores, unsolved_questions): + """Get the recommendations for all categories (comfortable difficulty, and comfortable concepts/contexts).""" + comfortable_difficulties = get_comfortable_difficulties(scores['difficulty']) + uncomfortable_concepts = get_uncomfortable_concepts_or_contexts(scores['concept']) + uncomfortable_contexts = get_uncomfortable_concepts_or_contexts(scores['context']) + comfortable_difficulty_recommendations = get_recommendations( + unsolved_questions, comfortable_difficulties, uncomfortable_concepts, uncomfortable_contexts + ) + uncomfortable_difficulties = get_uncomfortable_difficulties( + comfortable_difficulties, scores['difficulty']['numbers'] + ) + comfortable_concepts = list(reversed(uncomfortable_concepts)) + comfortable_contexts = list(reversed(uncomfortable_contexts)) + comfortable_concepts_contexts_recommendations = get_recommendations( + unsolved_questions, uncomfortable_difficulties, comfortable_concepts, comfortable_contexts + ) + return comfortable_difficulty_recommendations, comfortable_concepts_contexts_recommendations + + +def get_recommendations(questions, ordered_difficulties, ordered_concepts, ordered_contexts): + """ + Get the question recommendations for the given ordered difficulty, concepts, and contexts. + + Iterates through possible combinations to find valid questions that matches the criteria. + """ + initial_concepts = ordered_concepts[0] + for difficulty in ordered_difficulties: + comfortable_difficulty_questions = questions.filter(difficulty_level__level=difficulty) + for contexts in ordered_contexts: + recommendations = calculate_comfortable_difficulty_questions( + comfortable_difficulty_questions, initial_concepts, contexts + ) + if len(recommendations) > 0: + return recommendations + for concepts in ordered_concepts: + recommendations = calculate_comfortable_difficulty_questions( + comfortable_difficulty_questions, concepts + ) + if len(recommendations) > 0: + return recommendations + recommendations = calculate_comfortable_difficulty_questions( + comfortable_difficulty_questions + ) + if len(recommendations) > 0: + return recommendations + return [] + + +def calculate_comfortable_difficulty_questions(questions, concepts=None, contexts=None): + """Calculate and return the comfortable difficulty, uncomfortable concepts/contexts, question recommendations.""" + comfortable_difficulty_questions = [] + for question in questions: + filters = [] + if concepts is not None: + question_concepts = [concept.number for concept in question.concepts.all()] + filters.append( + any(concept in question_concepts for concept in concepts)) + if contexts is not None: + question_contexts = [context.number for context in question.contexts.all()] + filters.append( + any(context in question_contexts for context in contexts)) + if all(filters): + comfortable_difficulty_questions.append(question) + return comfortable_difficulty_questions + + +def get_random_recommendations(comfortable_difficulty_questions, comfortable_concepts_contexts_questions): + """Get a random (and unique) recommendation with questions from each recommendation category.""" + random_recommendations = [] + if len(comfortable_difficulty_questions) > 0: + comfortable_difficulty_choice = random.choice(comfortable_difficulty_questions) + random_recommendations.append(comfortable_difficulty_choice) + if comfortable_difficulty_choice in comfortable_concepts_contexts_questions: + comfortable_concepts_contexts_questions.remove(comfortable_difficulty_choice) + if len(comfortable_concepts_contexts_questions) > 0: + comfortable_concepts_contexts_choice = random.choice(comfortable_concepts_contexts_questions) + random_recommendations.append(comfortable_concepts_contexts_choice) + return random_recommendations + + +def get_comfortable_difficulties(scores): + """Return comfortable difficulty levels (reasonable for the user to solve, not too hard or easy, in-order).""" + comfortable_difficulty = calculate_comfortable_difficulties(scores['month'], scores['numbers']) + if comfortable_difficulty == scores['numbers']: + comfortable_difficulty = calculate_comfortable_difficulties(scores['all'], scores['numbers']) + return comfortable_difficulty + + +def calculate_comfortable_difficulties(difficulty_scores, difficulty_levels): + """ + Calculate and return comfortable difficulty levels (in a prioritised order) based on the supplied scores. + + The easiest difficulty is set as the most comfortable when scores are not assigned to the difficulties, in cases + where there are no questions answered by a given CodeWOF user. This is to ensure that no assumptions are made in + terms of how well a user performs at answering CodeWOF questions, and to start them with a question difficulty that + is expected to be comfortable to the user. Otherwise, if possible, the most comfortable difficulty is set to the + hardest difficulty with a score less than or equal to zero. This is done to ensure that questions are recommended + at a difficulty that the user has been demonstrably able to answer effectively, as shown by the low score. In the + case where only higher scores are assigned to difficulties, one difficulty level less than the easiest with the + said type of score is assigned as the most comfortable difficulty. Additionally, this is only assigned with one + difficulty level reduced in cases where it is not already the easiest difficulty. The reasoning for this is that + while the CodeWOF user has proven themselves to be able to answer such a question, due to the scoring having been + set, the high score indicates that the user is struggling with the given type of question. Therefore, by setting + the comfortable difficulty to a level lower, this intends to remediate the impact of the higher score as part of + the question recommendation. The subsequently in-order comfortable difficulties use lower difficulties (in + decreasing difficulty order), and then higher difficulties (in increasing difficulty order). + """ + comfortable_difficulty = None + max_difficulty_low_score = None + min_difficulty_high_score = None + for difficulty_level, score in zip(difficulty_levels, difficulty_scores): + if score is not None and score <= 0: + max_difficulty_low_score = difficulty_level + if max_difficulty_low_score is not None: + comfortable_difficulty = max_difficulty_low_score + if comfortable_difficulty is None: + for difficulty_level, score in zip(reversed(difficulty_levels), reversed(difficulty_scores)): + if score is not None and score > 0: + min_difficulty_high_score = difficulty_level + if min_difficulty_high_score is not None and min_difficulty_high_score - 1 >= difficulty_levels[0]: + comfortable_difficulty = min_difficulty_high_score - 1 + if comfortable_difficulty is None: + return difficulty_levels + else: + comfortable_difficulty_index = difficulty_levels.index(comfortable_difficulty) + return [ + comfortable_difficulty, + *reversed(difficulty_levels[:comfortable_difficulty_index]), + *difficulty_levels[comfortable_difficulty_index + 1:], + ] + + +def get_uncomfortable_difficulties(comfortable_difficulties, difficulty_levels): + """ + Calculate and return uncomfortable difficulty levels (in a prioritised order), using the comfortable difficulties. + + Simply, the most uncomfortable difficulty is set to one harder difficulty level than the most comfortable + difficulty level. Otherwise, if the most comfortable difficulty is already the hardest difficulty level, this is + set to equal said difficulty. The reasoning for this was to apply a challenge that is adaptive to the user's most + comfortable difficulty, and to ensure that an outrageously hard question is not delivered to the user, where only a + single difficulty level lift is applied. + """ + comfortable_difficulty = comfortable_difficulties[0] + if comfortable_difficulty + 1 <= max(difficulty_levels): + comfortable_difficulty += 1 + comfortable_difficulty_index = difficulty_levels.index(comfortable_difficulty) + return [ + comfortable_difficulty, + *difficulty_levels[comfortable_difficulty_index + 1:], + *reversed(difficulty_levels[:comfortable_difficulty_index]), + ] + + +def get_uncomfortable_concepts_or_contexts(scores): + """ + Return uncomfortable concept or context numbers. + + In order, the concepts/contexts either have not been done recently/before, or the user has struggled to answer + questions with them. + """ + uncomfortable_categories = calculate_uncomfortable_concepts_or_contexts(scores['numbers'], scores['month']) + if len(uncomfortable_categories) <= 1: + uncomfortable_categories = calculate_uncomfortable_concepts_or_contexts(scores['numbers'], scores['all']) + return uncomfortable_categories + + +def calculate_uncomfortable_concepts_or_contexts(category_nums, category_scores): + """ + Calculate and return an uncomfortable concepts or contexts (in a prioritised order) based on the supplied scores. + + The most comfortable concepts or contexts are assigned in sets. For example, the calculation of the most + comfortable concepts would generate a set of one or more concept types. Because of this, the most uncomfortable + concepts or contexts are assigned by gathering the set of concepts or contexts that are not assigned scores. + However, if there are no concepts or contexts with a score unassigned, the set of concepts/contexts with the + highest score is assigned as the most uncomfortable. This was done to take into account a mix of concepts or + contexts, while also prioritising the scoring assigned. Additionally, the aforementioned concepts or contexts with + unassigned scores are especially useful, where these indicate question types that a given user has not attempted. + The subsequently in-order uncomfortable concepts or contexts use lower scores (i.e. decreasing score values). + """ + uncomfortable_concepts_or_contexts = [] + excluded_category_nums = set() + all_concepts_or_contexts_added = False + while not all_concepts_or_contexts_added: + categories_uncompleted = [] + highest_score_categories = [] + highest_score = -float('inf') + for category_num, score in zip(category_nums, category_scores): + if category_num not in excluded_category_nums: + if score is None: + categories_uncompleted.append(category_num) + elif len(highest_score_categories) < 1 or score == highest_score: + highest_score_categories.append(category_num) + highest_score = score + elif score > highest_score: + highest_score_categories = [category_num] + highest_score = score + if len(categories_uncompleted) > 0: + uncomfortable_concepts_or_contexts.append(categories_uncompleted) + excluded_category_nums.update(categories_uncompleted) + elif len(highest_score_categories) > 0: + uncomfortable_concepts_or_contexts.append(highest_score_categories) + excluded_category_nums.update(highest_score_categories) + else: + all_concepts_or_contexts_added = True + return uncomfortable_concepts_or_contexts diff --git a/codewof/programming/serializers.py b/codewof/programming/serializers.py index 46ab94a95..541901236 100644 --- a/codewof/programming/serializers.py +++ b/codewof/programming/serializers.py @@ -1,7 +1,7 @@ """Serializers for programming models.""" from rest_framework import serializers -from programming.models import Question, Attempt, Profile +from programming.models import Question, Attempt, Profile, Like class QuestionSerializer(serializers.ModelSerializer): @@ -23,6 +23,7 @@ class Meta: 'pk', 'title', 'question_type', + 'concepts', ) @@ -85,3 +86,21 @@ class Meta: 'question_type', 'attempt_set', ) + + +class LikeSerializer(serializers.ModelSerializer): + """Serializer for codeWOF attempt likes.""" + + user = serializers.ReadOnlyField(source='user.pk') + attempt = serializers.ReadOnlyField(source='attempt.pk') + + class Meta: + """Meta settings for serializer.""" + + model = Like + fields = ( + 'pk', + 'user', + 'attempt', + 'datetime', + ) diff --git a/codewof/programming/skill_and_level_tracking.py b/codewof/programming/skill_and_level_tracking.py new file mode 100644 index 000000000..dea2e2026 --- /dev/null +++ b/codewof/programming/skill_and_level_tracking.py @@ -0,0 +1,48 @@ +"""Skill and level tracking for codeWOF.""" + +from programming.codewof_utils import filter_attempts_in_past_month +from programming.models import Attempt + + +def get_level_and_skill_info(profile): + """ + Return a dictionary of level and skill information from a given profile. + + This uses the solved plus all attempts and those within the past month. + """ + all_attempts = Attempt.objects.filter(profile=profile) + solved = all_attempts.filter(passed_tests=True) + return { + 'all': get_level_and_skill_dict(solved, all_attempts), + 'month': get_level_and_skill_dict( + filter_attempts_in_past_month(solved), + filter_attempts_in_past_month(all_attempts), + ), + } + + +def get_level_and_skill_dict(solved, all_attempts): + """Return a dictionary of level and skill information from a given set of solved and all attempts.""" + solved_without_duplicates = solved.distinct('question__slug') + levels_and_skills = {'difficulty_level': dict(), 'concept_num': dict(), 'context_num': dict()} + for solved_attempt in solved_without_duplicates: + question = solved_attempt.question + num_attempts = len(all_attempts.filter(question__slug=question.slug)) + + if question.difficulty_level.level not in levels_and_skills['difficulty_level']: + levels_and_skills['difficulty_level'][question.difficulty_level.level] = {'num_solved': 0, 'attempts': []} + levels_and_skills['difficulty_level'][question.difficulty_level.level]['num_solved'] += 1 + levels_and_skills['difficulty_level'][question.difficulty_level.level]['attempts'].append(num_attempts) + + for concept_num in set(concept.number for concept in question.concepts.all()): + if concept_num not in levels_and_skills['concept_num']: + levels_and_skills['concept_num'][concept_num] = {'num_solved': 0, 'attempts': []} + levels_and_skills['concept_num'][concept_num]['num_solved'] += 1 + levels_and_skills['concept_num'][concept_num]['attempts'].append(num_attempts) + + for context_num in set(context.number for context in question.contexts.all()): + if context_num not in levels_and_skills['context_num']: + levels_and_skills['context_num'][context_num] = {'num_solved': 0, 'attempts': []} + levels_and_skills['context_num'][context_num]['num_solved'] += 1 + levels_and_skills['context_num'][context_num]['attempts'].append(num_attempts) + return levels_and_skills diff --git a/codewof/programming/urls.py b/codewof/programming/urls.py index 59191e1d2..b5aac38f3 100644 --- a/codewof/programming/urls.py +++ b/codewof/programming/urls.py @@ -1,15 +1,18 @@ """URL routing for programming application.""" from django.urls import path +from django.conf import settings from rest_framework import routers from programming import views app_name = 'programming' router = routers.SimpleRouter() -router.register(r'programming/questions', views.QuestionAPIViewSet) -router.register(r'programming/attempts', views.AttemptAPIViewSet) -router.register(r'programming/profiles', views.ProfileAPIViewSet) +if not settings.PRODUCTION_ENVIRONMENT: + router.register(r'programming/questions', views.QuestionAPIViewSet) + router.register(r'programming/attempts', views.AttemptAPIViewSet) + router.register(r'programming/profiles', views.ProfileAPIViewSet) + router.register(r'programming/likes', views.LikeAPIViewSet) urlpatterns = [ path('questions/', views.QuestionListView.as_view(), name='question_list'), diff --git a/codewof/programming/utils.py b/codewof/programming/utils.py new file mode 100644 index 000000000..73c17a2d0 --- /dev/null +++ b/codewof/programming/utils.py @@ -0,0 +1,59 @@ +"""Utilities for programming application.""" + +from crispy_forms.helper import FormHelper +from crispy_forms.layout import ( + Layout, + Row, + Column, + Field, + Div, + Button, + Submit, +) + + +def create_filter_helper(reset_url_pattern): + """Return filter formatting helper. + + Args: + reset_url_pattern (str): URL to set reset button to. + Returns: + Crispy-forms form helper. + """ + filter_formatter = FormHelper() + filter_formatter.form_method = 'get' + filter_formatter.layout = Layout( + Div( + Row( + Column( + Field( + 'difficulty_level', + ), + Field( + 'question_type' + ), + css_class='col-sm-12 col-md-4 mb-0', + ), + Column( + Field( + 'concepts', + css_class='qf-indent2', + ), + css_class='form-group col-sm-12 col-md-4 mb-0', + ), + Column( + Field( + 'contexts', + ), + css_class='form-group col-sm-12 col-md-4 mb-0', + ), + ), + Div( + Button('reset', 'Reset', css_class='btn-danger'), + Submit('', 'Filter questions', css_class='btn-success'), + css_class='d-flex justify-content-between collapsed', + ), + css_class='filter-box' + ) + ) + return filter_formatter diff --git a/codewof/programming/views.py b/codewof/programming/views.py index fe9dde145..091328c8c 100644 --- a/codewof/programming/views.py +++ b/codewof/programming/views.py @@ -3,18 +3,22 @@ import json from django.contrib.auth.decorators import login_required from django.views import generic -from django.db.models import Count, Max +from django.db.models import Count, Max, Exists, OuterRef from django.db.models.functions import Coalesce from django.http import JsonResponse, Http404, HttpResponse from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ObjectDoesNotExist from django.views.decorators.http import require_http_methods +from django_filters.views import FilterView from rest_framework import viewsets from rest_framework.permissions import IsAdminUser + +from programming.question_recommendations import get_recommended_questions, get_recommendation_descriptions from programming.serializers import ( QuestionSerializer, ProfileSerializer, AttemptSerializer, + LikeSerializer ) from programming.models import ( Profile, @@ -25,15 +29,18 @@ Like ) from programming.codewof_utils import add_points, check_achievement_conditions +from programming.filters import QuestionFilter +from programming.utils import create_filter_helper QUESTION_JAVASCRIPT = 'js/question_types/{}.js' -class QuestionListView(LoginRequiredMixin, generic.ListView): +class QuestionListView(LoginRequiredMixin, FilterView): """View for listing questions.""" - model = Question + filterset_class = QuestionFilter context_object_name = 'questions' + template_name = 'programming/question_list.html' def get_queryset(self): """Return questions objects for page. @@ -41,18 +48,44 @@ def get_queryset(self): Returns: Question queryset. """ - questions = Question.objects.all().select_subclasses() - - if self.request.user.is_authenticated: - # TODO: Check if passed in last 90 days - for question in questions: - question.completed = Attempt.objects.filter( - profile=self.request.user.profile, - question=question, - passed_tests=True, - ).exists() + user_successful_attempt_subquery = Attempt.objects.filter( + profile=self.request.user.profile, + question=OuterRef('pk'), + passed_tests=True, + ) + questions = ( + Question.objects.all() + .select_subclasses() + .select_related('difficulty_level') + .prefetch_related( + 'concepts', + 'concepts__parent', + 'contexts', + 'contexts__parent', + ) + .order_by('difficulty_level') + .annotate(completed=Exists(user_successful_attempt_subquery)) + ) return questions + def get_context_data(self, **kwargs): + """Provide the context data for the question list view. + + Returns: Dictionary of context data. + """ + user = self.request.user + + context = super().get_context_data(**kwargs) + context['filter_formatter'] = create_filter_helper("programming:question_list") + recommendation_descriptions = get_recommendation_descriptions() + recommended_questions = get_recommended_questions(user.profile) + if len(recommendation_descriptions) == len(recommended_questions): + context['recommendations'] = [(description, question) for description, question in zip( + recommendation_descriptions, recommended_questions + )] + context['filter_button_pressed'] = "submit" in self.request.GET + return context + class QuestionView(LoginRequiredMixin, generic.DetailView): """Displays a question. @@ -243,3 +276,11 @@ def unlike_attempt(request, pk): like.delete() return HttpResponse() + + +class LikeAPIViewSet(viewsets.ReadOnlyModelViewSet): + """API endpoint that allows attempt likes to be viewed.""" + + permission_classes = [IsAdminUser] + queryset = Like.objects.all().select_related('user', 'attempt') + serializer_class = LikeSerializer diff --git a/codewof/programming/widgets.py b/codewof/programming/widgets.py new file mode 100644 index 000000000..a18deb451 --- /dev/null +++ b/codewof/programming/widgets.py @@ -0,0 +1,55 @@ +"""Widgets for programming application.""" + +from django import forms + +# https://docs.djangoproject.com/en/3.2/ref/forms/fields/#iterating-relationship-choices + +# https://docs.djangoproject.com/en/3.2/ref/forms/widgets/#checkboxselectmultiple + +# https://github.com/django/django/blob/b9e872b59329393f615c440c54f632a49ab05b78/django/forms/widgets.py#L621 + + +class IndentCheckbox(forms.CheckboxSelectMultiple): + """IndentCheckbox extended from CheckboxSelectMultiple. + + Intended for use in the question filtering system + Indents checkboxes based on the indent level of stored in the object, and sets the filter attribute to number. + """ + + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + """Indent the checkbox based on the indent level stored in the object.""" + option = super().create_option(name, value, label, selected, index, subindex, attrs) + if value: + option['attrs']['data-indent-level'] = value.instance.indent_level + option['attrs']['filter'] = value.instance.number + return option + + +class DifficultyCheckbox(forms.CheckboxSelectMultiple): + """DifficultyCheckbox extended from CheckboxSelectMultiple. + + Intended for use in the question filtering system + Sets the filter attribute to level. + """ + + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + """Indent the checkbox based on the indent level stored in the object.""" + option = super().create_option(name, value, label, selected, index, subindex, attrs) + if value: + option['attrs']['filter'] = value.instance.level + return option + + +class TypeCheckbox(forms.CheckboxSelectMultiple): + """TypeCheckbox extended from CheckboxSelectMultiple. + + Intended for use in the question filtering system + Sets the filter attribute to value. + """ + + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + """Indent the checkbox based on the indent level stored in the object.""" + option = super().create_option(name, value, label, selected, index, subindex, attrs) + if value: + option['attrs']['filter'] = value + return option diff --git a/codewof/research/middleware/ResearchMiddleware.py b/codewof/research/middleware/ResearchMiddleware.py index 2f2bda50d..5e0b6c54f 100644 --- a/codewof/research/middleware/ResearchMiddleware.py +++ b/codewof/research/middleware/ResearchMiddleware.py @@ -39,9 +39,9 @@ class ResearchMiddleware: def __init__(self, get_response): """One-time configuration and initialization. - Only load research middleware if running in a staging enviroment. + Only load research middleware if running in a staging environment and not testing. """ - if not settings.PRODUCTION_ENVIRONMENT: + if not settings.PRODUCTION_ENVIRONMENT and not settings.TESTING: self.get_response = get_response else: raise MiddlewareNotUsed() diff --git a/codewof/research/settings.py b/codewof/research/settings.py index 7a7d163d9..25f223224 100644 --- a/codewof/research/settings.py +++ b/codewof/research/settings.py @@ -42,4 +42,5 @@ # Appearance SLUG = '2021-study' TITLE = '2021 Study' -# Description is stored in research/study_description.html +DESCRIPTION = 'This is an example description.' +# HTML Description is stored in research/study_description.html diff --git a/codewof/research/utils.py b/codewof/research/utils.py index 3bb8e5215..775790497 100644 --- a/codewof/research/utils.py +++ b/codewof/research/utils.py @@ -12,6 +12,7 @@ def get_study_for_context(): context = { 'slug': settings.SLUG, 'title': settings.TITLE, + 'description': settings.DESCRIPTION, 'start': settings.START_DATETIME, 'end': settings.END_DATETIME, } diff --git a/codewof/research/views.py b/codewof/research/views.py index 2adc56704..388671bd7 100644 --- a/codewof/research/views.py +++ b/codewof/research/views.py @@ -1,5 +1,7 @@ """Views for research application.""" +from django.core.mail import send_mail +from django.template.loader import get_template from django.views import generic from django.contrib import messages from django.conf import settings @@ -7,7 +9,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.edit import FormView from django.shortcuts import redirect -from mail_templated import send_mail from rest_framework import viewsets, permissions from rest_framework.permissions import IsAdminUser from research.forms import ResearchConsentForm @@ -70,15 +71,12 @@ def form_valid(self, form): send_study_results=form.cleaned_data.get('send_study_results', False) ) send_mail( - 'research/email/consent_confirm.tpl', - { - 'user': self.request.user, - 'study': study, - 'form': form, - 'registration': registration, - }, + 'CodeWOF Research Consent Confirmation', + self.build_email_plain(study, form, registration), settings.DEFAULT_FROM_EMAIL, [self.request.user.email], + fail_silently=False, + html_message=self.build_email_html(study, form, registration) ) messages.success( self.request, @@ -86,6 +84,41 @@ def form_valid(self, form): ) return redirect('research:home') + def build_email_plain(self, study, form, registration): + """ + Construct plaintext for the email body. + + :param study: A dictionary containing information for the study. + :param form: The consent form for the study. + :param registration: The study registration of the user. + :return: A string email body. + """ + message = f"Dear {self.request.user.first_name},\n\n" \ + f"Thank you for registering for the \"{study['title']}\" study. " \ + f"Below is a copy of the information sheet, and your signed consent form.\n\n" \ + f"{study['description']}\n\n" \ + "Consent Form\n" + for field in form: + message += ("I AGREE" if field.value() else "I DO NOT AGREE") + f" - {field.label}\n" + message += f"\nEmail address: {self.request.user.email}\n" \ + f"Date: {registration.datetime}\n\n" \ + f"Thank you,\nThe CodeWOF team\n\n{settings.CODEWOF_DOMAIN}" + return message + + def build_email_html(self, study, form, registration): + """ + Construct HTML for the email body using the consent_confirm.html template. + + :param study: A dictionary containing information for the study. + :param form: The consent form for the study. + :param registration: The study registration of the user. + :return: The rendered HTML. + """ + email_template = get_template("research/email/consent_confirm.html") + return email_template.render( + {"user": self.request.user, "study": study, "form": form, "registration": registration, + "DOMAIN": settings.CODEWOF_DOMAIN}) + class ResearcherPermission(permissions.BasePermission): """Global permission check if the user is a researcher of the study.""" diff --git a/codewof/static/img/homepage/question-boxes.png b/codewof/static/img/homepage/question-boxes.png index b0c4839e6..f6cfc8d7c 100644 Binary files a/codewof/static/img/homepage/question-boxes.png and b/codewof/static/img/homepage/question-boxes.png differ diff --git a/codewof/static/img/logos/logo-colour.png b/codewof/static/img/logos/logo-colour.png new file mode 100644 index 000000000..25c48079f Binary files /dev/null and b/codewof/static/img/logos/logo-colour.png differ diff --git a/codewof/static/img/logos/logo.png b/codewof/static/img/logos/logo.png new file mode 100644 index 000000000..da4f95541 Binary files /dev/null and b/codewof/static/img/logos/logo.png differ diff --git a/codewof/static/js/question_list/checkbox_listener.js b/codewof/static/js/question_list/checkbox_listener.js new file mode 100644 index 000000000..11bf378cd --- /dev/null +++ b/codewof/static/js/question_list/checkbox_listener.js @@ -0,0 +1,121 @@ +/** + * This checkbox listener dynamically checks, unchecks, enables, and disables checkboxes for filters in the "Filter + * Questions" panel on the Questions page, based on the indentation of filters. This includes automatically checking + * and disabling indented filters that belong to a filter that has been checked, and unchecking and enabling filters + * that belong to a filter that has been unchecked. Additionally, this is also performed on load of the page, based on + * which filters have been checked. + */ + +var filterSummaryText; +var allCheckboxes; + +function getElementIndex(element) { + let index = 0; + while ((element = element.previousElementSibling)) { + index++; + } + return index; +} + +function getCheckboxes(indentLevel) { + return document.querySelectorAll(`[data-indent-level="${indentLevel}"]`); +} + +function getCheckboxIndexesAndArray(checkboxes) { + const checkboxIndexes = []; + const checkboxArray = []; + for (const checkbox of checkboxes) { + const index = [...document.querySelectorAll("input")].indexOf(checkbox); + checkboxIndexes.push(index); + checkboxArray.push(checkbox); + } + return [checkboxIndexes, checkboxArray]; +} + +function modifyCheckboxes(index, checkboxIndexes, checkboxArray, event) { + while (true) { + const nextIndex = checkboxIndexes.indexOf(index); + if (nextIndex !== -1) { + const checkboxChecked = event.currentTarget.checked; + checkboxArray[nextIndex].checked = checkboxChecked; + checkboxArray[nextIndex].disabled = checkboxChecked; + } else { + break; + } + index++; + } +} + +function addCheckboxListener(indent, checkboxes) { + const indentIndex = [...document.querySelectorAll("input")].indexOf(indent); + indent.addEventListener("change", (event) => { + const [checkboxIndexes, checkboxArray] = getCheckboxIndexesAndArray(checkboxes); + modifyCheckboxes(indentIndex + 1, checkboxIndexes, checkboxArray, event); + }); +} + +function modifyCheckboxesOnLoad(checkboxes) { + for (const checkbox of checkboxes) { + if (checkbox.checked) { + const event = new Event('change'); + checkbox.dispatchEvent(event); + } + } +} + +function updateFilterSummary() { + /** + * Update the summary of applied filters. + */ + var filter_count = 0; + + allCheckboxes.forEach(function (checkbox) { + if (checkbox.checked && !checkbox.disabled) { + filter_count++; + } + }); + + if (filter_count == 1) { + filterSummaryText.textContent = '1 filter applied'; + } else { + filterSummaryText.textContent = `${filter_count} filters applied`; + } +}; + +function resetFilter() { + /** + * Reset the filter to no options selected. + * + * This is different from a which sets the form + * to it's original state, which could be prepopulated from the URL. + */ + allCheckboxes.forEach(function (checkbox) { + checkbox.checked = false; + checkbox.disabled = false; + }); + updateFilterSummary(); +} + +window.onload = () => { + filterSummaryText = document.getElementById('filter-summary-text'); + + const checkboxesIndentOne = getCheckboxes(1); + const checkboxesIndentTwo = getCheckboxes(2); + const checkboxesIndentThree = getCheckboxes(3); + for (const indentOne of checkboxesIndentOne) { + addCheckboxListener(indentOne, [...checkboxesIndentTwo, ...checkboxesIndentThree]); + } + for (const indentTwo of checkboxesIndentTwo) { + addCheckboxListener(indentTwo, checkboxesIndentThree); + } + modifyCheckboxesOnLoad([...checkboxesIndentOne, ...checkboxesIndentTwo]); + + allCheckboxes = document.querySelectorAll('#question-filter input'); + allCheckboxes.forEach(function (checkbox) { + checkbox.addEventListener('change', updateFilterSummary); + }); + updateFilterSummary(); + + let resetButton = document.querySelector('#question-filter input[name="reset"]'); + resetButton.addEventListener('click', resetFilter); +} diff --git a/codewof/static/scss/_question-card.scss b/codewof/static/scss/_question-card.scss index d143a5070..6b7832559 100644 --- a/codewof/static/scss/_question-card.scss +++ b/codewof/static/scss/_question-card.scss @@ -23,7 +23,9 @@ display: grid; grid-template-areas: "qc-checkbox qc-type" - "qc-checkbox qc-title"; + "qc-checkbox qc-title" + "qc-checkbox qc-details" + "qc-tags qc-tags"; grid-template-columns: auto 1fr auto; grid-template-rows: auto auto; border: 3px solid grey; @@ -66,3 +68,16 @@ .qc-title { grid-area: qc-title; } + +.qc-details { + align-self: end; + font-size: 0.8rem; + color: #3e4347; + grid-area: qc-details +} + +.qc-tags { + align-self: end; + margin-top: 0.5rem; + grid-area: qc-tags; +} diff --git a/codewof/static/scss/_question-filter.scss b/codewof/static/scss/_question-filter.scss new file mode 100644 index 000000000..93db23e7a --- /dev/null +++ b/codewof/static/scss/_question-filter.scss @@ -0,0 +1,298 @@ +@import "core-variables"; + +input[data-indent-level="2"] + label { + margin-left: 1rem; +} +input[data-indent-level="3"] + label { + margin-left: 2rem; +} + +%filter-label { + cursor: pointer; +} + +// Difficulties + +$difficulty-easy: #F7E89C; +$difficulty-moderate: #FAD19E; +$difficulty-difficult: #FCB9A0; +$difficulty-complex: #FFA2A2; +.difficulty { + @extend .badge; + user-select: none; + vertical-align: 2px; + &-0 { + color: darken($difficulty-easy, 60%); + background-color: $difficulty-easy; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($difficulty-easy, 10%); + } + } + &-1 { + color: darken($difficulty-moderate, 60%); + background-color: $difficulty-moderate; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($difficulty-moderate, 10%); + } + } + &-2 { + color: darken($difficulty-difficult, 60%); + background-color: $difficulty-difficult; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($difficulty-difficult, 10%); + } + } + &-3 { + color: darken($difficulty-complex, 60%); + background-color: $difficulty-complex; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($difficulty-complex, 10%); + } + } +} + +.difficulty-filter { + @extend .difficulty; + @extend %filter-label; + font-size: 0.95rem; +} + +@for $i from 0 through 3 { + input[filter="#{$i}"] + label { + @extend .difficulty-filter; + @extend .difficulty-#{$i}; + } +} + +// Types + +.type { + @extend .badge; + user-select: none; + vertical-align: 2px; + &-debugging { + box-shadow:inset 0 0 0 2px $question-type-debugging; + color: darken($question-type-debugging, 60%); + background-color: lighten($question-type-debugging, 40%); + &.link:hover, + &.custom-control-label:hover { + background-color: lighten($question-type-debugging, 35%); + } + } + &-function { + box-shadow:inset 0 0 0 2px $question-type-function; + color: darken($question-type-function, 60%); + background-color: lighten($question-type-function, 40%); + &.link:hover, + &.custom-control-label:hover { + background-color: lighten($question-type-function, 35%); + } + } + &-parsons { + box-shadow:inset 0 0 0 2px $question-type-parsons; + color: darken($question-type-parsons, 60%); + background-color: lighten($question-type-parsons, 40%); + &.link:hover, + &.custom-control-label:hover { + background-color: lighten($question-type-parsons, 35%); + } + } + &-program { + box-shadow:inset 0 0 0 2px $question-type-program; + color: darken($question-type-program, 60%); + background-color: lighten($question-type-program, 40%); + &.link:hover, + &.custom-control-label:hover { + background-color: lighten($question-type-program, 35%); + } + } +} + +.type-filter { + @extend .type; + @extend %filter-label; + font-size: 0.95rem; +} + +input[filter="Debugging"] + label { + @extend .type-filter; + @extend .type-debugging; +} + +input[filter="Function"] + label { + @extend .type-filter; + @extend .type-function; +} + +input[filter="Parsons"] + label { + @extend .type-filter; + @extend .type-parsons; +} + +input[filter="Program"] + label { + @extend .type-filter; + @extend .type-program; +} + + +// Concepts + +$concept-display-text: #A2DAF2; +$concept-functions: #AADDE0; +$concept-inputs: #B1E0CF; +$concept-conditionals: #B9E3BD; +$concept-loops: #C1E5AB; +$concept-string-operations: #C8E89A; +$concept-lists: #D0EB88; +.concept { + @extend .badge; + @extend .badge-pill; + user-select: none; + vertical-align: 2px; + &-1 { + color: darken($concept-display-text, 60%); + background-color: $concept-display-text; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-display-text, 10%); + } + } + &-2 { + color: darken($concept-functions, 60%); + background-color: $concept-functions; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-functions, 10%); + } + } + &-3 { + color: darken($concept-inputs, 60%); + background-color: $concept-inputs; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-inputs, 10%); + } + } + &-4 { + color: darken($concept-conditionals, 60%); + background-color: $concept-conditionals; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-conditionals, 10%); + } + } + &-5 { + color: darken($concept-loops, 60%); + background-color: $concept-loops; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-loops, 10%); + } + } + &-6 { + color: darken($concept-string-operations, 60%); + background-color: $concept-string-operations; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-string-operations, 10%); + } + } + &-7 { + color: darken($concept-lists, 60%); + background-color: $concept-lists; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($concept-lists, 10%); + } + } +} + +.concept-filter { + @extend .concept; + @extend %filter-label; + font-size: 0.95rem; +} + +@for $i from 1 through 7 { + input[filter="#{$i}"][name="concepts"] + label { + @extend .concept-filter; + @extend .concept-#{$i}; + } +} + +// Contexts + +$context-real-world-applications: #e3b5f5; +$context-mathematics: #bea7f2; +.context { + @extend .badge; + @extend .badge-pill; + user-select: none; + vertical-align: 2px; + &-1 { + color: darken($context-real-world-applications, 60%); + background-color: $context-real-world-applications; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($context-real-world-applications, 10%); + } + } + &-2 { + color: darken($context-mathematics, 60%); + background-color: $context-mathematics; + &.link:hover, + &.custom-control-label:hover { + background-color: darken($context-mathematics, 10%); + } + } +} + +.context-filter { + @extend .context; + @extend %filter-label; + font-size: 0.95rem; +} + +@for $i from 1 through 2 { + input[filter="#{$i}"][name="contexts"] + label { + @extend .context-filter; + @extend .context-#{$i}; + } +} + +.filter-box { + margin: 12px; + font-weight: bold; +} + +details#question-filter { + &[open] summary:before { + content: '⊖'; + } + + summary { + &::-webkit-details-marker { + display: none; + } + + &:before { + content: '⊕'; + margin-right: 0.5rem; + padding-bottom: 0.15rem; + opacity: .6; + } + #filter-summary { + @extend .flex-grow; + display: flex; + flex-direction: column; + justify-content: center; + font-size: 80%; + margin-left: 1rem; + min-height: 2.7rem; + } + } +} diff --git a/codewof/static/scss/_question-recommendations.scss b/codewof/static/scss/_question-recommendations.scss new file mode 100644 index 000000000..46226bb40 --- /dev/null +++ b/codewof/static/scss/_question-recommendations.scss @@ -0,0 +1,11 @@ +.recommendation-border { + padding-top: 1rem; + border: 3px solid rgba(0, 0, 0, 0.15); + border-radius: 5px; + height: 100%; +} + +.recommendation { + padding-left: 1rem; + padding-right: 1rem; +} diff --git a/codewof/static/scss/website.scss b/codewof/static/scss/website.scss index a5bff23dc..5721a6d9a 100644 --- a/codewof/static/scss/website.scss +++ b/codewof/static/scss/website.scss @@ -43,6 +43,8 @@ @import "homepage"; @import "footer"; @import "question-card"; +@import "question-filter"; +@import "question-recommendations"; @import url('https://fonts.googleapis.com/css?family=Noto+Sans:400,700|Righteous&display=swap'); @import "node_modules/codemirror/lib/codemirror"; @@ -338,7 +340,7 @@ $red: #b94a48; white-space: normal; } -#notifications-p { +.email-reminder-warning-p { margin-top: 20px; } @@ -346,3 +348,11 @@ $red: #b94a48; #recaptcha-declaration { font-size: small; } + +summary { + cursor: pointer; +} + +details div { + padding: ($spacer * .1); +} diff --git a/codewof/templates/account/email/base_message.html b/codewof/templates/account/email/base_message.html new file mode 100644 index 000000000..5aa69ebb1 --- /dev/null +++ b/codewof/templates/account/email/base_message.html @@ -0,0 +1,18 @@ +{% extends "email_template.html" %} +{% load account %} +{% load i18n %} + +{% user_display user as user_display %} + +{% block greeting %} + {% blocktrans with site_name=current_site.name %} + Hello from {{ site_name }}! + {% endblocktrans %} +{% endblock %} +{% block main_message %}{% block content %}{% endblock %}{% endblock %} + +{% block conclusion %} + {% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you for using {{ site_name }}! +
+ {{ site_domain }}{% endblocktrans %} +{% endblock %} diff --git a/codewof/templates/account/email/email_confirmation_message.html b/codewof/templates/account/email/email_confirmation_message.html new file mode 100644 index 000000000..a43bbd4e7 --- /dev/null +++ b/codewof/templates/account/email/email_confirmation_message.html @@ -0,0 +1,7 @@ +{% extends "account/email/base_message.html" %} +{% load account %} +{% load i18n %} + +{% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}You're receiving this e-mail because user {{ user_display }} has given your e-mail address to register an account on {{ site_domain }}. +

+To confirm this is correct, go to {{ activate_url }}{% endblocktrans %}{% endautoescape %}{% endblock %} diff --git a/codewof/templates/account/email/email_confirmation_signup_message.html b/codewof/templates/account/email/email_confirmation_signup_message.html new file mode 100644 index 000000000..aa4ccd35a --- /dev/null +++ b/codewof/templates/account/email/email_confirmation_signup_message.html @@ -0,0 +1 @@ +{% include "account/email/email_confirmation_message.html" %} diff --git a/codewof/templates/account/email/email_confirmation_signup_subject.html b/codewof/templates/account/email/email_confirmation_signup_subject.html new file mode 100644 index 000000000..7496bb2ea --- /dev/null +++ b/codewof/templates/account/email/email_confirmation_signup_subject.html @@ -0,0 +1 @@ +{% include "account/email/email_confirmation_subject.html" %} diff --git a/codewof/templates/account/email/email_confirmation_subject.html b/codewof/templates/account/email/email_confirmation_subject.html new file mode 100644 index 000000000..b0a876f5b --- /dev/null +++ b/codewof/templates/account/email/email_confirmation_subject.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Please Confirm Your E-mail Address{% endblocktrans %} +{% endautoescape %} diff --git a/codewof/templates/account/email/password_reset_key_message.html b/codewof/templates/account/email/password_reset_key_message.html new file mode 100644 index 000000000..1d987d557 --- /dev/null +++ b/codewof/templates/account/email/password_reset_key_message.html @@ -0,0 +1,10 @@ +{% extends "account/email/base_message.html" %} +{% load i18n %} + +{% block content %}{% autoescape off %}{% blocktrans %}You're receiving this e-mail because you or someone else has requested a password for your user account. +
+It can be safely ignored if you did not request a password reset. Click the link below to reset your password.{% endblocktrans %} +

+{{ password_reset_url }}{% if username %} + +{% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %}{% endif %}{% endautoescape %}{% endblock %} diff --git a/codewof/templates/account/email/password_reset_key_subject.html b/codewof/templates/account/email/password_reset_key_subject.html new file mode 100644 index 000000000..6840c40b7 --- /dev/null +++ b/codewof/templates/account/email/password_reset_key_subject.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Password Reset E-mail{% endblocktrans %} +{% endautoescape %} diff --git a/codewof/templates/email_template.html b/codewof/templates/email_template.html new file mode 100644 index 000000000..db91dc4e9 --- /dev/null +++ b/codewof/templates/email_template.html @@ -0,0 +1,176 @@ + +{% load static %} + + + + + + + {% block title %}{% endblock %} + + + + + + + + + +
+ +
+ +
+  â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ  +
+ + + + + +
+ + +
+ + + + CodeWOF Logo + + + +

+ {% block header %}{% endblock %} +

+
+ + +
+

+ {% block greeting %}{% endblock %} +

+ +

+ {% block main_message %}{% endblock %} +

+ + {% block link %}{% endblock %} + +

+ {% block conclusion %}{% endblock %} +

+
+ + + +
+ + + diff --git a/codewof/templates/general/contact-email.html b/codewof/templates/general/contact-email.html new file mode 100644 index 000000000..f265b1de2 --- /dev/null +++ b/codewof/templates/general/contact-email.html @@ -0,0 +1,9 @@ +{% extends "email_template.html" %} + +{% block title %}CodeWOF Contact{% endblock %} + +{% block header %}{{ subject }}{% endblock %} + +{% block main_message %}{{ message }}{% endblock %} + +{% block conclusion %}From,
{{ name }} <{{ email }}>{% endblock %} diff --git a/codewof/templates/programming/question.html b/codewof/templates/programming/question.html index 161a0a7dd..9c75e2cdb 100644 --- a/codewof/templates/programming/question.html +++ b/codewof/templates/programming/question.html @@ -10,6 +10,19 @@

{{ question.title }}

+
+ {% include "programming/question_components/badges/difficulty.html" with link=True %} + {% for concept in question.concepts.all %} + {% if not concept.has_children %} + {% include "programming/question_components/badges/concept.html" with link=True %} + {% endif %} + {% endfor %} + {% for context in question.contexts.all %} + {% if not context.has_children %} + {% include "programming/question_components/badges/context.html" with link=True %} + {% endif %} + {% endfor %} +
{{ question.question_text|safe }}
diff --git a/codewof/templates/programming/question_components/badges/concept.html b/codewof/templates/programming/question_components/badges/concept.html new file mode 100644 index 000000000..d6f90e4c0 --- /dev/null +++ b/codewof/templates/programming/question_components/badges/concept.html @@ -0,0 +1,4 @@ +<{% if link %}a href="{% url 'programming:question_list' %}?concepts={{ concept.pk }}"{% else %}span{% endif %} + class="concept concept-{{ concept.number }}{% if link %} link{% endif %}"> + {{ concept }} + diff --git a/codewof/templates/programming/question_components/badges/context.html b/codewof/templates/programming/question_components/badges/context.html new file mode 100644 index 000000000..27d16816e --- /dev/null +++ b/codewof/templates/programming/question_components/badges/context.html @@ -0,0 +1,4 @@ +<{% if link %}a href="{% url 'programming:question_list' %}?contexts={{ context.pk }}"{% else %}span{% endif %} + class="context context-{{ context.number }}{% if link %} link{% endif %}"> + {{ context }} + diff --git a/codewof/templates/programming/question_components/badges/difficulty.html b/codewof/templates/programming/question_components/badges/difficulty.html new file mode 100644 index 000000000..2970a1d78 --- /dev/null +++ b/codewof/templates/programming/question_components/badges/difficulty.html @@ -0,0 +1,4 @@ +<{% if link %}a href="{% url 'programming:question_list' %}?difficulty_level={{ question.difficulty_level.pk }}"{% else %}span{% endif %} + class="difficulty difficulty-{{ question.difficulty_level.level }}{% if link %} link{% endif %}"> + {{ question.difficulty_level }} + diff --git a/codewof/templates/programming/question_components/question-card.html b/codewof/templates/programming/question_components/question-card.html index 26446f422..f32b1b2d0 100644 --- a/codewof/templates/programming/question_components/question-card.html +++ b/codewof/templates/programming/question_components/question-card.html @@ -17,5 +17,18 @@
{{ question.title }}
+
+ {% include "programming/question_components/badges/difficulty.html" %} + {% for concept in question.concepts.all %} + {% if not concept.has_children %} + {% include "programming/question_components/badges/concept.html" %} + {% endif %} + {% endfor %} + {% for context in question.contexts.all %} + {% if not context.has_children %} + {% include "programming/question_components/badges/context.html" %} + {% endif %} + {% endfor %} +
diff --git a/codewof/templates/programming/question_components/question_recommendations.html b/codewof/templates/programming/question_components/question_recommendations.html new file mode 100644 index 000000000..b657549b6 --- /dev/null +++ b/codewof/templates/programming/question_components/question_recommendations.html @@ -0,0 +1,24 @@ +{% load static %} + +
+ {% if recommendations %} + {% for description, question in recommendations %} +
+
+
+

+ {{ description.0 }} +
+ {{ description.1 }} +

+ {% include "programming/question_components/question-card.html" %} +
+
+
+ {% endfor %} + {% else %} + + {% endif %} +
diff --git a/codewof/templates/programming/question_list.html b/codewof/templates/programming/question_list.html index 14f5f55a5..c3a36f295 100644 --- a/codewof/templates/programming/question_list.html +++ b/codewof/templates/programming/question_list.html @@ -1,5 +1,7 @@ {% extends "base.html" %} +{% load static crispy_forms_tags %} + {% block title %}Questions{% endblock %} {% block page_heading %} @@ -8,14 +10,37 @@

Questions

{% block content %}

Currently all questions are based in Python 3.

+

Your recommended questions

+ {% include "programming/question_components/question_recommendations.html" %} +
+

All questions

+
+ + Filter Questions +
+
+
+
+ {% crispy filter.form filter_formatter %} +
- {% if questions %} + {% if filter.qs %}
- {% for question in questions %} + {% for question in filter.qs %}
{% include "programming/question_components/question-card.html" %}
{% endfor %}
- {% endif %} -{% endblock %} + {% else %} +

+ Sorry!
+ No questions found matching the selected filters. +

+ {% endif %} + +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/codewof/templates/research/email/consent_confirm.html b/codewof/templates/research/email/consent_confirm.html new file mode 100644 index 000000000..5a5716e47 --- /dev/null +++ b/codewof/templates/research/email/consent_confirm.html @@ -0,0 +1,27 @@ +{% extends "email_template.html" %} + +{% block title %}CodeWOF Research Consent Confirmation{% endblock %} + +{% block header %}CodeWOF Research Consent Confirmation{% endblock %} + +{% block greeting %}Dear {{ user.first_name }},{% endblock %} + +{% block main_message %} + +Thank you for registering for the "{{ study.title }}" study. +Below is a copy of the information sheet, and your signed consent form.
+ +{% include "research/study_description.html" %} + +Consent Form

+{% for field in form %} +{% if field.value %}I AGREE{% else %}I DO NOT AGREE{% endif %} - {{ field.label }}
+{% endfor %} + +
+Email address: {{ user.email }}
+Date: {{ registration.datetime|time:"g:i A" }} {{ registration.datetime|date:"l j F Y" }} + +{% endblock %} + +{% block conclusion %}Thank you,
The CodeWOF team{% endblock %} diff --git a/codewof/templates/research/email/consent_confirm.tpl b/codewof/templates/research/email/consent_confirm.tpl deleted file mode 100644 index 78c958474..000000000 --- a/codewof/templates/research/email/consent_confirm.tpl +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "mail_templated/base.tpl" %} - -{% block subject %} - You have successfully registered for "{{ study.title }}" study -{% endblock %} - -{% block body %} -Dear {{ user.first_name }}, - -Thank you for registering for the "{{ study.title }}" study. -Below is a copy of the information sheet, and your signed consent form. - -{{ study.description|striptags }} - -------------------------------------------------------- -Signed Consent Form -------------------------------------------------------- - -{% for field in form %} -{% if field.value %}I AGREE{% else %}I DO NOT AGREE{% endif %} - {{ field.label }} -{% endfor %} -- Email address: {{ user.email }} -- Date: {{ registration.datetime|time:"g:i A" }} {{ registration.datetime|date:"l j F Y" }} - -Thank you, - -The codeWOF team -{% endblock %} - -{% block html %} -

Dear {{ user.first_name }},

- -

- Thank you for registering for the "{{ study.title }}" study. - Below is a copy of the information sheet, and your signed consent form. -

- - {{ study.description|safe }} - -

Consent Form

- - {% for field in form %} -

- {% if field.value %} - I AGREE - {% else %} - I DO NOT AGREE - {% endif %} - - {{ field.label }} -

- {% endfor %} -

- Email address: {{ user.email }} -

-

- Date: {{ registration.datetime|time:"g:i A" }} {{ registration.datetime|date:"l j F Y" }} -

- -

Thank you,

- -

The codeWOF team

-{% endblock %} diff --git a/codewof/templates/users/create_invitations.html b/codewof/templates/users/create_invitations.html index 154ff38eb..0b00bc038 100644 --- a/codewof/templates/users/create_invitations.html +++ b/codewof/templates/users/create_invitations.html @@ -6,13 +6,49 @@ {% block title %}{% trans "Send Invitations" %}{% endblock %} {% block content %} -
-

{% trans "Send Invitations" %}

-
- {% csrf_token %} - {{ form|crispy }} - - -
-
+
+

{% trans "Send Invitations" %}

+
+ {% csrf_token %} +

An invitation will be sent to each email address below inviting them to join this group. If there is no + codeWOF account with an email you provide, then we will also invite them to join codeWOF.

+ +
+ + Email Contents (hide/show) + + +
+ If the user exists:
+ Group invitation +

Hi John,

+

+ Jane Doe has invited you to join the Group 'Sample Group'. Click the link below to sign in. You will see your + invitation in the dashboard, where you can join the group. +

+

Sign In

+

Thanks,

+

The Computer Science Education Research Group

+ + If the user does not exist:
+ Group invitation +

Hi,

+

+ Jane Doe has invited you to join the Group 'Sample Group'. CodeWOF helps you maintain your programming fitness + with short daily programming exercises. With a free account you can save your progress and track your + programming fitness over time. Click the link below to make an account, using the email sample@email.com. You + will see your invitation in the dashboard, where you can join the group. If you already have a CodeWOF account, + then add sample@email.com to your profile to make the invitation appear. +

+

Sign In

+

Thanks,

+

The Computer Science Education Research Group

+
+
+ + {{ form|crispy }} + + +
+
{% endblock %} diff --git a/codewof/templates/users/dashboard.html b/codewof/templates/users/dashboard.html index 6b59c0ef9..0c0e053ba 100644 --- a/codewof/templates/users/dashboard.html +++ b/codewof/templates/users/dashboard.html @@ -13,29 +13,13 @@

{% comment %} Left column {% endcomment %}
{% comment %} Conditional here to check if allowed to use website {% endcomment %} -

Your questions for today

+

Your recommended questions

We recommend doing one or two questions per day, to maintain your programming skills over a long period of time.

- - {% if questions_to_do %} -
- {% for question in questions_to_do %} -
- {% include "programming/question_components/question-card.html" %} -
- {% endfor %} -
- {% if all_complete %} - - {% endif %} - {% else %} - - {% endif %} - + {% include "programming/question_components/question_recommendations.html" %} +

You've solved {{ num_questions_answered }} question{{ num_questions_answered|pluralize }} in the last month!

+ View All Questions +

Groups

{% if memberships %} diff --git a/codewof/templates/users/email_reminder.html b/codewof/templates/users/email_reminder.html index 5c853981f..0ec394485 100644 --- a/codewof/templates/users/email_reminder.html +++ b/codewof/templates/users/email_reminder.html @@ -1,4 +1,4 @@ -{% extends "users/email_template.html" %} +{% extends "email_template.html" %} {% block title %}CodeWOF Reminder Email{% endblock %} @@ -16,16 +16,13 @@ {% endblock %} -{% block footer %} - +{% block conclusion %}Thanks,
The CodeWOF team{% endblock %} + +{% block footer_message %} +

+ You received this email because you opted into reminders. You can + + change your reminder settings here + . +

{% endblock %} diff --git a/codewof/templates/users/email_template.html b/codewof/templates/users/email_template.html deleted file mode 100644 index 43d0b3966..000000000 --- a/codewof/templates/users/email_template.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - {% block title %}{% endblock %} - - - - - - - - - -
- -
- -
-  â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ â€Œ  -
- - - - - -
- - -
- - - -
- - - - - - - - - - - - - - - - - - - - -
-
- - -

- {% block header %}{% endblock %} -

-
- - -
-

- {% block greeting %}{% endblock %} -

- -

- {% block main_message %}{% endblock %} -

- - {% block link %}{% endblock %} - -

- Thanks,
- The Computer Science Education Research Group -

-
- - {% block footer %}{% endblock %} - -
- - - diff --git a/codewof/templates/users/group_invitation.html b/codewof/templates/users/group_invitation.html index 7e31eefbe..1399526ad 100644 --- a/codewof/templates/users/group_invitation.html +++ b/codewof/templates/users/group_invitation.html @@ -1,4 +1,4 @@ -{% extends "users/email_template.html" %} +{% extends "email_template.html" %} {% block title %}CodeWOF Group Invitation Email{% endblock %} @@ -15,3 +15,5 @@ {{ button_text }} {% endblock %} + +{% block conclusion %}Thanks,
The CodeWOF team{% endblock %} diff --git a/codewof/tests/codewof_test_data_generator.py b/codewof/tests/codewof_test_data_generator.py index 6f0c2b650..e383fff77 100644 --- a/codewof/tests/codewof_test_data_generator.py +++ b/codewof/tests/codewof_test_data_generator.py @@ -13,7 +13,10 @@ QuestionTypeParsons, QuestionTypeDebugging, QuestionTypeProgramTestCase, - Like + Like, + DifficultyLevel, + ProgrammingConcepts, + QuestionContexts, ) from research.models import StudyRegistration from users.models import UserType, Group, GroupRole, Membership, Invitation @@ -23,20 +26,38 @@ def generate_questions(): """Generate questions for use in codeWOF tests. Questions contain minimum information and complexity.""" - Question.objects.create(slug="question-1", title='Test', question_text='Hello') + DifficultyLevel.objects.create(slug='easy', level=0, name='Easy') + DifficultyLevel.objects.create(slug='moderate', level=1, name='Moderate') + DifficultyLevel.objects.create(slug='difficult', level=2, name='Difficult') + Question.objects.create( + slug="question-1", + title='Test', + question_text='Hello', + difficulty_level=DifficultyLevel.objects.get(slug='easy'), + ) + + ProgrammingConcepts.objects.create(name='Display Text', slug='display-text', number=1) + Question.objects.get(slug='question-1').concepts.add(ProgrammingConcepts.objects.get(slug='display-text')) + + QuestionContexts.objects.create(name='Mathematics', slug='mathematics', number=2) + Question.objects.get(slug='question-1').contexts.add(QuestionContexts.objects.get(slug='mathematics')) QuestionTypeProgram.objects.create( slug="program-question-1", title='Test', question_text='Hello', - solution="question_answer" + solution="question_answer", + difficulty_level=DifficultyLevel.objects.get(slug='easy'), ) + Question.objects.get(slug='program-question-1').concepts.add(ProgrammingConcepts.objects.get(slug='display-text')) + Question.objects.get(slug='program-question-1').contexts.add(QuestionContexts.objects.get(slug='mathematics')) QuestionTypeFunction.objects.create( slug="function-question-1", title='Test', question_text='Hello', - solution="question_answer" + solution="question_answer", + difficulty_level=DifficultyLevel.objects.get(slug='moderate'), ) QuestionTypeParsons.objects.create( @@ -44,7 +65,8 @@ def generate_questions(): title='Test', question_text='Hello', solution="question_answer", - lines="These are\nthe lines" + lines="These are\nthe lines", + difficulty_level=DifficultyLevel.objects.get(slug='difficult'), ) QuestionTypeDebugging.objects.create( @@ -52,7 +74,8 @@ def generate_questions(): title='Test', question_text='Hello', solution="question_answer", - initial_code='' + initial_code='', + difficulty_level=DifficultyLevel.objects.get(slug='difficult'), ) @@ -231,10 +254,17 @@ def generate_email_accounts(): primary=True, verified=True ) + email5 = EmailAddress( + user=user1, + email="UPPERcase@mail.com", + primary=False, + verified=True + ) email1.save() email2.save() email3.save() email4.save() + email5.save() def generate_invitations(): @@ -260,7 +290,7 @@ def generate_invitations(): date_expires=datetime.date(2021, 10, 22) ) invitation_3 = Invitation.objects.create( - email=user1.email, + email="uppercase@mail.com", group=group_team_cserg, inviter=user2, date_sent=datetime.date(2020, 10, 20), @@ -397,6 +427,27 @@ def generate_attempts(): datetime=datetime.date(2019, 9, 10)) +def generate_attempts_multiple_questions(): + """ + Generate attempts for codeWOF tests. + + Attempts are generated for user 1 and 3 different questions, with attempts created to cover consecutive days, + failed attempts, and passed attempts. + """ + user = User.objects.get(id=1) + program_question = Question.objects.get(slug='program-question-1') + function_question = Question.objects.get(slug='function-question-1') + parsons_question = Question.objects.get(slug='parsons-question-1') + Attempt.objects.create(profile=user.profile, question=program_question, passed_tests=False, + datetime=datetime.date(2019, 9, 9)) + Attempt.objects.create(profile=user.profile, question=program_question, passed_tests=False, + datetime=datetime.date(2019, 9, 9)) + Attempt.objects.create(profile=user.profile, question=program_question, passed_tests=True, + datetime=datetime.date(2019, 9, 10)) + Attempt.objects.create(profile=user.profile, question=function_question, passed_tests=True) + Attempt.objects.create(profile=user.profile, question=parsons_question, passed_tests=False) + + def generate_likes(): """Generate likes for codeWOF tests.""" sally = User.objects.get(id=2) diff --git a/codewof/tests/programming/factories.py b/codewof/tests/programming/factories.py index 9d78f3cbe..38638fcf5 100644 --- a/codewof/tests/programming/factories.py +++ b/codewof/tests/programming/factories.py @@ -2,12 +2,12 @@ import random from factory import ( - DjangoModelFactory, Faker, Iterator, LazyAttribute, post_generation, ) +from factory.django import DjangoModelFactory from programming.models import Question, Profile, Attempt from django.utils import timezone diff --git a/codewof/tests/programming/test_codewof_utils.py b/codewof/tests/programming/test_codewof_utils.py index 2a1f15a13..7c0321791 100644 --- a/codewof/tests/programming/test_codewof_utils.py +++ b/codewof/tests/programming/test_codewof_utils.py @@ -6,12 +6,16 @@ Attempt, Achievement, Earned, + DifficultyLevel, + ProgrammingConcepts, + QuestionContexts, ) from tests.codewof_test_data_generator import ( generate_users, generate_achievements, generate_questions, generate_attempts, + generate_attempts_multiple_questions, ) from programming.codewof_utils import ( add_points, @@ -19,11 +23,14 @@ backdate_points_and_achievements, calculate_achievement_points, check_achievement_conditions, + filter_attempts_in_past_month, get_days_consecutively_answered, get_questions_answered_in_past_month, POINTS_ACHIEVEMENT, POINTS_SOLUTION, ) +from programming.skill_and_level_tracking import get_level_and_skill_dict, get_level_and_skill_info +from programming.question_recommendations import get_scores, get_recommended_questions, get_unsolved_questions from tests.conftest import user User = get_user_model() @@ -139,12 +146,162 @@ def test_get_days_consecutively_answered(self): streak = get_days_consecutively_answered(user.profile) self.assertEqual(streak, 2) + def test_filter_attempts_in_past_month(self): + generate_attempts() + user = User.objects.get(id=1) + user_attempts = Attempt.objects.filter(profile=user.profile) + solved = filter_attempts_in_past_month(user_attempts) + self.assertEqual(len(solved), 3) + self.assertTrue(all(type(solved_attempt) == Attempt for solved_attempt in solved)) + def test_get_questions_answered_in_past_month(self): generate_attempts() user = User.objects.get(id=1) num_solved = get_questions_answered_in_past_month(user.profile) self.assertEqual(num_solved, 1) + def test_get_level_and_skill_dict(self): + generate_attempts_multiple_questions() + user = User.objects.get(id=1) + all_attempts = Attempt.objects.filter(profile=user.profile) + solved = all_attempts.filter(passed_tests=True) + level_and_skill_dict = get_level_and_skill_dict(solved, all_attempts) + expected = { + 'difficulty_level': { + DifficultyLevel.objects.get(slug='easy').level: { + 'num_solved': len(solved.filter(question__slug='program-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='program-question-1'))], + }, + DifficultyLevel.objects.get(slug='moderate').level: { + 'num_solved': len(solved.filter(question__slug='function-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='function-question-1'))], + }, + }, + 'concept_num': { + ProgrammingConcepts.objects.get(slug='display-text').number: { + 'num_solved': len(solved.filter(question__slug='program-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='program-question-1'))]}, + }, + 'context_num': { + QuestionContexts.objects.get(slug='mathematics').number: { + 'num_solved': len(solved.filter(question__slug='program-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='program-question-1'))]}, + }, + } + self.assertEqual(level_and_skill_dict, expected) + + def test_get_level_and_skill_info(self): + generate_attempts_multiple_questions() + user = User.objects.get(id=1) + all_attempts = Attempt.objects.filter(profile=user.profile) + solved = all_attempts.filter(passed_tests=True) + level_and_skill_info = get_level_and_skill_info(user.profile) + expected = { + 'all': { + 'difficulty_level': { + DifficultyLevel.objects.get(slug='easy').level: { + 'num_solved': len(solved.filter(question__slug='program-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='program-question-1'))], + }, + DifficultyLevel.objects.get(slug='moderate').level: { + 'num_solved': len(solved.filter(question__slug='function-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='function-question-1'))], + }, + }, + 'concept_num': { + ProgrammingConcepts.objects.get(slug='display-text').number: { + 'num_solved': len(solved.filter(question__slug='program-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='program-question-1'))]}, + }, + 'context_num': { + QuestionContexts.objects.get(slug='mathematics').number: { + 'num_solved': len(solved.filter(question__slug='program-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='program-question-1'))]}, + }, + }, + 'month': { + 'difficulty_level': { + DifficultyLevel.objects.get(slug='moderate').level: { + 'num_solved': len(solved.filter(question__slug='function-question-1')), + 'attempts': [len(all_attempts.filter(question__slug='function-question-1'))], + }, + }, + 'concept_num': {}, + 'context_num': {}, + }, + } + self.assertEqual(level_and_skill_info, expected) + + def test_get_scores(self): + generate_attempts_multiple_questions() + user = User.objects.get(id=1) + all_attempts = Attempt.objects.filter(profile=user.profile) + solved = all_attempts.filter(passed_tests=True) + level_and_skill_info = get_level_and_skill_info(user.profile) + scores = get_scores(level_and_skill_info) + expected = { + 'difficulty': { + 'all': [ + sum([ + -len(solved.filter(question__difficulty_level__slug='easy')), + len(all_attempts.filter(question__difficulty_level__slug='easy')), + -1, + ]), + sum([ + -len(solved.filter(question__difficulty_level__slug='moderate')), + len(all_attempts.filter(question__difficulty_level__slug='moderate')), + -1, + ]), + None, + ], + 'month': [ + None, + sum([ + -len(solved.filter(question__difficulty_level__slug='moderate')), + len(all_attempts.filter(question__difficulty_level__slug='moderate')), + -1, + ]), + None, + ], + 'numbers': sorted(list(set([difficulty.level for difficulty in DifficultyLevel.objects.all()]))), + }, + 'concept': { + 'all': [ + sum([ + -len(solved.filter(question__slug='program-question-1')), + len(all_attempts.filter(question__slug='program-question-1')), + -1, + ]), + ], + 'month': [None], + 'numbers': sorted(list(set([concept.number for concept in ProgrammingConcepts.objects.all()]))), + }, + 'context': { + 'all': [ + sum([ + -len(solved.filter(question__slug='program-question-1')), + len(all_attempts.filter(question__slug='program-question-1')), + -1, + ]), + ], + 'month': [None], + 'numbers': sorted(list(set([context.number for context in QuestionContexts.objects.all()]))), + }, + } + self.assertEqual(scores, expected) + + def test_get_num_unsolved_questions(self): + generate_attempts_multiple_questions() + user = User.objects.get(id=1) + num_unsolved_questions = len(get_unsolved_questions(user.profile)) + self.assertEqual(num_unsolved_questions, 4) + + def test_get_num_recommended_questions(self): + generate_attempts_multiple_questions() + user = User.objects.get(id=1) + num_recommended_questions = len(get_recommended_questions(user.profile)) + self.assertEqual(num_recommended_questions, 2) + def test_backdate_points_correct_second_attempt(self): user = User.objects.get(id=2) question = Question.objects.get(slug='question-1') diff --git a/codewof/tests/programming/test_models.py b/codewof/tests/programming/test_models.py index 3db6ab4ff..41eae4b14 100644 --- a/codewof/tests/programming/test_models.py +++ b/codewof/tests/programming/test_models.py @@ -276,6 +276,18 @@ def test_instance_of_question(self): question = Question.objects.get_subclass(slug='question-1') self.assertTrue(isinstance(question, Question)) + def test_difficulty(self): + question = Question.objects.get(slug='question-1') + self.assertEqual('Easy', question.difficulty_level.name) + + def test_concepts(self): + question = Question.objects.get(slug='question-1') + self.assertEqual('Display Text', question.concepts.all().first().name) + + def test_contexts(self): + question = Question.objects.get(slug='question-1') + self.assertEqual('Mathematics', question.contexts.all().first().name) + class QuestionTypeProgramModelTests(TestCase): @classmethod diff --git a/codewof/tests/users/factories.py b/codewof/tests/users/factories.py index 2141c3734..56c99919e 100644 --- a/codewof/tests/users/factories.py +++ b/codewof/tests/users/factories.py @@ -1,34 +1,20 @@ """Module for factories for tesing user application.""" -from typing import Any, Sequence from django.contrib.auth import get_user_model import factory +from factory.django import DjangoModelFactory from users.models import UserType -class UserFactory(factory.DjangoModelFactory): +class UserFactory(DjangoModelFactory): """Factory for generating users.""" email = factory.Faker("email") first_name = factory.Faker("first_name") last_name = factory.Faker("last_name") + password = factory.Faker("password") user_type = factory.Iterator(UserType.objects.all()) - @factory.post_generation - def password(self, create: bool, extracted: Sequence[Any], **kwargs): - """Create password for user.""" - password = factory.Faker( - "password", - length=42, - special_chars=True, - digits=True, - upper_case=True, - lower_case=True, - ).generate( - extra_kwargs={} - ) - self.set_password(password) - class Meta: """Metadata for UserFactory class.""" diff --git a/codewof/tests/users/test_remove_expired_invitations.py b/codewof/tests/users/test_remove_expired_invitations.py index 94b16b311..2991660a9 100644 --- a/codewof/tests/users/test_remove_expired_invitations.py +++ b/codewof/tests/users/test_remove_expired_invitations.py @@ -34,7 +34,7 @@ def setUp(self): self.sally = User.objects.get(pk=2) self.invitation1 = Invitation.objects.get(email=self.john.email, group=self.group_team_300, inviter=self.sally) self.invitation2 = Invitation.objects.get(email="john@mail.com", group=self.group_mystery, inviter=self.sally) - self.invitation3 = Invitation.objects.get(email=self.john.email, group=self.group_team_cserg, + self.invitation3 = Invitation.objects.get(email="uppercase@mail.com", group=self.group_team_cserg, inviter=self.sally) def call_command(self, *args, **kwargs): diff --git a/codewof/tests/users/test_utils.py b/codewof/tests/users/test_utils.py index e13e26196..ce4f84cbc 100644 --- a/codewof/tests/users/test_utils.py +++ b/codewof/tests/users/test_utils.py @@ -30,24 +30,24 @@ def setUp(self): self.group_north = Group.objects.get(name="Group North") def test_user_exists(self): - expected_url = settings.CODEWOF_DOMAIN + reverse('users:dashboard') - expected = "Hi Sally,\n\nJohn Doe has invited you to join the Group 'Group North'. Click the link below to "\ - "sign in. You will see your invitation in the dashboard, where you can join the group.\n\n{}"\ - "\n\nThanks,\nThe Computer Science Education Research Group".format(expected_url) + sign_in_url = settings.CODEWOF_DOMAIN + reverse('users:dashboard') + expected = f"Hi Sally,\n\nJohn Doe has invited you to join the Group 'Group North'. Click the link below to " \ + f"sign in. You will see your invitation in the dashboard, where you can join the group.\n\n" \ + f"Sign In: {sign_in_url}\n\nThanks,\nThe CodeWOF team\n\n{settings.CODEWOF_DOMAIN}" self.assertEqual(create_invitation_plaintext(True, self.sally.first_name, self.john.first_name + " " + self.john.last_name, self.group_north.name, self.sally.email), expected) def test_user_does_not_exist(self): - expected_url = settings.CODEWOF_DOMAIN + reverse('account_signup') - expected = "Hi,\n\nJohn Doe has invited you to join the Group 'Group North'. CodeWOF helps you maintain your "\ - "programming fitness with short daily programming exercises. With a free account you can save your"\ - " progress and track your programming fitness over time. Click the link below to make an account,"\ - " using the email unknown@mail.com. You will see your invitation in the dashboard, where you can "\ - "join the group. If you already have a CodeWOF account, then add unknown@mail.com to your profile "\ - "to make the invitation appear.\n\n{}\n\nThanks,\nThe Computer Science Education Research Group"\ - .format(expected_url) + sign_up_url = settings.CODEWOF_DOMAIN + reverse('account_signup') + expected = f"Hi,\n\nJohn Doe has invited you to join the Group 'Group North'. CodeWOF helps you maintain " \ + f"your programming fitness with short daily programming exercises. With a free account you can " \ + f"save your progress and track your programming fitness over time. Click the link below to make " \ + f"an account, using the email unknown@mail.com. You will see your invitation in the dashboard, " \ + f"where you can join the group. If you already have a CodeWOF account, then add unknown@mail.com" \ + f" to your profile to make the invitation appear.\n\nSign Up: {sign_up_url}\n\nThanks,\nThe " \ + f"CodeWOF team\n\n{settings.CODEWOF_DOMAIN}" self.assertEqual(create_invitation_plaintext(False, None, self.john.first_name + " " + self.john.last_name, self.group_north.name, "unknown@mail.com"), expected) @@ -127,10 +127,10 @@ def setUp(self): def test_email_sent_user_exists(self): send_invitation_email(self.sally, self.john, self.group_north.name, self.sally.email) outbox = get_outbox_sorted() - expected_url = settings.CODEWOF_DOMAIN + reverse('users:dashboard') - expected = "Hi Sally,\n\nJohn Doe has invited you to join the Group 'Group North'. Click the link below to "\ - "sign in. You will see your invitation in the dashboard, where you can join the group.\n\n{}"\ - "\n\nThanks,\nThe Computer Science Education Research Group".format(expected_url) + sign_in_url = settings.CODEWOF_DOMAIN + reverse('users:dashboard') + expected = f"Hi Sally,\n\nJohn Doe has invited you to join the Group 'Group North'. Click the link below to " \ + f"sign in. You will see your invitation in the dashboard, where you can join the group.\n\n" \ + f"Sign In: {sign_in_url}\n\nThanks,\nThe CodeWOF team\n\n{settings.CODEWOF_DOMAIN}" self.assertEqual(len(outbox), 1) self.assertTrue(self.sally.first_name in outbox[0].body) self.assertTrue(expected in outbox[0].body) @@ -138,13 +138,13 @@ def test_email_sent_user_exists(self): def test_email_sent_user_does_not_exist(self): send_invitation_email(None, self.john, self.group_north.name, "unknown@mail.com") outbox = get_outbox_sorted() - expected_url = settings.CODEWOF_DOMAIN + reverse('account_signup') - expected = "Hi,\n\nJohn Doe has invited you to join the Group 'Group North'. CodeWOF helps you maintain your "\ - "programming fitness with short daily programming exercises. With a free account you can save your"\ - " progress and track your programming fitness over time. Click the link below to make an account,"\ - " using the email unknown@mail.com. You will see your invitation in the dashboard, where you can "\ - "join the group. If you already have a CodeWOF account, then add unknown@mail.com to your profile "\ - "to make the invitation appear.\n\n{}\n\nThanks,\nThe Computer Science Education Research Group"\ - .format(expected_url) + sign_up_url = settings.CODEWOF_DOMAIN + reverse('account_signup') + expected = f"Hi,\n\nJohn Doe has invited you to join the Group 'Group North'. CodeWOF helps you maintain " \ + f"your programming fitness with short daily programming exercises. With a free account you can " \ + f"save your progress and track your programming fitness over time. Click the link below to make " \ + f"an account, using the email unknown@mail.com. You will see your invitation in the dashboard, " \ + f"where you can join the group. If you already have a CodeWOF account, then add unknown@mail.com" \ + f" to your profile to make the invitation appear.\n\nSign Up: {sign_up_url}\n\nThanks,\nThe " \ + f"CodeWOF team\n\n{settings.CODEWOF_DOMAIN}" self.assertEqual(len(outbox), 1) self.assertTrue(expected in outbox[0].body) diff --git a/codewof/tests/users/test_views.py b/codewof/tests/users/test_views.py index 81b2ed155..6d136bbb2 100644 --- a/codewof/tests/users/test_views.py +++ b/codewof/tests/users/test_views.py @@ -3,6 +3,7 @@ import pytest from django.core.exceptions import ObjectDoesNotExist +from django.db.models.functions import Lower from django.test import Client, TestCase from django.contrib.auth import get_user_model from django.core import management @@ -65,7 +66,7 @@ def setUp(self): self.membership4 = Membership.objects.get(user=self.john, group=self.group_south) self.invitation1 = Invitation.objects.get(email=self.john.email, group=self.group_team_300, inviter=self.sally) self.invitation2 = Invitation.objects.get(email="john@mail.com", group=self.group_mystery, inviter=self.sally) - self.invitation3 = Invitation.objects.get(email=self.john.email, group=self.group_team_cserg, + self.invitation3 = Invitation.objects.get(email="uppercase@mail.com", group=self.group_team_cserg, inviter=self.sally) def login_user(self): @@ -96,9 +97,7 @@ def test_context_object(self): resp = self.client.get('/users/dashboard/') self.assertEqual(resp.status_code, 200) - self.assertEqual(len(resp.context['questions_to_do']), 2) self.assertEqual(len(resp.context['all_achievements']), len(Achievement.objects.all())) - self.assertEqual(resp.context['all_complete'], False) self.assertEqual(resp.context['codewof_profile'], user.profile) self.assertEqual(resp.context['goal'], user.profile.goal) self.assertEqual(resp.context['num_questions_answered'], 1) @@ -116,21 +115,22 @@ def test_context_object_memberships(self): def test_context_object_invitations_in_correct_order(self): self.login_user() resp = self.client.get('/users/dashboard/') - john_emails = EmailAddress.objects.filter(user=self.john) - john_invitations = Invitation.objects.filter(email__in=john_emails.values('email')) + john_emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=self.john) + john_invitations = Invitation.objects.filter(email__in=john_emails.values('email_lower')) self.assertEqual(len(john_invitations), 3) self.assertEqual(len(resp.context['invitations']), 3) self.assertEqual(resp.context['invitations'][0], self.invitation2) self.assertEqual(resp.context['invitations'][1], self.invitation1) + # Invitation email is all lowercase while the matching email address has upper case letters, but still works self.assertEqual(resp.context['invitations'][2], self.invitation3) def test_context_object_invitations_is_missing_invalid_invitations(self): self.login_user() generate_invalid_invitations() resp = self.client.get('/users/dashboard/') - john_emails = EmailAddress.objects.filter(user=self.john) - john_invitations = Invitation.objects.filter(email__in=john_emails.values('email')) + john_emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=self.john) + john_invitations = Invitation.objects.filter(email__in=john_emails.values('email_lower')) self.assertEqual(len(john_invitations), 6) self.assertEqual(set(resp.context['invitations']), {self.invitation1, self.invitation2, self.invitation3}) @@ -346,6 +346,7 @@ class TestGroupCreateView(TestCase): def setUpTestData(cls): # never modify this object in tests generate_users(user) + generate_questions() management.call_command("load_group_roles") def setUp(self): @@ -718,6 +719,7 @@ class TestGroupDeleteView(TestCase): def setUpTestData(cls): # never modify this object in tests generate_users(user) + generate_questions() generate_groups() generate_memberships() management.call_command("load_group_roles") @@ -1082,6 +1084,7 @@ class TestMembershipDeleteView(TestCase): def setUpTestData(cls): # never modify this object in tests generate_users(user) + generate_questions() generate_groups() generate_memberships() management.call_command("load_group_roles") @@ -1266,6 +1269,20 @@ def test_new_emails_only(self): self.assertEqual(str(messages[0]), 'The following emails had invitations sent to them: user1@mail.com, ' 'user2@mail.com, user3@mail.com') + def test_case_is_converted(self): + self.login_user(self.john) + emails = ['User1@mail.com'] + resp = self.client.post(reverse('users:groups-memberships-invite', args=[self.group_north.pk]), + {'emails': '\n'.join(emails)}, follow=True) + messages = list(resp.context['messages']) + outbox = get_outbox_sorted() + + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].to[0], emails[0].lower()) + self.assertTrue(Invitation.objects.filter(email=emails[0].lower(), group=self.group_north).exists()) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), 'The following emails had invitations sent to them: user1@mail.com') + def test_existing_invitation(self): self.login_user(self.john) email = 'user1@mail.com' @@ -1281,6 +1298,21 @@ def test_existing_invitation(self): self.assertEqual(str(messages[0]), 'The following emails were skipped either because they have already been ' 'invited or are already a member of the group: user1@mail.com') + def test_existing_invitation_but_different_case(self): + self.login_user(self.john) + email = 'user1@mail.com' + Invitation(email=email, group=self.group_north, inviter=self.john).save() + resp = self.client.post(reverse('users:groups-memberships-invite', args=[self.group_north.pk]), + {'emails': email.capitalize()}, follow=True) + messages = list(resp.context['messages']) + outbox = get_outbox_sorted() + + self.assertEqual(len(outbox), 0) + self.assertEqual(len(Invitation.objects.filter(email=email, group=self.group_north)), 1) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), 'The following emails were skipped either because they have already been ' + 'invited or are already a member of the group: user1@mail.com') + def test_existing_membership(self): self.login_user(self.john) resp = self.client.post(reverse('users:groups-memberships-invite', args=[self.group_north.pk]), @@ -1314,6 +1346,25 @@ def test_existing_invitation_to_different_email(self): 'The following emails were skipped either because they have already been ' 'invited or are already a member of the group: john@mail.com') + def test_existing_invitation_to_different_email_but_different_case(self): + self.login_user(self.sally) + admin_role = GroupRole.objects.get(name="Admin") + Membership(user=self.sally, group=self.group_mystery, role=admin_role).save() + Invitation(email=self.john.email, group=self.group_mystery, inviter=self.sally).save() + resp = self.client.post(reverse('users:groups-memberships-invite', args=[self.group_mystery.pk]), + {'emails': "uppercase@mail.com"}, follow=True) + messages = list(resp.context['messages']) + outbox = get_outbox_sorted() + john_emails = EmailAddress.objects.filter(user=self.john) + john_invitations = Invitation.objects.filter(email__in=john_emails.values('email'), group=self.group_mystery) + + self.assertEqual(len(outbox), 0) + self.assertEqual(len(john_invitations), 1) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), + 'The following emails were skipped either because they have already been ' + 'invited or are already a member of the group: uppercase@mail.com') + def test_existing_invitation_to_different_email_in_same_request(self): self.login_user(self.sally) admin_role = GroupRole.objects.get(name="Admin") @@ -1381,7 +1432,10 @@ def setUp(self): self.group_mystery = Group.objects.get(name="Group Mystery") self.group_class_1 = Group.objects.get(name="Class 1") self.group_north = Group.objects.get(name="Group North") + self.group_team_cserg = Group.objects.get(name="Team CSERG") self.invitation = Invitation.objects.get(email="john@mail.com", group=self.group_mystery) + self.invitation_different_case = Invitation.objects.get(email="uppercase@mail.com", + group=self.group_team_cserg) self.invitation_already_member = Invitation.objects.get(email=self.john.email, group=self.group_north) self.invitation_unverified_email = Invitation.objects.get(email="jack@mail.com", group=self.group_class_1) self.client = Client() @@ -1410,6 +1464,11 @@ def test_can_accept_if_invitee(self): resp = self.client.post(reverse('users:groups-invitations-accept', args=[self.invitation.pk])) self.assertEqual(resp.status_code, 200) + def test_can_accept_if_invite_email_case_does_not_match_email_address_object_case(self): + self.login_user(self.john) + resp = self.client.post(reverse('users:groups-invitations-accept', args=[self.invitation_different_case.pk])) + self.assertEqual(resp.status_code, 200) + def test_accepting_deletes_the_invitation(self): self.login_user(self.john) self.client.post(reverse('users:groups-invitations-accept', args=[self.invitation.pk])) @@ -1451,7 +1510,10 @@ def setUp(self): self.sally = User.objects.get(pk=2) self.group_mystery = Group.objects.get(name="Group Mystery") self.group_class_1 = Group.objects.get(name="Class 1") + self.group_team_cserg = Group.objects.get(name="Team CSERG") self.invitation = Invitation.objects.get(email="john@mail.com", group=self.group_mystery) + self.invitation_different_case = Invitation.objects.get(email="uppercase@mail.com", + group=self.group_team_cserg) self.invitation_unverified_email = Invitation.objects.get(email="jack@mail.com", group=self.group_class_1) self.client = Client() @@ -1480,6 +1542,11 @@ def test_can_reject_if_invitee(self): resp = self.client.delete(reverse('users:groups-invitations-reject', args=[self.invitation.pk])) self.assertEqual(resp.status_code, 200) + def test_can_reject_if_invite_email_case_does_not_match_email_address_object_case(self): + self.login_user(self.john) + resp = self.client.delete(reverse('users:groups-invitations-reject', args=[self.invitation_different_case.pk])) + self.assertEqual(resp.status_code, 200) + def test_rejecting_deletes_the_invitation(self): self.login_user(self.john) self.client.delete(reverse('users:groups-invitations-reject', args=[self.invitation.pk])) diff --git a/codewof/users/admin.py b/codewof/users/admin.py index 1d995a0d0..0f718b7d3 100644 --- a/codewof/users/admin.py +++ b/codewof/users/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.contrib.auth import admin as auth_admin from django.contrib.auth import get_user_model -from users.models import UserType +from users.models import UserType, Group, Invitation, Membership, GroupRole from users.forms import UserAdminChangeForm, UserAdminCreationForm User = get_user_model() @@ -20,6 +20,14 @@ class UserAdmin(auth_admin.UserAdmin): 'last_name', 'user_type', 'is_superuser', + 'remind_on_monday', + 'remind_on_tuesday', + 'remind_on_wednesday', + 'remind_on_thursday', + 'remind_on_friday', + 'remind_on_saturday', + 'remind_on_sunday', + 'timezone', ] ordering = [ 'first_name', @@ -30,3 +38,7 @@ class UserAdmin(auth_admin.UserAdmin): admin.site.register(User, UserAdmin) admin.site.register(UserType) +admin.site.register(Group) +admin.site.register(Invitation) +admin.site.register(Membership) +admin.site.register(GroupRole) diff --git a/codewof/users/forms.py b/codewof/users/forms.py index e4a1f40e8..e37cd5157 100644 --- a/codewof/users/forms.py +++ b/codewof/users/forms.py @@ -1,15 +1,15 @@ """Forms for user application.""" +from captcha.fields import ReCaptchaField +from captcha.widgets import ReCaptchaV3 +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Submit, HTML, Fieldset, ButtonHolder, Button, Div from django import forms from django.contrib import auth +from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext as _ -from django.template.loader import render_to_string from users.models import UserType, Group -from captcha.fields import ReCaptchaField -from captcha.widgets import ReCaptchaV3 -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Submit, HTML, Fieldset, ButtonHolder, Button, Div User = auth.get_user_model() @@ -109,11 +109,13 @@ def __init__(self, *args, **kwargs): 'user_type', ), HTML("

Emails

"), - Button('emails', 'Manage your email addresses', css_class='btn btn-outline-primary', + Button('emails', 'Manage your email addresses', css_class='btn btn-primary', onclick="window.location.href = '{}';".format(reverse('account_email'))), Div( - HTML("

Send me notifications on:

"), + HTML("

Warning! Email reminders may end up in your " + "spam. Unmark the email as spam to prevent this.

"), + HTML("

Send me reminders on:

"), 'remind_on_monday', 'remind_on_tuesday', 'remind_on_wednesday', @@ -127,7 +129,7 @@ def __init__(self, *args, **kwargs): 'timezone', ), ButtonHolder( - Submit('submit', 'Update', css_class='btn btn-primary') + Submit('submit', 'Update', css_class='btn btn-success') ), ) @@ -147,7 +149,11 @@ class Meta(auth.forms.UserChangeForm.Meta): """Metadata for UserAdminChangeForm class.""" model = User - fields = ('email', 'last_name', 'user_type') + fields = ( + 'email', + 'last_name', + 'user_type', + ) class UserAdminCreationForm(auth.forms.UserCreationForm): @@ -157,7 +163,12 @@ class Meta(auth.forms.UserCreationForm.Meta): """Metadata for UserAdminCreationForm class.""" model = User - fields = ('email', 'first_name', 'last_name', 'user_type') + fields = ( + 'email', + 'first_name', + 'last_name', + 'user_type', + ) class GroupCreateUpdateForm(forms.ModelForm): @@ -189,7 +200,10 @@ class GroupCreateUpdateForm(forms.ModelForm): feed_enabled = forms.BooleanField( label='Enable Feed?', - required=False + required=False, + help_text="The feed displays the latest successful (all tests passed) question attempts of members of the " + "group. Each feed entry includes the user's name, the question they answered, " + "and the date and time of submission. Members can also like feed entries." ) class Meta: diff --git a/codewof/users/management/commands/remove_expired_invitations.py b/codewof/users/management/commands/remove_expired_invitations.py index f485aa0c8..b17111bcf 100644 --- a/codewof/users/management/commands/remove_expired_invitations.py +++ b/codewof/users/management/commands/remove_expired_invitations.py @@ -7,7 +7,11 @@ class Command(BaseCommand): - """Required command class for the custom Django removed_expired_invitations command.""" + """ + Required command class for the custom Django removed_expired_invitations command. + + The script should run at least once a day. + """ def handle(self, *args, **options): """Get Invitations that have expired and delete them.""" diff --git a/codewof/users/management/commands/send_email_reminders.py b/codewof/users/management/commands/send_email_reminders.py index 9b0d45b86..b5752c13a 100644 --- a/codewof/users/management/commands/send_email_reminders.py +++ b/codewof/users/management/commands/send_email_reminders.py @@ -16,7 +16,11 @@ class Command(BaseCommand): - """Required command class for the custom Django send_email_reminders command.""" + """ + Required command class for the custom Django send_email_reminders command. + + The script should run once every hour, preferably near the beginning of the hour. + """ def handle(self, *args, **options): """ @@ -82,7 +86,7 @@ def build_email_html(self, username, message): return email_template.render( {"username": username, "message": message, "dashboard_url": settings.CODEWOF_DOMAIN + reverse('users:dashboard'), - "settings_url": settings.CODEWOF_DOMAIN + reverse('users:update')}) + "settings_url": settings.CODEWOF_DOMAIN + reverse('users:update'), "DOMAIN": settings.CODEWOF_DOMAIN}) def build_email_plain(self, username, message): """ @@ -92,11 +96,10 @@ def build_email_plain(self, username, message): :param message: The string message to insert in the template. :return: The string message. """ - return "Hi {},\n\n{}\nLet's practice!: {}\n\nThanks,\nThe Computer Science Education Research " \ - "Group\n\nYou received this email because you opted into reminders. You can change " \ - "your reminder settings here: {}."\ + return "Hi {},\n\n{}\n\nLet's practice!: {}\n\nThanks,\nThe CodeWOF team\n\nYou received this email because " \ + "you opted into reminders. You can change your reminder settings here: {}.\n\n{}" \ .format(username, message, settings.CODEWOF_DOMAIN + reverse('users:dashboard'), - settings.CODEWOF_DOMAIN + reverse('users:update')) + settings.CODEWOF_DOMAIN + reverse('users:update'), settings.CODEWOF_DOMAIN) def get_users_to_email(self): """ diff --git a/codewof/users/migrations/0006_emailreminder.py b/codewof/users/migrations/0006_emailreminder.py new file mode 100644 index 000000000..8c45b615e --- /dev/null +++ b/codewof/users/migrations/0006_emailreminder.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.6 on 2021-09-13 04:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_alter_invitation_date_expires'), + ] + + operations = [ + migrations.CreateModel( + name='EmailReminder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime', models.DateTimeField(default=django.utils.timezone.now)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/codewof/users/migrations/0007_alter_user_timezone.py b/codewof/users/migrations/0007_alter_user_timezone.py new file mode 100644 index 000000000..830af6457 --- /dev/null +++ b/codewof/users/migrations/0007_alter_user_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-04-15 03:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_emailreminder'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='timezone', + field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar es Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao Tome'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Cambridge_Bay', 'America/Cambridge Bay'), ('America/Campo_Grande', 'America/Campo Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Costa_Rica', 'America/Costa Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El Salvador'), ('America/Fort_Nelson', 'America/Fort Nelson'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace Bay'), ('America/Goose_Bay', 'America/Goose Bay'), ('America/Grand_Turk', 'America/Grand Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los Angeles'), ('America/Lower_Princes', 'America/Lower Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North Dakota/New Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port of Spain'), ('America/Porto_Velho', 'America/Porto Velho'), ('America/Puerto_Rico', 'America/Puerto Rico'), ('America/Punta_Arenas', 'America/Punta Arenas'), ('America/Rainy_River', 'America/Rainy River'), ('America/Rankin_Inlet', 'America/Rankin Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio Branco'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo Domingo'), ('America/Sao_Paulo', 'America/Sao Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St Barthelemy'), ('America/St_Johns', 'America/St Johns'), ('America/St_Kitts', 'America/St Kitts'), ('America/St_Lucia', 'America/St Lucia'), ('America/St_Thomas', 'America/St Thomas'), ('America/St_Vincent', 'America/St Vincent'), ('America/Swift_Current', 'America/Swift Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho Chi Minh'), ('Asia/Hong_Kong', 'Asia/Hong Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape Verde'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South Georgia'), ('Atlantic/St_Helena', 'Atlantic/St Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken Hill'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Sydney', 'Australia/Sydney'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle of Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('GMT', 'GMT'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Port_Moresby', 'Pacific/Port Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('US/Alaska', 'US/Alaska'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('UTC', 'UTC')], default='Pacific/Auckland', max_length=32), + ), + ] diff --git a/codewof/users/migrations/0008_auto_20220927_1543.py b/codewof/users/migrations/0008_auto_20220927_1543.py new file mode 100644 index 000000000..d983690a2 --- /dev/null +++ b/codewof/users/migrations/0008_auto_20220927_1543.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.15 on 2022-09-27 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_alter_user_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='timezone', + field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar es Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao Tome'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Cambridge_Bay', 'America/Cambridge Bay'), ('America/Campo_Grande', 'America/Campo Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Costa_Rica', 'America/Costa Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El Salvador'), ('America/Fort_Nelson', 'America/Fort Nelson'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace Bay'), ('America/Goose_Bay', 'America/Goose Bay'), ('America/Grand_Turk', 'America/Grand Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los Angeles'), ('America/Lower_Princes', 'America/Lower Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North Dakota/New Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port of Spain'), ('America/Porto_Velho', 'America/Porto Velho'), ('America/Puerto_Rico', 'America/Puerto Rico'), ('America/Punta_Arenas', 'America/Punta Arenas'), ('America/Rainy_River', 'America/Rainy River'), ('America/Rankin_Inlet', 'America/Rankin Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio Branco'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo Domingo'), ('America/Sao_Paulo', 'America/Sao Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St Barthelemy'), ('America/St_Johns', 'America/St Johns'), ('America/St_Kitts', 'America/St Kitts'), ('America/St_Lucia', 'America/St Lucia'), ('America/St_Thomas', 'America/St Thomas'), ('America/St_Vincent', 'America/St Vincent'), ('America/Swift_Current', 'America/Swift Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho Chi Minh'), ('Asia/Hong_Kong', 'Asia/Hong Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape Verde'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South Georgia'), ('Atlantic/St_Helena', 'Atlantic/St Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken Hill'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Sydney', 'Australia/Sydney'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle of Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('GMT', 'GMT'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Port_Moresby', 'Pacific/Port Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('US/Alaska', 'US/Alaska'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('UTC', 'UTC')], default='Pacific/Auckland', max_length=32), + ), + migrations.DeleteModel( + name='EmailReminder', + ), + ] diff --git a/codewof/users/migrations/0009_alter_user_timezone.py b/codewof/users/migrations/0009_alter_user_timezone.py new file mode 100644 index 000000000..f6da3e220 --- /dev/null +++ b/codewof/users/migrations/0009_alter_user_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2022-09-27 02:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_auto_20220927_1543'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='timezone', + field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar es Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao Tome'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Cambridge_Bay', 'America/Cambridge Bay'), ('America/Campo_Grande', 'America/Campo Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Costa_Rica', 'America/Costa Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El Salvador'), ('America/Fort_Nelson', 'America/Fort Nelson'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace Bay'), ('America/Goose_Bay', 'America/Goose Bay'), ('America/Grand_Turk', 'America/Grand Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los Angeles'), ('America/Lower_Princes', 'America/Lower Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North Dakota/New Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port of Spain'), ('America/Porto_Velho', 'America/Porto Velho'), ('America/Puerto_Rico', 'America/Puerto Rico'), ('America/Punta_Arenas', 'America/Punta Arenas'), ('America/Rainy_River', 'America/Rainy River'), ('America/Rankin_Inlet', 'America/Rankin Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio Branco'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo Domingo'), ('America/Sao_Paulo', 'America/Sao Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St Barthelemy'), ('America/St_Johns', 'America/St Johns'), ('America/St_Kitts', 'America/St Kitts'), ('America/St_Lucia', 'America/St Lucia'), ('America/St_Thomas', 'America/St Thomas'), ('America/St_Vincent', 'America/St Vincent'), ('America/Swift_Current', 'America/Swift Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho Chi Minh'), ('Asia/Hong_Kong', 'Asia/Hong Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape Verde'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South Georgia'), ('Atlantic/St_Helena', 'Atlantic/St Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken Hill'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Sydney', 'Australia/Sydney'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle of Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('GMT', 'GMT'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Port_Moresby', 'Pacific/Port Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('US/Alaska', 'US/Alaska'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('UTC', 'UTC')], default='Pacific/Auckland', max_length=100), + ), + ] diff --git a/codewof/users/models.py b/codewof/users/models.py index 071e4bc39..76394954e 100644 --- a/codewof/users/models.py +++ b/codewof/users/models.py @@ -61,7 +61,7 @@ class User(AbstractUser): remind_on_sunday = models.BooleanField(default=False) # Determine when to send the email reminder - timezone = models.CharField(max_length=32, choices=TIMEZONES, default='Pacific/Auckland') + timezone = models.CharField(max_length=100, choices=TIMEZONES, default='Pacific/Auckland') REMINDER_DAYS = [remind_on_monday, remind_on_tuesday, remind_on_wednesday, remind_on_thursday, remind_on_friday, remind_on_saturday, remind_on_sunday] diff --git a/codewof/users/serializers.py b/codewof/users/serializers.py index 2e02a644f..53c479920 100644 --- a/codewof/users/serializers.py +++ b/codewof/users/serializers.py @@ -1,7 +1,7 @@ """Serializers for user models.""" from rest_framework import serializers -from users.models import User, UserType +from users.models import User, UserType, Group, Membership, GroupRole, Invitation class UserSerializer(serializers.ModelSerializer): @@ -17,6 +17,14 @@ class Meta: 'first_name', 'last_name', 'user_type', + 'remind_on_monday', + 'remind_on_tuesday', + 'remind_on_wednesday', + 'remind_on_thursday', + 'remind_on_friday', + 'remind_on_saturday', + 'remind_on_sunday', + 'timezone', ) @@ -31,3 +39,73 @@ class Meta: 'pk', 'name', ) + + +class GroupSerializer(serializers.ModelSerializer): + """Serializer for codeWOF groups.""" + + class Meta: + """Meta settings for serializer.""" + + model = Group + fields = ( + 'pk', + 'name', + 'description', + 'date_created', + 'feed_enabled', + 'users' + ) + + +class GroupRoleSerializer(serializers.ModelSerializer): + """Serializer for codeWOF group roles.""" + + class Meta: + """Meta settings for serializer.""" + + model = GroupRole + fields = ( + 'pk', + 'name' + ) + + +class MembershipSerializer(serializers.ModelSerializer): + """Serializer for codeWOF group memberships.""" + + user = serializers.ReadOnlyField(source='user.pk') + group = serializers.ReadOnlyField(source='group.pk') + role = serializers.StringRelatedField() + + class Meta: + """Meta settings for serializer.""" + + model = Membership + fields = ( + 'pk', + 'user', + 'group', + 'role', + 'date_joined' + ) + + +class InvitationSerializer(serializers.ModelSerializer): + """Serializer for codeWOF group invitations.""" + + group = serializers.ReadOnlyField(source='user.pk') + inviter = serializers.ReadOnlyField(source='inviter.pk') + + class Meta: + """Meta settings for serializer.""" + + model = Invitation + fields = ( + 'pk', + 'group', + 'inviter', + 'email', + 'date_sent', + 'date_expires' + ) diff --git a/codewof/users/urls.py b/codewof/users/urls.py index 359cd174e..577ab505c 100644 --- a/codewof/users/urls.py +++ b/codewof/users/urls.py @@ -1,14 +1,20 @@ """URL routing for users application.""" from django.urls import path +from django.conf import settings from rest_framework import routers from . import views app_name = "users" router = routers.SimpleRouter() -router.register(r'users/users', views.UserAPIViewSet) -router.register(r'users/user-types', views.UserTypeAPIViewSet) +if not settings.PRODUCTION_ENVIRONMENT: + router.register(r'users/users', views.UserAPIViewSet) + router.register(r'users/user-types', views.UserTypeAPIViewSet) + router.register(r'users/groups', views.GroupAPIViewSet) + router.register(r'users/memberships', views.MembershipAPIViewSet) + router.register(r'users/group-roles', views.GroupRoleAPIViewSet) + router.register(r'users/invitations', views.InvitationAPIViewSet) urlpatterns = [ diff --git a/codewof/users/utils.py b/codewof/users/utils.py index 8cee09159..4bb6f490d 100644 --- a/codewof/users/utils.py +++ b/codewof/users/utils.py @@ -50,8 +50,9 @@ def create_invitation_plaintext(user_exists, invitee_name, inviter_name, group_n if user_exists: url = settings.CODEWOF_DOMAIN + reverse('users:dashboard') plaintext = "Hi {},\n\n{} has invited you to join the Group '{}'. Click the link below to sign in. You will "\ - "see your invitation in the dashboard, where you can join the group.\n\n{}\n\nThanks,\nThe "\ - "Computer Science Education Research Group".format(invitee_name, inviter_name, group_name, url) + "see your invitation in the dashboard, where you can join the group.\n\nSign In: {}\n\nThanks,\n" \ + "The CodeWOF team\n\n{}"\ + .format(invitee_name, inviter_name, group_name, url, settings.CODEWOF_DOMAIN) else: url = settings.CODEWOF_DOMAIN + reverse('account_signup') plaintext = "Hi,\n\n{} has invited you to join the Group '{}'. CodeWOF helps you maintain your programming "\ @@ -59,8 +60,8 @@ def create_invitation_plaintext(user_exists, invitee_name, inviter_name, group_n "and track your programming fitness over time. Click the link below to make an account, using "\ "the email {}. You will see your invitation in the dashboard, where you can join the group. "\ "If you already have a CodeWOF account, then add {} to your profile to make the invitation "\ - "appear.\n\n{}\n\nThanks,\nThe Computer Science Education Research Group"\ - .format(inviter_name, group_name, email, email, url) + "appear.\n\nSign Up: {}\n\nThanks,\nThe CodeWOF team\n\n{}"\ + .format(inviter_name, group_name, email, email, url, settings.CODEWOF_DOMAIN) return plaintext @@ -81,8 +82,7 @@ def create_invitation_html(user_exists, invitee_name, inviter_name, group_name, "see your invitation in the dashboard, where you can join the group."\ .format(inviter_name, group_name) url = settings.CODEWOF_DOMAIN + reverse('users:dashboard') - html = email_template.render({"user_exists": user_exists, "invitee_name": invitee_name, "message": message, - "url": url, "button_text": "Sign In"}) + button_text = "Sign In" else: message = "{} has invited you to join the Group '{}'. CodeWOF helps you maintain your "\ "programming fitness with short daily programming exercises. With a free account you can save your "\ @@ -91,6 +91,9 @@ def create_invitation_html(user_exists, invitee_name, inviter_name, group_name, "If you already have a CodeWOF account, then add {} to your profile to make the invitation appear."\ .format(inviter_name, group_name, email, email) url = settings.CODEWOF_DOMAIN + reverse('account_signup') - html = email_template.render({"user_exists": user_exists, "invitee_name": invitee_name, "message": message, - "url": url, "button_text": "Sign Up"}) + button_text = "Sign Up" + + # Retrieve logo source here instead of template to avoid the domain being set to the one for MailHog + html = email_template.render({"user_exists": user_exists, "invitee_name": invitee_name, "message": message, + "url": url, "button_text": button_text, "DOMAIN": settings.CODEWOF_DOMAIN}) return html diff --git a/codewof/users/views.py b/codewof/users/views.py index 92b285378..3e685445d 100644 --- a/codewof/users/views.py +++ b/codewof/users/views.py @@ -1,35 +1,35 @@ """Views for users application.""" import json import logging -from random import Random from django.db import transaction from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render -from django.utils import timezone from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.decorators import login_required from django.contrib import messages from django.urls import reverse -from django.db.models import Q from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.views.generic import DetailView, RedirectView, UpdateView, CreateView, DeleteView from django.views.decorators.http import require_http_methods from django.http import JsonResponse +from django.db.models.functions import Lower from rest_framework import viewsets from rest_framework.permissions import IsAdminUser from users.serializers import ( UserSerializer, UserTypeSerializer, + GroupSerializer, + MembershipSerializer, + GroupRoleSerializer, + InvitationSerializer, ) -from programming import settings as programming_settings from users.forms import UserChangeForm, GroupCreateUpdateForm, GroupInvitationsForm from functools import wraps from allauth.account.admin import EmailAddress from programming.models import ( - Question, Attempt, Achievement ) @@ -41,6 +41,7 @@ UserType, ) from programming.codewof_utils import get_questions_answered_in_past_month, backdate_user +from programming.question_recommendations import get_recommended_questions, get_recommendation_descriptions from users.mixins import AdminRequiredMixin, AdminOrMemberRequiredMixin, SufficientAdminsMixin, \ RequestUserIsMembershipUserMixin from users.utils import send_invitation_email @@ -77,49 +78,21 @@ def get_object(self): def get_context_data(self, **kwargs): """Get additional context data for template.""" user = self.request.user + context = super().get_context_data(**kwargs) if not user.profile.has_backdated: backdate_user(user.profile) - context = super().get_context_data(**kwargs) - now = timezone.now() - today = now.date() - - # Get questions not attempted before today - questions = Question.objects.all() - - # TODO: Also filter by questions added before today - questions = questions.filter( - Q(attempt__isnull=True) - | (Q(attempt__passed_tests=False) & Q(attempt__datetime__date__lte=today)) - | (Q(attempt__passed_tests=True) & Q(attempt__datetime__date=today)) - ).order_by('pk').distinct('pk').select_subclasses() - questions = list(questions) - - # Randomly pick 3 based off seed of todays date - if len(questions) > 0: - random_seeded = Random('{}{}'.format(user.pk, today)) - number_to_do = min(len(questions), programming_settings.QUESTIONS_PER_DAY) - todays_questions = random_seeded.sample(questions, number_to_do) - all_complete = True - for question in todays_questions: - question.completed = Attempt.objects.filter( - profile=user.profile, - question=question, - passed_tests=True, - ).exists() - if all_complete and not question.completed: - all_complete = False - else: - todays_questions = list() - all_complete = False - - context['questions_to_do'] = todays_questions - context['all_complete'] = all_complete memberships = user.membership_set.all().order_by('group__name') groups = memberships.values('group').distinct() - emails = EmailAddress.objects.filter(user=user, verified=True) - invitations = Invitation.objects.filter(email__in=emails.values('email')).exclude(group__in=groups)\ + emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=user, verified=True) + invitations = Invitation.objects.filter(email__in=emails.values('email_lower')).exclude(group__in=groups)\ .order_by('group__pk', '-date_sent').distinct('group__pk') + recommendation_descriptions = get_recommendation_descriptions() + recommended_questions = get_recommended_questions(user.profile) + if len(recommendation_descriptions) == len(recommended_questions): + context['recommendations'] = [(description, question) for description, question in zip( + recommendation_descriptions, recommended_questions + )] context['memberships'] = memberships context['invitations'] = invitations context['codewof_profile'] = self.object.profile @@ -361,20 +334,22 @@ def create_invitations(request, pk, group): skipped = [] for email in emails: - email = email.strip() + email = email.lower().strip() if len(Invitation.objects.filter(email=email, group=group)) > 0: skipped.append(email) continue try: - user = EmailAddress.objects.get(email=email).user - invitee_emails = EmailAddress.objects.filter(user=user) + user = EmailAddress.objects.annotate(email_lower=Lower('email')).get(email_lower=email).user + invitee_emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=user) except EmailAddress.DoesNotExist: user = None + # If user with that email exists and they are already a member or an invitation has been sent to one of + # their other emails. if user is not None and (len(Membership.objects.filter(user=user, group=group)) > 0 - or len(Invitation.objects.filter(email__in=invitee_emails.values('email'), - group=group)) > 0): + or len(Invitation.objects.filter( + email__in=invitee_emails.values('email_lower'), group=group)) > 0): skipped.append(email) continue @@ -418,8 +393,8 @@ def invitee_required(f): @wraps(f) def g(request, *args, **kwargs): - emails = EmailAddress.objects.filter(user=request.user, verified=True) - if Invitation.objects.filter(pk=kwargs['pk'], email__in=emails.values('email')): + emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=request.user, verified=True) + if Invitation.objects.filter(pk=kwargs['pk'], email__in=emails.values('email_lower')): return f(request, *args, **kwargs) else: raise PermissionDenied() @@ -431,14 +406,18 @@ def g(request, *args, **kwargs): @login_required() @invitee_required def accept_invitation(request, pk): - """View for accepting an invitation and creating a membership.""" + """ + View for accepting an invitation and creating a membership. + + Removes any duplicate invitations as well. + """ invitation = Invitation.objects.get(pk=pk) membership_role = GroupRole.objects.get(name="Member") if not Membership.objects.filter(user=request.user, group=invitation.group).exists(): Membership(user=request.user, group=invitation.group, role=membership_role).save() - emails = EmailAddress.objects.filter(user=request.user) - Invitation.objects.filter(email__in=emails.values('email'), group=invitation.group).delete() + emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=request.user) + Invitation.objects.filter(email__in=emails.values('email_lower'), group=invitation.group).delete() return HttpResponse() @@ -447,10 +426,14 @@ def accept_invitation(request, pk): @login_required() @invitee_required def reject_invitation(request, pk): - """View for rejecting an invitation.""" + """ + View for rejecting an invitation. + + Removes any duplicate invitations as well. + """ invitation = Invitation.objects.get(pk=pk) - emails = EmailAddress.objects.filter(user=request.user) - Invitation.objects.filter(email__in=emails.values('email'), group=invitation.group).delete() + emails = EmailAddress.objects.annotate(email_lower=Lower('email')).filter(user=request.user) + Invitation.objects.filter(email__in=emails.values('email_lower'), group=invitation.group).delete() return HttpResponse() @@ -462,3 +445,35 @@ def get_group_emails(request, pk, group): """View for obtaining the email addresses of the members of the group.""" emails_list = list(group.users.values_list('email', flat=True)) return JsonResponse({"emails": emails_list}) + + +class GroupAPIViewSet(viewsets.ReadOnlyModelViewSet): + """API endpoint that allows groups to be viewed.""" + + permission_classes = [IsAdminUser] + queryset = Group.objects.all() + serializer_class = GroupSerializer + + +class MembershipAPIViewSet(viewsets.ReadOnlyModelViewSet): + """API endpoint that allows group memberships to be viewed.""" + + permission_classes = [IsAdminUser] + queryset = Membership.objects.all().select_related('user', 'group', 'role') + serializer_class = MembershipSerializer + + +class GroupRoleAPIViewSet(viewsets.ReadOnlyModelViewSet): + """API endpoint that allows group roles to be viewed.""" + + permission_classes = [IsAdminUser] + queryset = GroupRole.objects.all() + serializer_class = GroupRoleSerializer + + +class InvitationAPIViewSet(viewsets.ReadOnlyModelViewSet): + """API endpoint that allows group invitations to be viewed.""" + + permission_classes = [IsAdminUser] + queryset = Invitation.objects.all().select_related('group', 'inviter') + serializer_class = InvitationSerializer diff --git a/codewof/utils/LoaderFactory.py b/codewof/utils/LoaderFactory.py index ca5a620e5..cd79a00da 100644 --- a/codewof/utils/LoaderFactory.py +++ b/codewof/utils/LoaderFactory.py @@ -1,11 +1,26 @@ """Factory for creating loader objects.""" from programming.management.commands._QuestionsLoader import QuestionsLoader +from programming.management.commands._DifficultiesLoader import DifficultiesLoader +from programming.management.commands._ProgrammingConceptsLoader import ProgrammingConceptsLoader +from programming.management.commands._QuestionContextsLoader import QuestionContextsLoader class LoaderFactory: """Factory for creating loader objects.""" + def difficulty_levels_loader(self, **kwargs): + """Difficulty levels loader.""" + return DifficultiesLoader(**kwargs) + + def programming_concepts_loader(self, **kwargs): + """Programming concepts loader.""" + return ProgrammingConceptsLoader(**kwargs) + + def question_contexts_loader(self, **kwargs): + """Question contexts loader.""" + return QuestionContextsLoader(**kwargs) + def create_questions_loader(self, **kwargs): """Create questions loader.""" return QuestionsLoader(**kwargs) diff --git a/dev b/dev index 4ed368d4a..a4fbee3bb 100755 --- a/dev +++ b/dev @@ -41,7 +41,7 @@ defhelp help 'View all help.' # Start development environment cmd_start() { echo "Creating systems..." - docker-compose -f docker-compose.local.yml up -d + docker compose -f docker-compose.local.yml up -d # Alert user that system is ready echo -e "\n${SUCCESS}System is up!${NC}" echo -e "Run the command ${CODE}./dev update${NC} to load content." @@ -51,12 +51,12 @@ defhelp start 'Start development environment.' # Stop development environment cmd_end() { echo "Stopping systems..." - docker-compose -f docker-compose.local.yml down + docker compose -f docker-compose.local.yml down --remove-orphans } defhelp end 'Stop development environment.' cmd_restart() { - docker-compose -f docker-compose.local.yml restart "$@" + docker compose -f docker-compose.local.yml restart "$@" } defhelp restart 'Restart container.' @@ -82,7 +82,7 @@ defhelp update 'Update system ready for use.' # Run in exising container to use existing volumes cmd_makemigrations() { echo "Creating database migrations..." - docker-compose -f docker-compose.local.yml exec django python ./manage.py makemigrations --no-input + docker compose -f docker-compose.local.yml exec django python ./manage.py makemigrations --no-input } defhelp makemigrations 'Run Django makemigrations command.' @@ -90,7 +90,7 @@ defhelp makemigrations 'Run Django makemigrations command.' # Run in exising container to access new migration files cmd_migrate() { echo "Applying database migrations..." - docker-compose -f docker-compose.local.yml exec django python ./manage.py migrate + docker compose -f docker-compose.local.yml exec django python ./manage.py migrate } defhelp migrate 'Run Django migrate command.' @@ -98,61 +98,61 @@ defhelp migrate 'Run Django migrate command.' # Run update_data command cmd_update_data() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py update_data + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py update_data } defhelp update_data "Update all required data." # Run load_user_types command cmd_load_user_types() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_user_types + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_user_types } defhelp load_user_types "Load user types." # Run load_questions command cmd_load_questions() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_questions + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_questions } defhelp load_questions "Load questions." # Run load_style_errors command cmd_load_style_errors() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_style_errors + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_style_errors } defhelp load_style_errors "Load style errors." # Run load_achievements command cmd_load_achievements() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_achievements + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py load_achievements } defhelp load_achievements "Load achievements." cmd_createsuperuser() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py createsuperuser + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py createsuperuser } defhelp createsuperuser "Create superuser in Django system." cmd_sample_data() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py sample_data $1 + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py sample_data $1 } defhelp sample_data "Add sample data to website." cmd_send_email_reminders() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py send_email_reminders + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py send_email_reminders } defhelp send_email_reminders "Send an email reminder to all users who opted to receive one today." cmd_raise_backdate_flags() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py raise_backdate_flags + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py raise_backdate_flags } defhelp raise_backdate_flags 'Raise a flag on all user profiles, requiring a backdate be done on them.' cmd_remove_expired_invitations() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py remove_expired_invitations + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py remove_expired_invitations } defhelp remove_expired_invitations "Remove all Invitation objects where date_expires is before the current datetime." cmd_backdate() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py backdate_points_and_achievements --ignore_flags + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py backdate_points_and_achievements --ignore_flags } defhelp backdate 'Re-calculate points and achievements earned for all user profiles.' @@ -161,7 +161,7 @@ defhelp backdate 'Re-calculate points and achievements earned for all user profi # Build static files cmd_static() { echo "Building static files..." - docker-compose -f docker-compose.local.yml run --rm node npm run dev + docker compose -f docker-compose.local.yml run --rm node npm run generate-assets } defhelp static 'Build static files.' @@ -169,7 +169,7 @@ defhelp static 'Build static files.' cmd_collect_static() { echo echo "Collecting static files..." - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python manage.py collectstatic --no-input --clear + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python manage.py collectstatic --no-input --clear } defhelp collect_static "Collecting static files." @@ -186,31 +186,31 @@ defhelp update_static 'Update static files.' # Build Docker images cmd_build() { echo "Building Docker images..." - docker-compose -f docker-compose.local.yml build + docker compose -f docker-compose.local.yml build } defhelp build 'Build or rebuild Docker images.' # Run exec cmd_exec() { - docker-compose -f docker-compose.local.yml exec "$@" + docker compose -f docker-compose.local.yml exec "$@" } defhelp exec "Execute command in given container." # View Docker logs cmd_logs() { echo "Building Docker images..." - docker-compose -f docker-compose.local.yml logs --timestamps "$@" + docker compose -f docker-compose.local.yml logs --timestamps "$@" } defhelp logs 'View logs.' # Run style checks cmd_style() { echo "Running PEP8 style checker..." - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django flake8 + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django flake8 pep8_status=$? echo echo "Running Python docstring checker..." - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django pydocstyle --count --explain + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django pydocstyle --count --explain pydocstyle_status=$? ! (( pep8_status || pydocstyle_status )) } @@ -219,27 +219,27 @@ defhelp style 'Run style checks.' # Run test suite cmd_test_suite() { echo "Running test suite..." - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage run --rcfile=./.coveragerc ./manage.py test --settings=config.settings.testing --pattern "test_*.py" -v 3 --nomigrations + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage run --rcfile=./.coveragerc ./manage.py test --settings=config.settings.testing --pattern "test_*.py" -v 3 --nomigrations } defhelp test_suite 'Run test suite with code coverage.' # Run specific test suite cmd_test_specific() { echo "Running specific test suite..." - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py test --settings=config.settings.testing "${1}" -v 3 --nomigrations + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py test --settings=config.settings.testing "${1}" -v 3 --nomigrations } defhelp test_specific 'Run specific test suite. Pass in parameter of Python test module.' # Display test coverage table cmd_test_coverage() { echo "Displaying test suite coverage..." - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage xml -i - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage report -m --skip-covered + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage xml -i + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage report -m --skip-covered } defhelp test_coverage 'Display code coverage report.' cmd_createsuperuser() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py createsuperuser + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django python ./manage.py createsuperuser } defhelp createsuperuser "Create superuser in Django system." @@ -247,7 +247,7 @@ defhelp createsuperuser "Create superuser in Django system." # For use in GitHub Actions environment cmd_ci() { - docker network create uccser-development-proxy + docker network create uccser-development-stack cmd_start local cmd="$1" shift @@ -267,7 +267,7 @@ cmd_ci() { } cmd_test_general() { - docker-compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage run --rcfile=./.coveragerc ./manage.py test --settings=config.settings.testing --pattern "test_*.py" -v 3 --exclude-tag=resource --exclude-tag=management --nomigrations + docker compose -f docker-compose.local.yml run --rm --label traefik.enable=false django coverage run --rcfile=./.coveragerc ./manage.py test --settings=config.settings.testing --pattern "test_*.py" -v 3 --exclude-tag=resource --exclude-tag=management --nomigrations } # --- Core script logic ------------------------------------------------------- diff --git a/dev.old b/dev.old deleted file mode 100755 index 9353ad765..000000000 --- a/dev.old +++ /dev/null @@ -1,370 +0,0 @@ -#!/bin/bash -# Helper script for commands related to this repository. -# -# Notes: -# - Changes to template only require user to refresh browser. -# - Changes to static files require the 'static' command to be run. -# - Changes to Python code are detected by gunicorn and should take effect -# on the server after a few seconds. - -set -e - -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' # No Color - -cmd_helps=() -dev_cmd_helps=() - -defhelp() { - local command="${1?}" - local text="${2?}" - local help_str - help_str="$(printf ' %-28s %s' "$command" "$text")" - cmd_helps+=("$help_str") -} - -# Print out help information -cmd_help() { - echo "Script for performing tasks related to the codeWOF repository." - echo - echo "Usage: ./dev [COMMAND]" - echo "Replace [COMMAND] with a word from the list below." - echo - echo "COMMAND list:" - for str in "${cmd_helps[@]}"; do - echo -e "$str" - done -} - -defhelp help 'View all help.' -defhelp 'dev [DEV_COMMAND]' 'Run a developer command.' - -# Start development environment -cmd_start() { - echo "Creating systems..." - docker-compose up -d - echo "" - # Alert user that system is ready - echo -e "\n${GREEN}System is up!${NC}" - echo "Run the command ./dev update to load content." -} - -defhelp start 'Start development environment.' - -# Stop development environment -cmd_end() { - echo "Stopping systems... (takes roughly 10 to 20 seconds)" - docker-compose down - echo - echo "Deleting system volumes..." - volumes=($(docker volume ls -qf dangling=true )) - for volume in "${volumes[@]}"; do - docker volume rm "${volume}" - done -} -defhelp end 'Stop development environment.' - -cmd_restart() { - cmd_end - cmd_start -} -defhelp restart 'Stop and then restart development environment.' - -# Run Django migrate and updatedata commands -cmd_update() { - cmd_static - - echo "" - cmd_migrate - - echo "" - cmd_load_questions - cmd_load_style_errors - - echo "" - cmd_collect_static - echo "" - echo -e "\n${GREEN}Content is loaded!${NC}" - echo "Open your preferred web browser to the URL 'localhost:83'" -} -defhelp update 'Update system ready for use.' - -# Collecting static files -cmd_collect_static() { - echo - echo "Collecting static files..." - docker-compose exec django /docker_venv/bin/python3 ./manage.py collectstatic --no-input --clear -} -defhelp collect_static "Collecting static files." - -# Run Django flush command -cmd_flush() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py flush -} -defhelp flush 'Run Django flush command.' - -# Run Django makemigrations command -cmd_makemigrations() { - echo "Creating database migrations..." - docker-compose exec django /docker_venv/bin/python3 ./manage.py makemigrations -} -defhelp makemigrations 'Run Django makemigrations command.' - -# Run Django migrate command -cmd_migrate() { - echo "Applying database migrations..." - docker-compose exec django /docker_venv/bin/python3 ./manage.py migrate -} -defhelp migrate 'Run Django migrate command.' - -# Build Docker images -cmd_build() { - echo "Building Docker images..." - docker-compose build - echo - echo "Deleting untagged images..." - untagged_images=($(docker images --no-trunc | grep '' | awk '{print $3}')) - if [[ ${#untagged_images[@]} -gt 0 ]]; then - docker rmi "${untagged_images[@]}" - fi -} -defhelp build 'Build or rebuild Docker images.' - -# Build static files -cmd_static() { - echo "Building static files..." - docker-compose exec nginx gulp build -} -defhelp static 'Build static files.' - -# Build production static files -cmd_static_prod() { - echo "Building production static files..." - docker-compose exec nginx gulp build --production -} -defhelp static_prod 'Build production static files.' - -# Update and collect static files -cmd_update_static() { - cmd_static - - echo "" - cmd_collect_static - echo "" - echo -e "\n${GREEN}Static files are updated!${NC}" -} -defhelp update_static 'Update and collect static files.' - -# Run shell -cmd_shell() { - docker-compose exec django bash -} -defhelp shell "Open shell to Django folder." - -# Run shell -cmd_django_shell() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py shell -} -defhelp django_shell "Open Django shell." - -# Run load_questions command -cmd_load_questions() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py load_questions -} -defhelp load_questions "Load questions." - -# Run load_style_errors command -cmd_load_style_errors() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py load_style_errors -} -defhelp load_style_errors "Load style errors." - -# Run load_achievements command -cmd_load_achievements() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py load_achievements -} -defhelp load_achievements "Load achievements." - -cmd_createsuperuser() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py createsuperuser -} -defhelp createsuperuser "Create superuser in Django system." - -cmd_sampledata() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py sampledata $1 -} -defhelp sampledata "Add sample data to website." - -cmd_raise_backdate_flags() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py raise_backdate_flags -} -defhelp raise_backdate_flags 'Raise a flag on all user profiles, requiring a backdate be done on them.' - -cmd_backdate() { - docker-compose exec django /docker_venv/bin/python3 ./manage.py backdate_points_and_achievements --ignore_flags -} -defhelp backdate 'Re-calculate points and achievements earned for all user profiles.' - -# Reboot Django Docker container -cmd_reboot_django() { - echo "Rebooting Django Docker container..." - docker-compose restart django -} -defhelp reboot_django 'Reboot Django Docker container.' - -# Run style checks -cmd_style() { - echo "Running PEP8 style checker..." - docker-compose exec django /docker_venv/bin/flake8 - pep8_status=$? - echo - echo "Running Python docstring checker..." - docker-compose exec django /docker_venv/bin/pydocstyle --count --explain - pydocstyle_status=$? - ! (( pep8_status || pydocstyle_status )) -} -defhelp style 'Run style checks.' - -# Run test suite -cmd_test_suite() { - echo "Running test suite..." - docker-compose exec django /docker_venv/bin/coverage run --rcfile=/codewof/.coveragerc -m pytest --ds=config.settings.test - # docker-compose exec django /docker_venv/bin/mypy /codewof/codewof/ -} -defhelp test_suite 'Run test suite with code coverage.' - -# Run specific test suite -cmd_test_specific() { - echo "Running specific test suite..." - docker-compose exec django /docker_venv/bin/pytest --ds=config.settings.test "${1}" -} -defhelp test_specific 'Run specific test suite. Pass in parameter of Python test module.' - -# Display test coverage table -cmd_test_coverage() { - echo "Displaying test suite coverage..." - docker-compose exec django /docker_venv/bin/coverage xml -i - docker-compose exec django /docker_venv/bin/coverage report -m -} -defhelp test_coverage 'Display code coverage report.' - -# Delete all untagged dangling Docker images -cmd_clean() { - echo "If the following commands return an argument not found error," - echo "this is because there is nothing to delete for clean up." - - echo - echo "Deleting unused volumes..." - - unused_volumes=($(docker volume ls -qf dangling=true)) - for vol in "${unused_volumes[@]}"; do - docker volume rm "${vol}" - done - - echo - echo "Deleting exited containers..." - exited_containers=($(docker ps --filter status=dead --filter status=exited -aq)) - for container in "${exited_containers[@]}"; do - docker rm -v "${container}" - done - echo - echo "Deleting dangling images..." - dangling_images=($(docker images -f "dangling=true" -q)) - if [[ ${#dangling_images[@]} -gt 0 ]]; then - docker rmi "${dangling_images[@]}" - fi -} -defhelp clean 'Delete unused Docker files.' - -# Delete all Docker containers and images -cmd_wipe() { - docker ps -a -q | xargs docker rm - docker images -q | xargs docker rmi -} -defhelp wipe 'Delete all Docker containers and images.' - -# View logs -cmd_logs() { - docker-compose logs django -} -defhelp logs 'View logs.' - -ci_test_suite() { - cmd_static - cmd_collect_static - cmd_test_suite - test_status=$? - cmd_test_coverage - coverage_status=$? - bash <(curl -s https://codecov.io/bash) - ! (( $test_status || $coverage_status )) -} - -ci_style() { - cmd_style -} - -ci_test_backwards() { - cmd_static - cmd_collect_static - cmd_test_backwards -} - -cmd_ci() { - cmd_start - local cmd="$1" - shift - if [ -z "$cmd" ]; then - echo -e "${RED}ERROR: ci command requires one parameter!${NC}" - cmd_help - exit 1 - fi - if silent type "ci_$cmd"; then - "ci_$cmd" "$@" - exit $? - else - echo -e "${RED}ERROR: Unknown command!${NC}" - echo "Type './dev help' for available commands." - return 1 - fi -} - -silent() { - "$@" > /dev/null 2>&1 -} - -cmd_dev() { - local cmd="$1" - shift - if [ -z "$cmd" ]; then - echo -e "${RED}ERROR: command requires one parameter!${NC}" - cmd_help - return 1 - fi - if silent type "cmd_$cmd"; then - "cmd_$cmd" "$@" - exit $? - else - echo -e "${RED}ERROR: Unknown command!${NC}" - echo "Type './dev help' for available commands." - return 1 - fi -} - -# If no command given -if [ $# -eq 0 ]; then - echo -e "${RED}ERROR: This script requires a command!${NC}" - cmd_help - exit 1 -fi -cmd="$1" -shift -if silent type "cmd_$cmd"; then - "cmd_$cmd" "$@" - exit $? -else - echo -e "${RED}ERROR: Unknown command!${NC}" - echo "Type './dev help' for available commands." - exit 1 -fi diff --git a/docker-compose.local.yml b/docker-compose.local.yml index b48b3d818..9d6f68127 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -13,12 +13,12 @@ services: - ./infrastructure/local/postgres/.envs command: /start networks: - - uccser-development-proxy + - uccser-development-stack - backend labels: # General labels - "traefik.enable=true" - - "traefik.docker.network=uccser-development-proxy" + - "traefik.docker.network=uccser-development-stack" - "traefik.http.services.codewof-django.loadbalancer.server.port=8000" # HTTPS - "traefik.http.routers.codewof-django.entryPoints=web-secure" @@ -39,8 +39,6 @@ services: - ./codewof/static:/app/static:z - ./codewof/build:/app/build:z command: npm run dev - profiles: - - static-files ports: - "3000:3000" # Expose browsersync UI: https://www.browsersync.io/docs/options/#option-ui @@ -59,15 +57,8 @@ services: networks: - backend - mailhog: - image: mailhog/mailhog:v1.0.1 - ports: - - "8025:8025" - networks: - - backend - networks: backend: driver: bridge - uccser-development-proxy: + uccser-development-stack: external: true diff --git a/docs/adding-questions.md b/docs/adding-questions.md index d18003bfa..ecc5c9347 100644 --- a/docs/adding-questions.md +++ b/docs/adding-questions.md @@ -47,10 +47,11 @@ A question can only be one type, expect for questions that can be both function ## Adding a question -There are two stages to adding a question: +There are three stages to adding a question: 1. Add question metadata (language independent) 2. Add question content (language dependent) +3. Add question tags (difficulty, concepts, contexts) ### Adding question metadata @@ -95,3 +96,43 @@ Each question directory should have the following files: - `test-case-N-output.txt` - Expected output for test case. - `initial.py` (debugging type only) - Python file for initial code to display. + +## Adding question tags + +Each question must be tagged by difficulty, and can be tagged by programming concepts and programming contexts. +This allows users to easily search for questions of a specific type. + +Open directory `codewof/programming/content/structure/questions.yaml` + +Each question **requires** a `difficulty`, either: +- `difficulty-0` - Easy +- `difficulty-1` - Moderate +- `difficulty-2` - Difficult +- `difficulty-3` - Complex + +If applicable, one or more `concepts` should be added to the question from the following +(i.e. you cannot have a question with the "Conditionals" concept, it needs to be a sub-category such as `single-condition`): + +- `display-text` - Display Text +- `functions` - Functions +- `inputs` - Inputs +- Conditionals + - `single-condition` - Single Condition + - `multiple-conditions` - Multiple Conditions + - `advanced-conditionals` - Advanced Conditionals +- Loops + - `conditional-loops` - Conditional Loops + - `range-loops` - Range Loops +- `string-operations` - String Operations +- `lists` - Lists + +If applicable, one or more `contexts` should be added to the question from the following +(i.e. you cannot have a question with the "Geometry" context, it needs to be a sub-category such as `basic-geometry`): + +- Mathematics + - Geometry + - `basic-geometry` - Basic Geometry + - `advanced-geometry` - Advanced Geometry + - `simple-mathematics` - Simple Mathematics + - `advanced-mathematics` - Advanced Mathematics +- `real-world-applications` - Real World Applications diff --git a/infrastructure/production/django/Dockerfile b/infrastructure/production/django/Dockerfile index 44611bb16..18efa4cb8 100644 --- a/infrastructure/production/django/Dockerfile +++ b/infrastructure/production/django/Dockerfile @@ -26,6 +26,16 @@ ARG GIT_SHA ARG BUILD_ENVIRONMENT ARG APP_HOME=/app +LABEL org.opencontainers.image.title='codeWOF' +LABEL org.opencontainers.image.description='A website for maintaining your basic programming skills.' +LABEL org.opencontainers.image.authors='University of Canterbury Computer Science Education Research Group (UCCSER) https://github.com/uccser' +LABEL org.opencontainers.image.url='https://www.codewof.co.nz/' +LABEL org.opencontainers.image.documentation='https://github.com/uccser/codewof' +LABEL org.opencontainers.image.source='https://github.com/uccser/codewof' +LABEL org.opencontainers.image.vendor='University of Canterbury Computer Science Education Research Group (UCCSER)' +LABEL org.opencontainers.image.licenses='MIT' +LABEL org.opencontainers.image.revision=${GIT_SHA} + # Set environment variables ENV LANG C.UTF-8 ENV DEPLOYED=True diff --git a/requirements/base.txt b/requirements/base.txt index f1c429cd6..a5e83b5f0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,27 +1,27 @@ # Django -django==3.2.6 -django-environ==0.4.5 -django-model-utils==4.1.1 -django-anymail[mailgun]==7.0.0 +django==3.2.15 +django-environ==0.9.0 +django-model-utils==4.2.0 +django-anymail[mailgun]==8.6 django-mail-templated==2.6.5 -django-allauth==0.45.0 -django-crispy-forms==1.9.0 +django-allauth==0.51.0 +django-crispy-forms==1.14.0 django-activeurl==0.2.0 django-autoslug==1.9.8 -djangorestframework==3.12.4 +djangorestframework==3.14.0 coreapi==2.3.3 -django-filter==21.1 +django-filter==22.1 django-inline-svg==0.1.1 -django-ckeditor==6.1.0 -django-recaptcha==2.0.6 +django-ckeditor==6.5.1 +django-recaptcha==3.0.0 django-bootstrap-breadcrumbs==0.9.2 # Web serving gunicorn==20.1.0 -whitenoise==5.3.0 +whitenoise==6.2.0 # Database APIs -psycopg2==2.9.1 +psycopg2==2.9.3 # Content loading verto==1.0.1 @@ -30,14 +30,17 @@ PyYAML==5.4.1 # Style checker flake8==4.0.1 -flake8-docstrings==1.5.0 -flake8-quotes==2.1.1 -pep8-naming==0.12.1 +flake8-docstrings==1.6.0 +flake8-quotes==3.3.1 +pep8-naming==0.13.2 # Other -pytz==2019.3 +pytz==2022.2.1 python-dateutil==2.8.2 -argon2-cffi==19.2.0 +argon2-cffi==21.3.0 # I18n -django-modeltranslation==0.17.3 +django-modeltranslation==0.18.4 + +# CORS +django-cors-headers==3.13.0 diff --git a/requirements/local.txt b/requirements/local.txt index c2d03947c..abccdc18e 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -3,18 +3,18 @@ # Testing # ------------------------------------------------------------------------------ -mypy==0.782 # https://github.com/python/mypy -pytest==6.2.5 # https://github.com/pytest-dev/pytest -pytest-sugar==0.9.4 # https://github.com/Frozenball/pytest-sugar +mypy==0.971 # https://github.com/python/mypy +pytest==7.1.3 # https://github.com/pytest-dev/pytest +pytest-sugar==0.9.5 # https://github.com/Frozenball/pytest-sugar # Code quality # ------------------------------------------------------------------------------ # flake8 is installed as dependency in base.txt -coverage==6.0.2 # https://github.com/nedbat/coveragepy +coverage==6.4.4 # https://github.com/nedbat/coveragepy pydocstyle==6.1.1 # Django # ------------------------------------------------------------------------------ -django-debug-toolbar==3.2.2 # https://github.com/jazzband/django-debug-toolbar -django-extensions==3.0.8 # https://github.com/django-extensions/django-extensions -pytest-django==3.9.0 # https://github.com/pytest-dev/pytest-django +django-debug-toolbar==3.6.0 # https://github.com/jazzband/django-debug-toolbar +django-extensions==3.2.1 # https://github.com/django-extensions/django-extensions +pytest-django==4.5.2 # https://github.com/pytest-dev/pytest-django diff --git a/requirements/test.txt b/requirements/test.txt index ca1a32d83..c17999d56 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,4 +2,4 @@ # Skip migration files for local testing django-test-without-migrations==0.6 -factory-boy==2.12.0 +factory-boy==3.2.1