+ {% block header %}{% endblock %} +
++ {% block greeting %}{% endblock %} +
+ ++ {% block main_message %}{% endblock %} +
+ + {% block link %}{% endblock %} + ++ {% block conclusion %}{% endblock %} +
+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 greeting %}{% endblock %} +
+ ++ {% block main_message %}{% endblock %} +
+ + {% block link %}{% endblock %} + ++ {% block conclusion %}{% endblock %} +
+
+ {{ description.0 }}
+
+ {{ description.1 }}
+
Currently all questions are based in Python 3.
++ 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.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 }} - -- {% 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 %} -We recommend doing one or two questions per day, to maintain your programming skills over a long period of time.
- - {% if questions_to_do %} -You've solved {{ num_questions_answered }} question{{ num_questions_answered|pluralize }} in the last month!
+ View All Questions ++ 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 greeting %}{% endblock %} -
- -- {% block main_message %}{% endblock %} -
- - {% block link %}{% endblock %} - -
- Thanks,
- The Computer Science Education Research Group
-
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 '