diff --git a/.dockerignore b/.dockerignore index f5e96db..2d19ec7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ -venv \ No newline at end of file +venv +.env \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 0fd0d40..5675efc 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,30 +1,30 @@ name-template: 'v$RESOLVED_VERSION' tag-template: 'v$RESOLVED_VERSION' categories: - - title: 'Features' - labels: - - 'feature' - - 'enhancement' - - title: 'Bug Fixes' - labels: - - 'fix' - - 'bugfix' - - 'bug' - - title: 'Maintenance' - label: 'chore' + - title: 'Features' + labels: + - 'feature' + - 'enhancement' + - title: 'Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: 'Maintenance' + label: 'chore' change-template: '- $TITLE @$AUTHOR (#$NUMBER)' change-title-escapes: '\<*_&#@' # You can add # and @ to disable mentions, and add ` to disable code blocks. version-resolver: - major: - labels: - - 'major' - minor: - labels: - - 'minor' - patch: - labels: - - 'patch' - default: patch + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch template: | - # Changes - $CHANGES + # Changes + $CHANGES diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000..e0aacac --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,55 @@ +name: Docker Build and Push + +on: + push: + branches: + - main + tags: + - v* + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Setup Chrome + uses: nanasess/setup-chromedriver@v2 + - name: Install dependencies + run: python -m pip install -r requirements.txt + - name: Test build + run: python manage.py test + + build-and-push: + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Build & push Docker image + uses: mr-smithers-excellent/docker-build-push@v6 + with: + image: c4thebomb/csmb-housewars + registry: docker.io + addLatest: true + multiPlatform: true + platform: linux/amd64,linux/arm64/v8 + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + draft: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v3 + - name: Draft release + uses: release-drafter/release-drafter@v5 + id: release-draft + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml deleted file mode 100644 index a201277..0000000 --- a/.github/workflows/build-docker.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Commit docker image - -on: - push: - tags: - - 'v*.*.*' - workflow_dispatch: - -jobs: - build: - environment: production - runs-on: ubuntu-latest - - steps: - - name: Cache Docker layers - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - name: Checkout - uses: actions/checkout@v2 - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - builder: ${{ steps.buildx.outputs.name }} - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/collegiate-housewars:latest - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml deleted file mode 100644 index 29cc887..0000000 --- a/.github/workflows/draft-release.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Create release draft - -on: - push: - branches: [development] - workflow_dispatch: - -jobs: - draft: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Draft release - uses: release-drafter/release-drafter@v5 - id: release-draft - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml deleted file mode 100644 index 3efd770..0000000 --- a/.github/workflows/test-build.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Test build - -on: - push: - branches: [development, main] - pull_request: - workflow_dispatch: - -jobs: - test: - environment: test - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Write .env to build - env: - DOTENV: '${{ secrets.DOTENV }}' - run: echo "$DOTENV" >> .env - - name: Setup python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - name: Install dependencies - run: python -m pip install -r requirements.txt - - name: Test build - run: python manage.py test diff --git a/.gitignore b/.gitignore index 82ad58d..813c18b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ - +staticfiles/ # Created by https://www.toptal.com/developers/gitignore/api/django,python,pydev,venv # Edit at https://www.toptal.com/developers/gitignore?templates=django,python,pydev,venv @@ -260,14 +260,6 @@ cython_debug/ ### venv ### # Virtualenv # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ -[Bb]in -[Ii]nclude -[Ll]ib -[Ll]ib64 -[Ll]ocal -[Ss]cripts -pyvenv.cfg -pip-selfcheck.json +venv # End of https://www.toptal.com/developers/gitignore/api/django,python,pydev,venv - diff --git a/.vscode/settings.json b/.vscode/settings.json index 6e9ce91..aaadf14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,12 @@ "emmet.includeLanguages": { "jinja-html": "html" }, - "editor.defaultFormatter": null + "editor.defaultFormatter": null, + "[dockercompose]": { + "editor.defaultFormatter": "ms-azuretools.vscode-docker" + }, + "lldb.displayFormat": "auto", + "lldb.showDisassembly": "auto", + "lldb.dereferencePointers": true, + "lldb.consoleMode": "commands" } diff --git a/CollegiateHouseWars/settings/__init__.py b/CollegiateHouseWars/settings/__init__.py index 72e250a..8f607e4 100644 --- a/CollegiateHouseWars/settings/__init__.py +++ b/CollegiateHouseWars/settings/__init__.py @@ -1,3 +1 @@ -from . import dev -from . import prod -from . import test +from .local import * diff --git a/CollegiateHouseWars/settings/test.py b/CollegiateHouseWars/settings/defaults.py similarity index 65% rename from CollegiateHouseWars/settings/test.py rename to CollegiateHouseWars/settings/defaults.py index 4466a06..055f083 100644 --- a/CollegiateHouseWars/settings/test.py +++ b/CollegiateHouseWars/settings/defaults.py @@ -1,31 +1,12 @@ from pathlib import Path -import os from django.contrib.messages import constants -# Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent.parent -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -DOTENV_FILE = BASE_DIR / '.env' -SECRET_KEY = os.environ.get('SECRET_KEY') - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - SECURE_SSL_REDIRECT = False SESSION_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False -ALLOWED_HOSTS = [ - 'localhost', - '127.0.0.1', -] - -# Application definition - INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -34,13 +15,15 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', + 'smart_selects', 'formtools', - 'housewars.apps.HousewarsConfig' + 'housewars', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -69,21 +52,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'CollegiateHouseWars.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/4.0/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': '/var/www/housewars/db/db.sqlite3', - } -} - - -# Password validation -# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -99,10 +67,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - -# Internationalization -# https://docs.djangoproject.com/en/4.0/topics/i18n/ - LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' @@ -111,20 +75,26 @@ USE_I18N = True USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.0/howto/static-files/ - STATIC_URL = 'static/' -STATIC_ROOT = '/var/www/collegiate-housewars/static/' +STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [ BASE_DIR / "static" ] - -# Default primary key field type -# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'WARNING', + }, +} DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' @@ -134,3 +104,5 @@ MESSAGE_TAGS = {constants.DEBUG: 'debug', constants.SUCCESS: 'success', constants.WARNING: 'warning', constants.ERROR: 'danger', } + +USE_DJANGO_JQUERY = True diff --git a/CollegiateHouseWars/settings/dev.py b/CollegiateHouseWars/settings/dev.py deleted file mode 100644 index 0f88db5..0000000 --- a/CollegiateHouseWars/settings/dev.py +++ /dev/null @@ -1,141 +0,0 @@ -from pathlib import Path -import os -from django.contrib.messages import constants - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -DOTENV_FILE = BASE_DIR / '.env' -SECRET_KEY = os.environ.get('SECRET_KEY') - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -SECURE_SSL_REDIRECT = False -SESSION_COOKIE_SECURE = False -CSRF_COOKIE_SECURE = False - -ALLOWED_HOSTS = [ - 'localhost', - '127.0.0.1', -] - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'formtools', - - 'housewars.apps.HousewarsConfig' -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'CollegiateHouseWars.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'CollegiateHouseWars.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/4.0/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'dev', - 'USER': os.environ.get('DATABASE_USERNAME'), - 'PASSWORD': os.environ.get('DATABASE_PASSWORD'), - 'HOST': 'mysqldb', - 'PORT': '3306' - } -} - - -# Password validation -# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/4.0/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.0/howto/static-files/ - -STATIC_URL = 'static/' -STATIC_ROOT = '/var/www/collegiate-housewars/static/' - -STATICFILES_DIRS = [ - BASE_DIR / "static" -] - - -# Default primary key field type -# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - - -MESSAGE_TAGS = {constants.DEBUG: 'debug', - constants.INFO: 'info', - constants.SUCCESS: 'success', - constants.WARNING: 'warning', - constants.ERROR: 'danger', } diff --git a/CollegiateHouseWars/settings/docker.py b/CollegiateHouseWars/settings/docker.py new file mode 100644 index 0000000..e6c3a0e --- /dev/null +++ b/CollegiateHouseWars/settings/docker.py @@ -0,0 +1,12 @@ +from .local import * + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'dev', + 'USER': 'root', + 'PASSWORD': 'passw0rd', + 'HOST': 'mysqldb', + 'PORT': '3306' + } +} diff --git a/CollegiateHouseWars/settings/local.py b/CollegiateHouseWars/settings/local.py new file mode 100644 index 0000000..6722443 --- /dev/null +++ b/CollegiateHouseWars/settings/local.py @@ -0,0 +1,17 @@ +from .defaults import * + +SECRET_KEY = '*9r50c74r58p#*tnfpadsw*cp&$(^sj585w1u@!y9*eowmwl1*' + +DEBUG = True + +ALLOWED_HOSTS = [ + 'localhost', + '127.0.0.1', +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} diff --git a/CollegiateHouseWars/settings/prod.py b/CollegiateHouseWars/settings/prod.py index ca83e42..82732ba 100644 --- a/CollegiateHouseWars/settings/prod.py +++ b/CollegiateHouseWars/settings/prod.py @@ -1,141 +1,29 @@ -from pathlib import Path import os -from django.contrib.messages import constants -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent.parent +from .defaults import * - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -DOTENV_FILE = BASE_DIR / '.env' SECRET_KEY = os.environ.get('SECRET_KEY') -# SECURITY WARNING: don't run with debug turned on in production! DEBUG = False -SECURE_SSL_REDIRECT = False -SESSION_COOKIE_SECURE = False -CSRF_COOKIE_SECURE = False - ALLOWED_HOSTS = [ - 'localhost', - '127.0.0.1', + 'csmb-housewars.c4patino.com', + 'collegiate-housewars-02810c3f29dc.herokuapp.com', ] -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'formtools', - - 'housewars.apps.HousewarsConfig' +CSRF_TRUSTED_ORIGINS = [ + 'http://collegiate-housewars-02810c3f29dc.herokuapp.com/' ] -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'CollegiateHouseWars.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'CollegiateHouseWars.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/4.0/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'prod', + 'NAME': os.environ.get('DATABASE_NAME'), 'USER': os.environ.get('DATABASE_USERNAME'), 'PASSWORD': os.environ.get('DATABASE_PASSWORD'), - 'HOST': 'mysqldb', + 'HOST': os.environ.get('DATABASE_HOST'), 'PORT': '3306' } } - -# Password validation -# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/4.0/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.0/howto/static-files/ - -STATIC_URL = 'static/' -STATIC_ROOT = '/var/www/housewars/static/' - -STATICFILES_DIRS = [ - BASE_DIR / "static" -] - - -# Default primary key field type -# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - - -MESSAGE_TAGS = {constants.DEBUG: 'debug', - constants.INFO: 'info', - constants.SUCCESS: 'success', - constants.WARNING: 'warning', - constants.ERROR: 'danger', } +STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage" diff --git a/CollegiateHouseWars/urls.py b/CollegiateHouseWars/urls.py index 9546ba2..a8371b8 100644 --- a/CollegiateHouseWars/urls.py +++ b/CollegiateHouseWars/urls.py @@ -21,5 +21,6 @@ from django.conf.urls.static import static urlpatterns = [ path('', include('housewars.urls')), + path('chaining/', include('smart_selects.urls')), path('admin/', admin.site.urls), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/CollegiateHouseWars/wsgi.py b/CollegiateHouseWars/wsgi.py index 60e7c54..6985fc8 100644 --- a/CollegiateHouseWars/wsgi.py +++ b/CollegiateHouseWars/wsgi.py @@ -10,7 +10,9 @@ https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ import os from django.core.wsgi import get_wsgi_application +from whitenoise import WhiteNoise -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'CollegiateHouseWars.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'CollegiateHouseWars.settings.prod') application = get_wsgi_application() +application = WhiteNoise(application) diff --git a/Dockerfile b/Dockerfile index 6523189..f042053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,20 @@ -# syntax=docker/dockerfile:1 -FROM python:3.10.6-slim-buster -RUN apt update && apt install build-essential default-libmysqlclient-dev -y +FROM python:3.10 + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + WORKDIR /app + COPY requirements.txt requirements.txt -RUN python -m pip install -r requirements.txt + +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install gunicorn + COPY . . + +RUN python manage.py collectstatic --noinput + EXPOSE 8000/tcp -CMD python manage.py runserver 0.0.0.0:8000 \ No newline at end of file + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "CollegiateHouseWars.wsgi:application"] \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..42d8fca --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM python:3.10 + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app + +COPY requirements.txt requirements.txt + +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +COPY . . + +EXPOSE 8000/tcp + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] \ No newline at end of file diff --git a/LICENSE b/LICENSE index 3b8d3aa..6c6233e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2022 C4 Patino + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + 1. Definitions. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 C4 Patino + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..74c5278 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn CollegiateHouseWars.wsgi diff --git a/README.md b/README.md index 0ab3dab..93b7fe1 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,86 @@ # Collegiate Housewars -Welcome to the repository for the Collegiate School of Medicine and Bioscience Housewars site. This repository is open-source, so feel free to download, play around, or contribute. Below is a guide detailing the basic usage of the site and instructions for installation of a local server. Enjoy! +Welcome to the repository for the Collegiate School of Medicine and Bioscience House Wars site. This is a project that is completely made in the Python Django framework, and should contain all the features that are necessary to run House Wars (if it doesn't please let us know). This repository is open-source, so feel free to download, play around, and/or contribute. Below is a guide detailing the basic usage of the site and instructions for installation of a local development server. Enjoy! | Table of Contents | | ------- | -| [Usage](#Usage-Instructions) | -| [Installation](#Installation) | -| [Defining .env](#Defining-env) | -| [Contributing](#Contributing) | +| [Usage](#usage-instructions) | +| [Setup](#setup) | +| [Hosting](#hosting) | +| [Contributing](#contributing) | ## Usage Instructions So far there are three primary pages on this site. They include the: -1. Admin page - this is accessed through `/admin`, and contains the function for managing the activities in the latest housewars and managing points totals and student signups. -2. Student signup form - this is accessed at `/`, and contains the form needed for students to signup for housewars activities. -3. Teacher points form - this is accessed at `/points` and contains the form needed for teachers to add points to houses for activities. +1. Student signup form - this is accessed at `/`, and contains the form for students to signup for House Wars activities. +2. Teacher points form - this is accessed at `/points` and contains the form to award points for activities. +3. Facilitator signup form - this is accessed at `/facilitator` and contains the form for volunteers to signup to facilitate activities. +4. Admin - this is accessed through `/admin`, and contains the function for managing everything related to House Wars that the general population should not be able to see. -## Installation -Welcome to the installation section of the guide. This will walk you through installing the site and spinning up a local development server. +## Setup +Welcome to the installation section of the guide. This will walk you through installing the site and spinning up a local development server. There are two methods to settings up the development server. One uses docker and the other just boots up a local development server. I would highly recommend setting up docker for contributing, it is used by very many other projects and is a great tool for development. +- [With Docker](#setup-with-docker) +- [Without Docker](#setup-without-docker) -### Prerequisites: -- git - You can test if you have git installed using the command `git -v`, which should output a version number. If you do not have it installed you can install it [here](https://git-scm.com/downloads). -- python - You can test if you have python installed by typing `python -v` in the terminal. The resulting output should be a version number. If you do not have python installed, you can download it [here](https://www.python.org/downloads/). -- python-pip - Test if you have it installed by typing `python -m pip --version`. You should see a version number for the current version of pip. There are two ways to download it if you do not have it. You can either run the command `python -m ensurepip --upgrade` if your python installation contains the ensurepip module. Otherwise, you will have to download the [get-pip.py](https://bootstrap.pypa.io/get-pip.py) file from online and run that. -- virtualenv - You will need to install virtualenv by using the command `python -m virtualenv `. +### Setup with Docker: + +#### Prerequisites: +- `git` - You can test if you have git installed using the command `git -v`, which should output a version number. If you do not have git installed you can download it [here](https://git-scm.com/downloads). +- `docker` - You can test if you have docker installed by typing `docker -v` in the terminal. The resulting output should be a version number. If you do not have docker installed, you can download it [here](https://www.docker.com/get-started/). -### Instructions -1. Clone the repository into the desired directory using `git clone https://github.com/C4theBomb/collegiate-housewars.git` -2. Navigate into the repository using `cd collegiate-housewars` -3. Run the command `python -m virtualenv `, then activate the environment using `.//Scripts/activate`. -4. Install all the required packages using `pip install -r requirements.txt`. -5. Run migrations and create a development sqlite3 database using `python manage.py migrate`. -6. Server should be available to run. You can activate the server using `python manage.py runserver`. The site will be accessible using the [default url](http://localhost:8000). +#### Instructions +1. Clone the repository into the desired directory using `git clone https://github.com/C4theBomb/collegiate-housewars.git`. +2. Navigate into the repository using `cd collegiate-housewars`. +3. Open Docker and run the command `docker compose up -d` in a shell. +4. In Docker, open the shell of the `app-1`. +5. Server should be running automatically. The site will be accessible using the [default url](http://localhost:8000). Any changes made in the filesystem will update live on the local server. -## Defining .env -The `.env` file is used to secure data and keep it from entering a cloud, open-acccess environment. Because of this, you will need to define a `.env` file yourself. The parameters that you will need are: -- SECRET_KEY - This will contain the server key of your django local server. It can be generated using the command `python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`. +#### Relevant Commands: +- `docker compose up`: This will run the docker server on the default url. + - `-d`: Removed terminal output from the command (disconnected mode). AKA docker doesn't steal your terminal +- `docker compose down`: This will stop the currently running containers and free up other ports for other apps (if you have them). + - `-v`: Removes volumes as well. Docker volumes can get very large and this will wipe all data contained in your database. WARNING: IRREVERSIBLE. + +### Setup without Docker: + +#### Prerequisites: +- `git` - You can test if you have git installed using the command `git -v`, which should output a version number. If you do not have git installed you can download it [here](https://git-scm.com/downloads). +- `python` - You can test if you have docker installed by typing `docker -v` in the terminal. The resulting output should be a version number. If you do not have docker installed, you can download it [here](https://www.docker.com/get-started/). +- `pip` - You can test if you have pip installed using `python -m pip --version`. If you do not have it installed then installation instructions can be found [here](https://pip.pypa.io/en/stable/installation/). + +#### Instructions: +1. Clone the repository into the desired directory using `git clone https://github.com/C4theBomb/collegiate-housewars.git`. +2. Navigate into the repository using `cd collegiate-housewars`. +3. Install virtualenv by running `python -m pip install virtualenv`. +4. Create a virtualenv using the command `python -m virtualenv venv`. +5. Activate the virtualenvironment using the command for your os: + - Linux/Mac: `source ./venv/bin/activate` + - Windows: `.\venv\Scripts\activate` +6. Install dependencies using the command `pip install -r requirements.txt`. +7. Run migrations using the command `python manage.py migrate`. +8. Run the local development server using `python manage.py runserver`. + +### Relevant Commands: +- `python manage.py test`: Runs all tests for current features of the site. If something fails, your broke something, please fix it. If you think its not your fault, it probably is. However, if your REALLY think its not your fault, create an issue and we will try our best to resolve it as soon as possible. +- `python manage.py makemigrations`: If you end up making any changes to the files in `housewars/models`, please run this command to log your changes. This will allow us to make the relevant changes to our production database and display the new features on the site. + +## Hosting + +### Options +There are several options that you can use for hosting the site on a production server. My personal recommendation would either be Heroku or Linode, since it comes pre-configured with the website. Linode is very cheap at only $5 a month, while Heroku is completely free (albeit more restricted). + +### Environment Variables +The server environment variables are used to secure data and keep it from entering a cloud, open-acccess environment. Because of this, you will need to define a them file yourself. These are only needed in production and are completely unnecessary if you are using a local development server. The varaibles that you will need are: +- SECRET_KEY - This will contain the secret encryption key of your django server. It can be generated using the command `python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`. - DATABASE_USERNAME - This will be the username of your production database. - DATABASE_PASSWORD - This will be the password of your production database. -- DATABASE_HOST - This will be IPv4/IPv6 address of your production database. -- DATABASE_NAME - This will be the name of your production database. +- DATABASE_HOST - This will be the IP address of your production database. ## Contributing -As an open-source school repository we welcome all contributors willing to help make our website better. +As an open-source school repository we welcome all contributors willing to help make the House Wars website better (by hacking it or otherwise). ### Instructions -1. Follow the [installation instructions above](#Installation) to install the required packages and run the local server. You will want to fork the repository before cloning it. +1. Follow the [setup instructions above](#setup) to install the required dependencies and run the local server. You will want to fork the repository and clone your own repository. 2. Commit your new features to your new forked repository. 3. Create a pull request that details the changes/improvements that you have made. -4. Wait for @C4theBomb to open discussion/merge your pull request. +4. Wait for @C4theBomb (C4 Patino), @gywn9081 (Henry Bloch), or @Slayer121 (Dylan Fritz) to open discussion or merge your pull request. +5. After your pull request is merged, it will automatically be uploaded into production code and will show up on the website. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 78a5831..1cb8e52 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,35 +1,27 @@ version: '3.8' -name: 'collegiate-housewars' services: app: restart: unless-stopped build: context: . + dockerfile: Dockerfile + platforms: + - linux/amd64 + - linux/arm64/v8 + image: c4thebomb/csmb-housewars:${TAG:-latest} + env_file: + - path: .env + required: true ports: - - 8000:8000 - volumes: - - ./:/app + - 127.0.0.1:8000:8000 networks: - housewars - env_file: .env - environment: - DJANGO_SETTINGS_MODULE: "CollegiateHouseWars.settings.prod" - depends_on: + - common-network + external_links: - mysqldb - mysqldb: - restart: unless-stopped - image: mysql:5.7 - ports: - - 3306:3306 - volumes: - - housewars-mysql:/var/lib/mysql - networks: - - housewars - environment: - MYSQL_ROOT_PASSWORD: "${DATABASE_PASSWORD}" - MYSQL_DATABASE: prod -volumes: - housewars-mysql: + networks: + common-network: + external: true housewars: {} diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index eccb211..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: '3.8' - -name: 'collegiate-housewars' -services: - app: - build: - context: . - ports: - - 8000:8000 - volumes: - - ./:/app - - housewars-sqlite3:/var/www/housewars/db - env_file: .env - environment: - DJANGO_SETTINGS_MODULE: "CollegiateHouseWars.settings.test" - command: python manage.py test -volumes: - housewars-sqlite3: diff --git a/docker-compose.yml b/docker-compose.yml index 832ba8c..652e598 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,27 @@ version: '3.8' -name: 'collegiate-housewars' services: app: restart: unless-stopped build: context: . + dockerfile: Dockerfile.dev ports: - 8000:8000 volumes: - ./:/app networks: - housewars - env_file: .env environment: - DJANGO_SETTINGS_MODULE: "CollegiateHouseWars.settings.dev" + DJANGO_SETTINGS_MODULE: "CollegiateHouseWars.settings.docker" depends_on: - - mysqldb + mysqldb: + condition: service_healthy + command: > + bash -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" mysqldb: restart: unless-stopped - image: mysql:5.7 + image: mysql:8.3 ports: - 3306:3306 volumes: @@ -27,9 +29,16 @@ services: networks: - housewars environment: - MYSQL_ROOT_PASSWORD: "${DATABASE_PASSWORD}" + MYSQL_ROOT_PASSWORD: "passw0rd" MYSQL_DATABASE: dev + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + timeout: 5s + retries: 10 + volumes: housewars-mysql: + + networks: housewars: {} diff --git a/housewars/admin.py b/housewars/admin.py deleted file mode 100644 index 75a8a93..0000000 --- a/housewars/admin.py +++ /dev/null @@ -1,66 +0,0 @@ -from django.contrib import admin -from django.db.models import Sum -from django.db.models.functions import Coalesce - -from .models import Activity, House, PointsEntry, UserEntry - - -@admin.register(Activity) -class ActivityAdmin(admin.ModelAdmin): - fieldsets = [ - (None, {'fields': ['name']}), - ('Activity Information', { - 'fields': ['quota', 'time']}), - ] - - list_display = ['name', 'time', 'quota', 'hawk_signups', - 'great_grey_signups', 'snowy_signups', 'eagle_signups'] - - @admin.display(description='Hawk Signups') - def hawk_signups(self, obj): - return str(UserEntry.hawk.filterActivity1(obj).count()) + " / " + str((UserEntry.hawk.filterActivity2(obj).count(), "N/A")[obj.time == 60]) - - @admin.display(description='Great Grey Signups') - def great_grey_signups(self, obj): - return str(UserEntry.great_grey.filterActivity1(obj).count()) + " / " + str((UserEntry.great_grey.filterActivity2(obj).count(), "N/A")[obj.time == 60]) - - @admin.display(description='Snowy Signups') - def snowy_signups(self, obj): - return str(UserEntry.snowy.filterActivity1(obj).count()) + " / " + str((UserEntry.snowy.filterActivity2(obj).count(), "N/A")[obj.time == 60]) - - @admin.display(description='Eagle Signups') - def eagle_signups(self, obj): - return str(UserEntry.eagle.filterActivity1(obj).count()) + " / " + str((UserEntry.eagle.filterActivity2(obj).count(), "N/A")[obj.time == 60]) - - -@admin.register(House) -class HouseAdmin(admin.ModelAdmin): - list_display = ('name', 'points', 'current_points', 'total_points') - - @admin.display(description='Current Housewars Points') - def current_points(self, obj): - return PointsEntry.objects.filter(house__name=obj.name).aggregate(points__sum=Coalesce(Sum('points'), 0)).get('points__sum') - - @admin.display(description='Total Points') - def total_points(self, obj): - return self.current_points(obj) + obj.points - - -@admin.register(UserEntry) -class EntryAdmin(admin.ModelAdmin): - fieldsets = [ - ('Personal Information', {'fields': [ - 'first_name', 'last_name', 'email', 'grade']}), - ('House Wars Information', { - 'fields': ['house', 'activity1', 'activity2']}), - ] - - list_display = ('first_name', 'last_name', - 'house', 'activity1', 'activity2') - - list_filter = ['house', 'grade', 'activity1', 'activity2'] - - -@admin.register(PointsEntry) -class PointsEntryAdmin(admin.ModelAdmin): - list_display = ('activity', 'name', 'house', 'points') diff --git a/housewars/admin/Activity.py b/housewars/admin/Activity.py new file mode 100644 index 0000000..2ebff93 --- /dev/null +++ b/housewars/admin/Activity.py @@ -0,0 +1,145 @@ +from django.http import HttpResponse +from django.contrib import admin, messages +from django.db.models import F +from django.utils.translation import ngettext + +import io +import zipfile + +from housewars.utils import get_random_string, load_pdf +from housewars.models import Activity, Award, Quota, UserEntry + + +class QuotaInline(admin.TabularInline): + model = Quota + extra = 0 + + +class AwardsInline(admin.TabularInline): + model = Award + extra = 0 + + +@admin.register(Activity) +class ActivityAdmin(admin.ModelAdmin): + fieldsets = [ + (None, {'fields': ['name', 'room_number']}), + ('Activity Information', { + 'fields': ['default_quota', 'time', 'password']}), + ] + + list_display = ['name', 'time', 'room_number', 'teacher', 'password', + 'default_quota', 'hawk_signups', 'great_grey_signups', 'snowy_signups', 'eagle_signups'] + + inlines = [QuotaInline, AwardsInline] + + actions = ['generate_passwords', 'remove_room_number', + 'export_a1_to_pdf', 'export_a2_to_pdf'] + + @admin.display(description='Hawk') + def hawk_signups(self, obj): + if (obj.time == None): + return "- | -" + return str(UserEntry.objects.filter(house__name='Hawk', activity1=obj).count()) + ' | ' + str((UserEntry.objects.filter(house__name='Hawk', activity2=obj).count(), '-')[obj.time == 60]) + + @admin.display(description='Great Grey') + def great_grey_signups(self, obj): + if (obj.time == None): + return "- | -" + return str(UserEntry.objects.filter(house__name='Great Grey', activity1=obj).count()) + ' | ' + str((UserEntry.objects.filter(house__name='Great Grey', activity2=obj).count(), '-')[obj.time == 60]) + + @admin.display(description='Snowy') + def snowy_signups(self, obj): + if (obj.time == None): + return "- | -" + return str(UserEntry.objects.filter(house__name='Snowy', activity1=obj).count()) + ' | ' + str((UserEntry.objects.filter(house__name='Snowy', activity2=obj).count(), '-')[obj.time == 60]) + + @admin.display(description='Eagle') + def eagle_signups(self, obj): + if (obj.time == None): + return "- | -" + return str(UserEntry.objects.filter(house__name='Eagle', activity1=obj).count()) + ' | ' + str((UserEntry.objects.filter(house__name='Eagle', activity2=obj).count(), '-')[obj.time == 60]) + + @admin.action(description='Generate passwords for selected activities') + def generate_passwords(self, request, queryset): + for activity in queryset: + activity.password = get_random_string(8) + activity.save() + + self.message_user(request, ngettext( + 'Generated password for %d activity.', + 'Generated passwords for %d activities.', + queryset.count(), + ) % queryset.count(), messages.SUCCESS) + + @admin.action(description='Remove room number from selected activities') + def remove_room_number(self, request, queryset): + queryset.update(room_number=None) + + self.message_user(request, ngettext( + 'Removed room number for %d activity.', + 'Removed room numbers for %d activities.', + queryset.count(), + ) % queryset.count(), messages.SUCCESS) + + @admin.action(description='Export activity 1 attendance to pdf') + def export_a1_to_pdf(self, request, queryset): + file_list = {} + + for activity in queryset: + user_entries = activity.activity1 + + # Augment the queryset with extra data + user_entries = user_entries.annotate( + a1_room=F('activity1__room_number')).annotate(a2_room=F('activity2__room_number')) + + # Select headers that are to be unpacked + headers = ['first_name', 'last_name', 'grade', 'house', + 'activity1', 'a1_room', 'activity2', 'a2_room'] + + file = load_pdf(user_entries, headers, f"{activity.name} - 1") + + file_list[f"{activity.name} - 1"] = file + + outfile = io.BytesIO() + with zipfile.ZipFile(outfile, 'w') as zf: + for name, file in file_list.items(): + zf.writestr(f"{name}.pdf", file.getvalue()) + outfile = outfile.getvalue() + + response = HttpResponse( + outfile, content_type='application/octet-stream') + response['Content-Disposition'] = 'attachment; filename=activity1_attendance.zip' + + return response + + @admin.action(description='Export activity 2 attendance to pdf') + def export_a2_to_pdf(self, request, queryset): + file_list = {} + + for activity in queryset: + user_entries = activity.activity2 + + # Augment the queryset with extra data + user_entries = user_entries.annotate( + a1_room=F('activity1__room_number')).annotate(a2_room=F('activity2__room_number')) + + # Select headers that are to be unpacked + headers = ['first_name', 'last_name', 'grade', 'house', + 'activity1', 'a1_room', 'activity2', 'a2_room'] + + file = load_pdf(user_entries, headers, f"{activity.name} - 2") + + file_list[f"{activity.name} - 2"] = file + + outfile = io.BytesIO() + with zipfile.ZipFile(outfile, 'w') as zf: + for name, file in file_list.items(): + zf.writestr(f"{name}.pdf", file.getvalue()) + outfile = outfile.getvalue() + + response = HttpResponse( + outfile, content_type='application/octet-stream') + response['Content-Disposition'] = 'attachment; filename=activity2_attendance.zip' + + return response diff --git a/housewars/admin/Facilitator.py b/housewars/admin/Facilitator.py new file mode 100644 index 0000000..f2cfcac --- /dev/null +++ b/housewars/admin/Facilitator.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from housewars.models import Facilitator + + +@admin.register(Facilitator) +class FacilitatorAdmin(admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'activity') + + fieldsets = [ + ('Personal Information', {'fields': [ + 'first_name', 'last_name']}), + ('House Wars Information', { + 'fields': ['activity']}), + ] diff --git a/housewars/admin/House.py b/housewars/admin/House.py new file mode 100644 index 0000000..0fc5ae6 --- /dev/null +++ b/housewars/admin/House.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from housewars.models import House + + +@admin.register(House) +class HouseAdmin(admin.ModelAdmin): + list_display = ('name', 'points', 'current_points', 'total_points') + + @admin.display(description='Current Housewars Points') + def current_points(self, obj): + return obj.current_points + + @admin.display(description='Total Points') + def total_points(self, obj): + return obj.total_points diff --git a/housewars/admin/PointsEntry.py b/housewars/admin/PointsEntry.py new file mode 100644 index 0000000..d3c5838 --- /dev/null +++ b/housewars/admin/PointsEntry.py @@ -0,0 +1,32 @@ +from django.contrib import admin +from django.db.models import F + +from housewars.models import PointsEntry + + +class ValidatedPasswordFilter(admin.SimpleListFilter): + title = 'valid password' + + parameter_name = 'valid' + + def lookups(self, request, model_admin): + return (('True', True), ('False', False)) + + def queryset(self, request, queryset): + if (self.value() == 'True'): + return queryset.filter(password=F('activity__password')) + elif (self.value() == 'False'): + return queryset.exclude(password=F('activity__password')) + else: + return queryset + + +@admin.register(PointsEntry) +class PointsEntryAdmin(admin.ModelAdmin): + list_display = ('activity', 'award', 'house', 'validated') + + list_filter = ('activity', ValidatedPasswordFilter) + + @admin.display(description='Validated', boolean=True) + def validated(self, obj): + return obj.password == obj.activity.password diff --git a/housewars/admin/Teacher.py b/housewars/admin/Teacher.py new file mode 100644 index 0000000..2fb2b33 --- /dev/null +++ b/housewars/admin/Teacher.py @@ -0,0 +1,87 @@ +from django.contrib import admin, messages +from django.http import HttpResponse +from django.db.models import F +from django.utils.translation import ngettext + +import io +import zipfile + +from housewars.utils import load_pdf +from housewars.models import Teacher, UserEntry + + +@admin.register(Teacher) +class TeacherAdmin(admin.ModelAdmin): + fieldsets = [ + ('Personal Information', {'fields': [ + 'first_name', 'last_name']}), + ('House Wars Information', { + 'fields': ['grade', 'house', 'activity']}), + ] + + list_display = ('first_name', 'last_name', 'house', 'grade', 'activity') + + actions = ['export_mentor_to_pdf'] + + @admin.action(description='Remove activity from selected teachers') + def remove_activity(self, request, queryset): + queryset.update(activity=None) + + self.message_user(request, ngettext( + 'Removed activity for %d teacher.', + 'Removed activities for %d teachers.', + queryset.count(), + ) % queryset.count(), messages.SUCCESS) + + @admin.action(description='Remove house from selected teachers') + def remove_activity(self, request, queryset): + queryset.update(house=None) + + self.message_user(request, ngettext( + 'Removed house for %d teacher.', + 'Removed houses for %d teachers.', + queryset.count(), + ) % queryset.count(), messages.SUCCESS) + + @admin.action(description='Remove grade from selected teachers') + def remove_activity(self, request, queryset): + queryset.update(grade=None) + + self.message_user(request, ngettext( + 'Removed grade for %d teacher.', + 'Removed grades for %d teachers.', + queryset.count(), + ) % queryset.count(), messages.SUCCESS) + + @admin.action(description='Export mentor to pdf') + def export_mentor_to_pdf(self, request, queryset): + file_list = {} + + for teacher in queryset: + user_entries = UserEntry.objects.filter( + grade=teacher.grade, house=teacher.house) + + # Augment the queryset with extra data + user_entries = user_entries.annotate( + a1_room=F('activity1__room_number')).annotate(a2_room=F('activity2__room_number')) + + # Select headers that are to be unpacked + headers = ['first_name', 'last_name', 'grade', 'house', + 'activity1', 'a1_room', 'activity2', 'a2_room'] + + file = load_pdf(user_entries, headers, + f"{teacher.last_name}, {teacher.first_name}") + + file_list[f"{teacher.last_name}, {teacher.first_name}"] = file + + outfile = io.BytesIO() + with zipfile.ZipFile(outfile, 'w') as zf: + for name, file in file_list.items(): + zf.writestr(f"{name}.pdf", file.getvalue()) + outfile = outfile.getvalue() + + response = HttpResponse( + outfile, content_type='application/octet-stream') + response['Content-Disposition'] = 'attachment; filename=mentor.zip' + + return response diff --git a/housewars/admin/UserEntry.py b/housewars/admin/UserEntry.py new file mode 100644 index 0000000..efa99cd --- /dev/null +++ b/housewars/admin/UserEntry.py @@ -0,0 +1,103 @@ +from django.contrib import admin +from django.db.models import F, Value +from django.db.models.functions import Concat +from django.http import HttpResponse, FileResponse + +import csv + +from housewars.models import UserEntry +from housewars.utils import unpack_values, load_pdf + + +class MentorListFilter(admin.SimpleListFilter): + title = 'mentor teacher' + + parameter_name = 'mentor' + + def lookups(self, request, model_admin): + qs = model_admin.get_queryset(request).annotate(full_name=Concat(F( + 'house__teacher__first_name'), Value(' '), F('house__teacher__last_name'))).values_list('house__teacher', 'full_name').distinct() + + return qs + + def queryset(self, request, queryset): + if (self.value() == None): + return queryset + + return queryset.filter(house__teacher=self.value(), grade=F('house__teacher__grade')) + + +@admin.register(UserEntry) +class UserEntryAdmin(admin.ModelAdmin): + fieldsets = [ + ('Personal Information', {'fields': [ + 'first_name', 'last_name', 'email', 'grade']}), + ('House Wars Information', { + 'fields': ['house', 'activity1', 'activity2']}), + ] + + list_display = ('first_name', 'last_name', + 'house', 'mentor_teacher', 'activity1', 'activity1_teacher', 'activity2', 'activity2_teacher') + + list_filter = ['house', 'grade', MentorListFilter, 'activity1', + 'activity1__teacher', 'activity2', 'activity2__teacher'] + + search_fields = ['first_name', 'last_name'] + + actions = ['export_to_csv', 'export_to_pdf'] + + @admin.display(description='Activity 1 Teacher') + def activity1_teacher(self, obj): + if (obj.activity1 == None): + return None + + return obj.activity1.teacher + + @admin.display(description='Mentor Teacher') + def mentor_teacher(self, obj): + return obj.mentor + + @admin.display(description='Activity 2 Teacher') + def activity2_teacher(self, obj): + if (obj.activity2 == None): + return None + + return obj.activity2.teacher + + @admin.action(description='Export selected to csv') + def export_to_csv(self, request, queryset): + # Augment the querset with extra data + queryset = queryset.annotate( + a1_room=F('activity1__room_number')).annotate(a2_room=F('activity1__room_number')) + + # Initialize csv object writers + response = HttpResponse(content_type='text/csv', headers={ + 'Content-Disposition': 'attachment; filename="export.csv"'},) + writer = csv.writer(response, delimiter=";") + + # Select headers that are to be unpacked + headers = ['first_name', 'last_name', 'grade', 'house', + 'activity1', 'a1_room', 'activity2', 'a2_room'] + + # Unpack queryset data + data = unpack_values(queryset, headers) + + # Write data into tables + writer.writerows(data) + + return response + + @admin.action(description='Export selected to pdf') + def export_to_pdf(self, request, queryset): + # Augment the querset with extra data + queryset = queryset.annotate( + a1_room=F('activity1__room_number')).annotate(a2_room=F('activity2__room_number')) + + # Select headers that are to be unpacked + headers = ['first_name', 'last_name', 'grade', 'house', + 'activity1', 'a1_room', 'activity2', 'a2_room'] + + # Unpack queryset data + file = load_pdf(queryset, headers) + + return FileResponse(file, as_attachment=True, filename='export.pdf') diff --git a/housewars/admin/__init__.py b/housewars/admin/__init__.py new file mode 100644 index 0000000..f9926e7 --- /dev/null +++ b/housewars/admin/__init__.py @@ -0,0 +1,6 @@ +from .Activity import ActivityAdmin +from .Facilitator import Facilitator +from .House import House +from .PointsEntry import PointsEntry +from .Teacher import Teacher +from .UserEntry import UserEntry diff --git a/housewars/forms.py b/housewars/forms.py deleted file mode 100644 index f0e7fbd..0000000 --- a/housewars/forms.py +++ /dev/null @@ -1,98 +0,0 @@ -from django.forms import (CharField, ChoiceField, EmailField, EmailInput, Form, - ModelChoiceField, ModelForm, NumberInput, Select, - Textarea, TextInput, ValidationError) - -from .models import Activity, House, PointsEntry, UserEntry - - -class UserEntryForm(Form): - first_name = CharField(widget=TextInput(attrs={ - 'class': 'form-control', - 'id': 'first-name', - 'placeholder': 'First Name' - })) - last_name = CharField(widget=TextInput(attrs={ - 'class': "form-control", - 'id': 'last-name', - 'placeholder': 'Last Name' - })) - email = EmailField(widget=EmailInput(attrs={ - 'class': "form-control", - 'id': 'email', - 'placeholder': 'Email' - })) - grade = ChoiceField(choices=UserEntry.GradeChoices, widget=Select(attrs={ - 'class': "form-select", - 'id': 'grade', - 'placeholder': 'Grade' - })) - house = ModelChoiceField(House.objects.all(), widget=Select(attrs={ - 'class': "form-select", - 'id': 'house', - 'placeholder': 'House' - })) - - -class ActivityForm(Form): - activity1 = ModelChoiceField(Activity.objects.all(), widget=Select(attrs={ - 'class': "form-select", - 'id': 'activity1', - 'placeholder': 'Activity 1' - })) - activity2 = ModelChoiceField(Activity.objects.all(), required=False, widget=Select(attrs={ - 'class': "form-select", - 'id': 'activity2', - 'placeholder': 'Activity 2' - })) - - def clean(self): - data = self.cleaned_data - - activity1 = data.get('activity1') - activity2 = data.get('activity2') - - if activity1: - if activity2 and activity1.time + activity2.time != 60: - raise ValidationError( - "Please pick activities adding up to one hour.") - elif not activity2 and activity1.time != 60: - raise ValidationError( - "Please pick activities adding up to one hour.") - else: - super().clean() - else: - raise ValidationError("Please select an activity.") - - -class PointsEntryForm(ModelForm): - class Meta: - model = PointsEntry - fields = '__all__' - widgets = { - 'name': TextInput(attrs={ - 'class': 'form-control', - 'id': 'first-name', - 'placeholder': 'Full Name' - }), - 'activity': Select(attrs={ - 'class': "form-select", - 'id': 'house', - 'placeholder': 'Activity' - }), - 'house': Select(attrs={ - 'class': "form-select", - 'id': 'house', - 'placeholder': 'House' - }), - 'points': NumberInput(attrs={ - 'class': "form-control", - 'id': 'last-name', - 'placeholder': 'Number of Points' - }), - 'comment': Textarea(attrs={ - 'class': 'form-control', - 'id': 'first-name', - 'placeholder': 'Comments', - 'style': 'height:10vh;' - }) - } diff --git a/housewars/forms/ActivityForm.py b/housewars/forms/ActivityForm.py new file mode 100644 index 0000000..0e36cba --- /dev/null +++ b/housewars/forms/ActivityForm.py @@ -0,0 +1,35 @@ +from django.forms import Form, ModelChoiceField, Select, ValidationError + +from housewars.models import Activity + + +class ActivityForm(Form): + activity1 = ModelChoiceField(Activity.objects.all(), widget=Select(attrs={ + 'class': "form-select", + 'id': 'activity1', + 'placeholder': 'Activity 1' + })) + activity2 = ModelChoiceField(Activity.objects.all(), required=False, widget=Select(attrs={ + 'class': "form-select", + 'id': 'activity2', + 'placeholder': 'Activity 2' + })) + + def clean(self): + data = self.cleaned_data + + activity1 = data.get('activity1') + activity2 = data.get('activity2') + + # Verify that there are 60 minutes of activities selected + if activity1: + if activity2 and activity1.time + activity2.time != 60: + raise ValidationError( + "Please pick activities adding up to one hour.") + elif not activity2 and activity1.time != 60: + raise ValidationError( + "Please pick activities adding up to one hour.") + else: + super().clean() + else: + raise ValidationError("Please select an activity.") diff --git a/housewars/forms/FacilitatorForm.py b/housewars/forms/FacilitatorForm.py new file mode 100644 index 0000000..af999ca --- /dev/null +++ b/housewars/forms/FacilitatorForm.py @@ -0,0 +1,26 @@ +from django.forms import ModelForm, Select, TextInput + +from housewars.models import Facilitator + + +class FacilitatorForm(ModelForm): + class Meta: + model = Facilitator + fields = '__all__' + widgets = { + 'first_name': TextInput(attrs={ + 'class': "form-control", + 'id': 'first-name', + 'placeholder': 'First Name' + }), + 'last_name': TextInput(attrs={ + 'class': 'form-control', + 'id': 'last-name', + 'placeholder': 'Last Name', + }), + 'activity': Select(attrs={ + 'class': 'form-select', + 'id': 'activity', + 'placeholder': 'activity' + }), + } diff --git a/housewars/forms/PointsEntryForm.py b/housewars/forms/PointsEntryForm.py new file mode 100644 index 0000000..696ea77 --- /dev/null +++ b/housewars/forms/PointsEntryForm.py @@ -0,0 +1,26 @@ +from django.forms import ModelForm, Select, TextInput +from housewars.models import PointsEntry + + +class PointsEntryForm(ModelForm): + class Meta: + model = PointsEntry + fields = '__all__' + widgets = { + 'house': Select(attrs={ + 'class': "form-select", + 'id': 'house', + 'placeholder': 'House' + }), + 'password': TextInput(attrs={ + 'class': 'form-control', + 'id': 'first-name', + 'placeholder': 'Password', + 'type': 'password' + }) + } + + def __init__(self, *args, **kwargs): + super(PointsEntryForm, self).__init__(*args, **kwargs) + self.fields['activity'].widget.attrs.update({'class': 'form-select'}) + self.fields['award'].widget.attrs.update({'class': 'form-select'}) diff --git a/housewars/forms/UserEntryForm.py b/housewars/forms/UserEntryForm.py new file mode 100644 index 0000000..dcdfdf2 --- /dev/null +++ b/housewars/forms/UserEntryForm.py @@ -0,0 +1,45 @@ +from django.forms import (CharField, ChoiceField, EmailField, EmailInput, + Form, ModelChoiceField, Select, TextInput, ValidationError) + +from housewars.models import House, UserEntry + + +class UserEntryForm(Form): + first_name = CharField(widget=TextInput(attrs={ + 'class': 'form-control', + 'id': 'first-name', + 'placeholder': 'First' + })) + last_name = CharField(widget=TextInput(attrs={ + 'class': "form-control", + 'id': 'last-name', + 'placeholder': 'Last' + })) + email = EmailField(widget=EmailInput(attrs={ + 'class': "form-control", + 'id': 'email', + 'placeholder': 'Email' + })) + grade = ChoiceField(choices=UserEntry.GradeChoices, widget=Select(attrs={ + 'class': "form-select", + 'id': 'grade', + 'placeholder': 'Grade' + })) + house = ModelChoiceField(House.objects.all(), widget=Select(attrs={ + 'class': "form-select", + 'id': 'house', + 'placeholder': 'House' + })) + + def clean_email(self): + email = self.cleaned_data.get('email') + + # Verify that email domain matches + if ('@slps.org' not in email): + raise ValidationError("Please use your school email.") + # Verify that the email is unique + if UserEntry.objects.filter(email=email).exists(): + raise ValidationError( + "A signup with this email already exists, if you want to change your signups, please email ipatino3341@slps.org.") + + return email diff --git a/housewars/forms/__init__.py b/housewars/forms/__init__.py new file mode 100644 index 0000000..7a47de0 --- /dev/null +++ b/housewars/forms/__init__.py @@ -0,0 +1,4 @@ +from .ActivityForm import ActivityForm +from .FacilitatorForm import FacilitatorForm +from .PointsEntryForm import PointsEntryForm +from .UserEntryForm import UserEntryForm diff --git a/housewars/managers.py b/housewars/managers.py deleted file mode 100644 index 2cc516d..0000000 --- a/housewars/managers.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.db.models import Manager - - -class EntryManager(Manager): - def filterActivity1(self, act): - return self.filter(activity1=act) - - def filterActivity2(self, act): - return self.filter(activity2=act) - - -class HawkEntryManager(EntryManager): - def get_queryset(self): - return super().get_queryset().filter(house__name='Hawk') - - -class EagleEntryManager(EntryManager): - def get_queryset(self): - return super().get_queryset().filter(house__name='Eagle') - - -class GreatGreyEntryManager(EntryManager): - def get_queryset(self): - return super().get_queryset().filter(house__name='Great Grey') - - -class SnowyEntryManager(EntryManager): - def get_queryset(self): - return super().get_queryset().filter(house__name='Snowy') diff --git a/housewars/migrations/0001_initial.py b/housewars/migrations/0001_initial.py index ea16bd5..610b21f 100644 --- a/housewars/migrations/0001_initial.py +++ b/housewars/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 4.0.4 on 2022-05-16 23:29 +# Generated by Django 4.0.4 on 2022-09-03 16:15 from django.db import migrations, models import django.db.models.deletion +import smart_selects.db_fields class Migration(migrations.Migration): @@ -17,20 +18,84 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), - ('quota', models.IntegerField()), - ('time', models.IntegerField(choices=[(30, '30'), (60, '60')])), + ('default_quota', models.IntegerField(default=0)), + ('time', models.IntegerField(blank=True, choices=[(30, '30'), (60, '60')], null=True)), + ('password', models.CharField(blank=True, default=None, max_length=100, null=True)), + ], + options={ + 'verbose_name_plural': 'Activities', + }, + ), + migrations.CreateModel( + name='Award', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=15)), + ('points', models.IntegerField(default=0)), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.activity')), ], ), migrations.CreateModel( - name='Entry', + name='House', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('points', models.IntegerField(default=0)), + ], + ), + migrations.CreateModel( + name='UserEntry', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('first_name', models.CharField(max_length=100)), ('last_name', models.CharField(max_length=100)), - ('email', models.EmailField(max_length=100)), - ('grade', models.CharField(choices=[('9th', '9Th'), ('10th', '10Th'), ('11th', '11Th'), ('12th', '12Th')], max_length=5)), + ('email', models.EmailField(max_length=100, unique=True)), + ('grade', models.IntegerField(choices=[(9, '9th/Freshman'), (10, '10th/Sophomore'), (11, '11th/Junior'), (12, '12th/Senior')])), ('activity1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity1', to='housewars.activity')), - ('activity2', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity2', to='housewars.activity')), + ('activity2', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity2', to='housewars.activity')), + ('house', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.house')), ], + options={ + 'verbose_name_plural': 'User Entries', + }, + ), + migrations.CreateModel( + name='PointsEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(blank=True, max_length=15)), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='Activity', to='housewars.activity')), + ('award', smart_selects.db_fields.ChainedForeignKey(chained_field='activity', chained_model_field='activity', on_delete=django.db.models.deletion.CASCADE, to='housewars.award')), + ('house', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.house')), + ], + options={ + 'verbose_name_plural': 'Point Entries', + }, + ), + migrations.CreateModel( + name='Teacher', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('grade', models.IntegerField(choices=[(9, '9th/Freshman'), (10, '10th/Sophomore'), (11, '11th/Junior'), (12, '12th/Senior')])), + ('activity', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teacher', to='housewars.activity')), + ('house', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teacher', to='housewars.house')), + ], + options={ + 'unique_together': {('house', 'grade')}, + }, + ), + migrations.CreateModel( + name='Quota', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quota', models.IntegerField(default=0)), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.activity')), + ('house', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.house')), + ], + options={ + 'unique_together': {('activity', 'house')}, + }, ), ] diff --git a/housewars/migrations/0002_alter_activity_table.py b/housewars/migrations/0002_alter_pointsentry_unique_together.py similarity index 50% rename from housewars/migrations/0002_alter_activity_table.py rename to housewars/migrations/0002_alter_pointsentry_unique_together.py index 4ee88df..ae8af3f 100644 --- a/housewars/migrations/0002_alter_activity_table.py +++ b/housewars/migrations/0002_alter_pointsentry_unique_together.py @@ -1,4 +1,4 @@ -# Generated by Django 4.0.4 on 2022-05-16 23:37 +# Generated by Django 4.0.4 on 2022-09-03 16:59 from django.db import migrations @@ -10,8 +10,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterModelTable( - name='activity', - table='Activities', + migrations.AlterUniqueTogether( + name='pointsentry', + unique_together={('activity', 'award')}, ), ] diff --git a/housewars/migrations/0003_alter_activity_options_alter_entry_options_and_more.py b/housewars/migrations/0003_alter_activity_options_alter_entry_options_and_more.py deleted file mode 100644 index 5efbab9..0000000 --- a/housewars/migrations/0003_alter_activity_options_alter_entry_options_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-16 23:38 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0002_alter_activity_table'), - ] - - operations = [ - migrations.AlterModelOptions( - name='activity', - options={'verbose_name_plural': 'Activities'}, - ), - migrations.AlterModelOptions( - name='entry', - options={'verbose_name_plural': 'Entries'}, - ), - migrations.AlterModelTable( - name='activity', - table=None, - ), - ] diff --git a/housewars/migrations/0003_alter_activity_time.py b/housewars/migrations/0003_alter_activity_time.py new file mode 100644 index 0000000..6f849ec --- /dev/null +++ b/housewars/migrations/0003_alter_activity_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-09-03 19:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0002_alter_pointsentry_unique_together'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='time', + field=models.IntegerField(choices=[(30, '30'), (60, '60')]), + ), + ] diff --git a/housewars/migrations/0004_alter_entry_grade.py b/housewars/migrations/0004_alter_entry_grade.py deleted file mode 100644 index 052f93e..0000000 --- a/housewars/migrations/0004_alter_entry_grade.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 02:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0003_alter_activity_options_alter_entry_options_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='entry', - name='grade', - field=models.IntegerField(choices=[(9, '9th/Freshman'), (10, '10th/Sophomore'), (11, '11th/Junior'), (12, '12th/Senior')]), - ), - ] diff --git a/housewars/migrations/0004_alter_pointsentry_unique_together.py b/housewars/migrations/0004_alter_pointsentry_unique_together.py new file mode 100644 index 0000000..493f69a --- /dev/null +++ b/housewars/migrations/0004_alter_pointsentry_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.4 on 2022-09-05 18:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0003_alter_activity_time'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='pointsentry', + unique_together=set(), + ), + ] diff --git a/housewars/migrations/0005_activity_room_number_alter_teacher_grade.py b/housewars/migrations/0005_activity_room_number_alter_teacher_grade.py new file mode 100644 index 0000000..a0cea31 --- /dev/null +++ b/housewars/migrations/0005_activity_room_number_alter_teacher_grade.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.4 on 2022-09-07 12:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0004_alter_pointsentry_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='room_number', + field=models.IntegerField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='teacher', + name='grade', + field=models.IntegerField(blank=True, choices=[(9, '9th/Freshman'), (10, '10th/Sophomore'), (11, '11th/Junior'), (12, '12th/Senior')], default=None, null=True), + ), + ] diff --git a/housewars/migrations/0005_alter_entry_activity2.py b/housewars/migrations/0005_alter_entry_activity2.py deleted file mode 100644 index 343cb50..0000000 --- a/housewars/migrations/0005_alter_entry_activity2.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 12:43 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0004_alter_entry_grade'), - ] - - operations = [ - migrations.AlterField( - model_name='entry', - name='activity2', - field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity2', to='housewars.activity'), - ), - ] diff --git a/housewars/migrations/0005_alter_teacher_grade.py b/housewars/migrations/0005_alter_teacher_grade.py new file mode 100644 index 0000000..f5f61a8 --- /dev/null +++ b/housewars/migrations/0005_alter_teacher_grade.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-09-06 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0004_alter_pointsentry_unique_together'), + ] + + operations = [ + migrations.AlterField( + model_name='teacher', + name='grade', + field=models.IntegerField(blank=True, choices=[(9, '9th/Freshman'), (10, '10th/Sophomore'), (11, '11th/Junior'), (12, '12th/Senior')], default=None, null=True), + ), + ] diff --git a/housewars/migrations/0006_entry_house.py b/housewars/migrations/0006_entry_house.py deleted file mode 100644 index 0d3c4f8..0000000 --- a/housewars/migrations/0006_entry_house.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 13:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0005_alter_entry_activity2'), - ] - - operations = [ - migrations.AddField( - model_name='entry', - name='house', - field=models.CharField(choices=[('HAWK', 'Hawk'), ('GREATGREY', 'Great Grey'), ('EAGLE', 'Eagle'), ('SNOWY', 'Snowy')], default='HAWK', max_length=15), - preserve_default=False, - ), - ] diff --git a/housewars/migrations/0006_facilitator.py b/housewars/migrations/0006_facilitator.py new file mode 100644 index 0000000..df47ace --- /dev/null +++ b/housewars/migrations/0006_facilitator.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.4 on 2022-09-07 21:22 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0005_activity_room_number_alter_teacher_grade'), + ] + + operations = [ + migrations.CreateModel( + name='Facilitator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('activity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='housewars.activity')), + ], + ), + ] diff --git a/housewars/migrations/0007_alter_entry_activity2.py b/housewars/migrations/0007_alter_entry_activity2.py deleted file mode 100644 index 39ad63f..0000000 --- a/housewars/migrations/0007_alter_entry_activity2.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 14:28 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0006_entry_house'), - ] - - operations = [ - migrations.AlterField( - model_name='entry', - name='activity2', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity2', to='housewars.activity'), - ), - ] diff --git a/housewars/migrations/0007_alter_facilitator_activity.py b/housewars/migrations/0007_alter_facilitator_activity.py new file mode 100644 index 0000000..4b52993 --- /dev/null +++ b/housewars/migrations/0007_alter_facilitator_activity.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.4 on 2022-09-07 22:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0006_facilitator'), + ] + + operations = [ + migrations.AlterField( + model_name='facilitator', + name='activity', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.activity'), + ), + ] diff --git a/housewars/migrations/0008_activity_session1_signups_activity_session2_signups.py b/housewars/migrations/0008_activity_session1_signups_activity_session2_signups.py deleted file mode 100644 index 4cace67..0000000 --- a/housewars/migrations/0008_activity_session1_signups_activity_session2_signups.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 15:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0007_alter_entry_activity2'), - ] - - operations = [ - migrations.AddField( - model_name='activity', - name='session1_signups', - field=models.IntegerField(default=10), - preserve_default=False, - ), - migrations.AddField( - model_name='activity', - name='session2_signups', - field=models.IntegerField(default=10), - preserve_default=False, - ), - ] diff --git a/housewars/migrations/0008_merge_20220907_2259.py b/housewars/migrations/0008_merge_20220907_2259.py new file mode 100644 index 0000000..27d3a43 --- /dev/null +++ b/housewars/migrations/0008_merge_20220907_2259.py @@ -0,0 +1,14 @@ +# Generated by Django 4.0.4 on 2022-09-07 22:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0005_alter_teacher_grade'), + ('housewars', '0007_alter_facilitator_activity'), + ] + + operations = [ + ] diff --git a/housewars/migrations/0009_alter_activity_room_number.py b/housewars/migrations/0009_alter_activity_room_number.py new file mode 100644 index 0000000..f70a7de --- /dev/null +++ b/housewars/migrations/0009_alter_activity_room_number.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2023-05-16 03:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('housewars', '0008_merge_20220907_2259'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='room_number', + field=models.CharField(blank=True, default=None, max_length=100, null=True), + ), + ] diff --git a/housewars/migrations/0009_alter_activity_session1_signups_and_more.py b/housewars/migrations/0009_alter_activity_session1_signups_and_more.py deleted file mode 100644 index e281053..0000000 --- a/housewars/migrations/0009_alter_activity_session1_signups_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 15:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0008_activity_session1_signups_activity_session2_signups'), - ] - - operations = [ - migrations.AlterField( - model_name='activity', - name='session1_signups', - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name='activity', - name='session2_signups', - field=models.IntegerField(default=0), - ), - ] diff --git a/housewars/migrations/0010_remove_activity_session1_signups_and_more.py b/housewars/migrations/0010_remove_activity_session1_signups_and_more.py deleted file mode 100644 index d83a859..0000000 --- a/housewars/migrations/0010_remove_activity_session1_signups_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-17 22:36 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0009_alter_activity_session1_signups_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='activity', - name='session1_signups', - ), - migrations.RemoveField( - model_name='activity', - name='session2_signups', - ), - ] diff --git a/housewars/migrations/0011_house_alter_activity_managers_alter_entry_house.py b/housewars/migrations/0011_house_alter_activity_managers_alter_entry_house.py deleted file mode 100644 index 5a18c1c..0000000 --- a/housewars/migrations/0011_house_alter_activity_managers_alter_entry_house.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-18 22:10 - -from django.db import migrations, models -import django.db.models.deletion -import django.db.models.manager - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0010_remove_activity_session1_signups_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='House', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ], - ), - migrations.AlterModelManagers( - name='activity', - managers=[ - ('session1', django.db.models.manager.Manager()), - ], - ), - migrations.AlterField( - model_name='entry', - name='house', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.house'), - ), - ] diff --git a/housewars/migrations/0012_alter_activity_managers_alter_entry_house_and_more.py b/housewars/migrations/0012_alter_activity_managers_alter_entry_house_and_more.py deleted file mode 100644 index 9a8a288..0000000 --- a/housewars/migrations/0012_alter_activity_managers_alter_entry_house_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-19 00:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0011_house_alter_activity_managers_alter_entry_house'), - ] - - operations = [ - migrations.AlterModelManagers( - name='activity', - managers=[ - ], - ), - migrations.AlterField( - model_name='entry', - name='house', - field=models.CharField(choices=[('HAWK', 'Hawk'), ('GREATGREY', 'Great Grey'), ('EAGLE', 'Eagle'), ('SNOWY', 'Snowy')], max_length=15), - ), - migrations.DeleteModel( - name='House', - ), - ] diff --git a/housewars/migrations/0013_house_pointsentry_userentry_delete_entry.py b/housewars/migrations/0013_house_pointsentry_userentry_delete_entry.py deleted file mode 100644 index f3ab584..0000000 --- a/housewars/migrations/0013_house_pointsentry_userentry_delete_entry.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-21 18:15 - -from django.db import migrations, models -import django.db.models.deletion -import django.db.models.manager - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0012_alter_activity_managers_alter_entry_house_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='House', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('points', models.IntegerField()), - ], - ), - migrations.CreateModel( - name='PointsEntry', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('points', models.IntegerField()), - ('reason', models.TextField()), - ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.activity')), - ('house', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.house')), - ], - options={ - 'verbose_name_plural': 'Point Entries', - }, - ), - migrations.CreateModel( - name='UserEntry', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('first_name', models.CharField(max_length=100)), - ('last_name', models.CharField(max_length=100)), - ('email', models.EmailField(max_length=100)), - ('grade', models.IntegerField(choices=[(9, '9th/Freshman'), (10, '10th/Sophomore'), (11, '11th/Junior'), (12, '12th/Senior')])), - ('activity1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity1', to='housewars.activity')), - ('activity2', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity2', to='housewars.activity')), - ('house', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='housewars.house')), - ], - options={ - 'verbose_name_plural': 'User Entries', - }, - managers=[ - ('hawk', django.db.models.manager.Manager()), - ], - ), - migrations.DeleteModel( - name='Entry', - ), - ] diff --git a/housewars/migrations/0014_alter_pointsentry_managers.py b/housewars/migrations/0014_alter_pointsentry_managers.py deleted file mode 100644 index a133ec3..0000000 --- a/housewars/migrations/0014_alter_pointsentry_managers.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-21 18:17 - -from django.db import migrations -import django.db.models.manager - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0013_house_pointsentry_userentry_delete_entry'), - ] - - operations = [ - migrations.AlterModelManagers( - name='pointsentry', - managers=[ - ('hawk', django.db.models.manager.Manager()), - ], - ), - ] diff --git a/housewars/migrations/0015_alter_pointsentry_managers.py b/housewars/migrations/0015_alter_pointsentry_managers.py deleted file mode 100644 index bd05edc..0000000 --- a/housewars/migrations/0015_alter_pointsentry_managers.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-21 18:33 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0014_alter_pointsentry_managers'), - ] - - operations = [ - migrations.AlterModelManagers( - name='pointsentry', - managers=[ - ], - ), - ] diff --git a/housewars/migrations/0016_alter_userentry_managers_and_more.py b/housewars/migrations/0016_alter_userentry_managers_and_more.py deleted file mode 100644 index b5da7c9..0000000 --- a/housewars/migrations/0016_alter_userentry_managers_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-21 20:18 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0015_alter_pointsentry_managers'), - ] - - operations = [ - migrations.AlterModelManagers( - name='userentry', - managers=[ - ], - ), - migrations.RenameField( - model_name='pointsentry', - old_name='reason', - new_name='comment', - ), - ] diff --git a/housewars/migrations/0017_alter_house_points_alter_pointsentry_points.py b/housewars/migrations/0017_alter_house_points_alter_pointsentry_points.py deleted file mode 100644 index 961f8ec..0000000 --- a/housewars/migrations/0017_alter_house_points_alter_pointsentry_points.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-21 20:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('housewars', '0016_alter_userentry_managers_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='house', - name='points', - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name='pointsentry', - name='points', - field=models.IntegerField(default=0), - ), - ] diff --git a/housewars/models.py b/housewars/models.py deleted file mode 100644 index e5c0590..0000000 --- a/housewars/models.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.db import models - -from .managers import (EagleEntryManager, EntryManager, GreatGreyEntryManager, - HawkEntryManager, SnowyEntryManager) - - -class Activity(models.Model): - class Meta: - verbose_name_plural = "Activities" - - TimeslotChoices = [ - (30, '30'), - (60, '60') - ] - - name = models.CharField(max_length=100) - quota = models.IntegerField() - time = models.IntegerField(choices=TimeslotChoices) - - def __str__(self): - return f"{self.name} ({self.time})" - - -class House(models.Model): - name = models.CharField(max_length=100) - points = models.IntegerField(default=0) - - def __str__(self): - return f"{self.name}" - - -class UserEntry(models.Model): - class Meta: - verbose_name_plural = "User Entries" - - objects = EntryManager() - hawk = HawkEntryManager() - great_grey = GreatGreyEntryManager() - snowy = SnowyEntryManager() - eagle = EagleEntryManager() - - GradeChoices = ( - (9, '9th/Freshman'), - (10, '10th/Sophomore'), - (11, '11th/Junior'), - (12, '12th/Senior'), - ) - - first_name = models.CharField(max_length=100) - last_name = models.CharField(max_length=100) - email = models.EmailField(max_length=100) - grade = models.IntegerField(choices=GradeChoices) - house = models.ForeignKey( - House, on_delete=models.CASCADE) - activity1 = models.ForeignKey( - Activity, related_name='activity1', on_delete=models.CASCADE) - activity2 = models.ForeignKey( - Activity, related_name='activity2', on_delete=models.CASCADE, blank=True, null=True) - - def __str__(self): - return f"{self.first_name} {self.last_name}" - - -class PointsEntry(models.Model): - class Meta: - verbose_name_plural = "Point Entries" - - name = models.CharField(max_length=100) - activity = models.ForeignKey(Activity, on_delete=models.CASCADE) - house = models.ForeignKey(House, on_delete=models.CASCADE) - points = models.IntegerField(default=0) - comment = models.TextField(blank=False) diff --git a/housewars/models/Activity.py b/housewars/models/Activity.py new file mode 100644 index 0000000..4d6a28d --- /dev/null +++ b/housewars/models/Activity.py @@ -0,0 +1,26 @@ +from django.db import models + + +class Activity(models.Model): + class Meta: + verbose_name_plural = "Activities" + + TimeslotChoices = [ + (30, '30'), + (60, '60') + ] + + name = models.CharField(max_length=100) + default_quota = models.IntegerField(default=0) + time = models.IntegerField(choices=TimeslotChoices) + room_number = models.CharField( + max_length=100, blank=True, null=True, default=None) + password = models.CharField( + max_length=100, blank=True, null=True, default=None) + + def __str__(self): + return f"{self.name} - {self.time} min" + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/Award.py b/housewars/models/Award.py new file mode 100644 index 0000000..c2f952b --- /dev/null +++ b/housewars/models/Award.py @@ -0,0 +1,15 @@ +from django.db import models + + +class Award(models.Model): + activity = models.ForeignKey( + 'Activity', on_delete=models.CASCADE) + name = models.CharField(max_length=15) + points = models.IntegerField(default=0) + + def __str__(self): + return f"{self.name}" + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/Facilitator.py b/housewars/models/Facilitator.py new file mode 100644 index 0000000..17ffd75 --- /dev/null +++ b/housewars/models/Facilitator.py @@ -0,0 +1,13 @@ +from django.db import models +from . import Activity + + +class Facilitator(models.Model): + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + activity = models.ForeignKey( + Activity, on_delete=models.CASCADE) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/House.py b/housewars/models/House.py new file mode 100644 index 0000000..19e181a --- /dev/null +++ b/housewars/models/House.py @@ -0,0 +1,23 @@ +from django.db import models +from django.db.models import Sum +from django.db.models.functions import Coalesce + + +class House(models.Model): + name = models.CharField(max_length=100) + points = models.IntegerField(default=0) + + @property + def current_points(self): + return self.pointsentry_set.aggregate(sum=Coalesce(Sum('award__points'), 0)).get('sum') + + @property + def total_points(self): + return self.current_points + self.points + + def __str__(self): + return f"{self.name}" + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/PointsEntry.py b/housewars/models/PointsEntry.py new file mode 100644 index 0000000..6999f7c --- /dev/null +++ b/housewars/models/PointsEntry.py @@ -0,0 +1,22 @@ +from django.db.models import Model, ForeignKey, CharField, CASCADE, Q, F +from smart_selects.db_fields import ChainedForeignKey +from . import Award + + +class PointsEntry(Model): + class Meta: + verbose_name_plural = "Point Entries" + + activity = ForeignKey( + 'Activity', related_name='Activity', on_delete=CASCADE) + house = ForeignKey('House', on_delete=CASCADE) + award = ChainedForeignKey(Award, chained_field="activity", + chained_model_field="activity", on_delete=CASCADE) + password = CharField(max_length=15, blank=True) + + def __str__(self): + return f"{self.activity}" + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/Quota.py b/housewars/models/Quota.py new file mode 100644 index 0000000..5266275 --- /dev/null +++ b/housewars/models/Quota.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Quota(models.Model): + class Meta: + unique_together = ('activity', 'house') + + activity = models.ForeignKey('Activity', on_delete=models.CASCADE) + house = models.ForeignKey('House', on_delete=models.CASCADE) + quota = models.IntegerField(default=0) + + def __str__(self): + return f"{self.house}" + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/Teacher.py b/housewars/models/Teacher.py new file mode 100644 index 0000000..f09d013 --- /dev/null +++ b/housewars/models/Teacher.py @@ -0,0 +1,30 @@ +from django.db import models +from . import House, Activity + + +class Teacher(models.Model): + class Meta: + unique_together = ('house', 'grade') + + GradeChoices = ( + (9, '9th/Freshman'), + (10, '10th/Sophomore'), + (11, '11th/Junior'), + (12, '12th/Senior'), + ) + + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + house = models.ForeignKey( + House, related_name='teacher', default=None, blank=True, null=True, on_delete=models.SET_NULL) + grade = models.IntegerField( + choices=GradeChoices, default=None, blank=True, null=True) + activity = models.OneToOneField( + Activity, related_name='teacher', default=None, blank=True, null=True, on_delete=models.SET_NULL) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/UserEntry.py b/housewars/models/UserEntry.py new file mode 100644 index 0000000..b910a60 --- /dev/null +++ b/housewars/models/UserEntry.py @@ -0,0 +1,47 @@ +from django.db import models +from django.core.exceptions import ValidationError + +from . import House, Activity + + +class UserEntry(models.Model): + class Meta: + verbose_name_plural = "User Entries" + + GradeChoices = ( + (9, '9th/Freshman'), + (10, '10th/Sophomore'), + (11, '11th/Junior'), + (12, '12th/Senior'), + ) + + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + email = models.EmailField(max_length=100, unique=True) + grade = models.IntegerField(choices=GradeChoices) + house = models.ForeignKey( + House, on_delete=models.CASCADE) + activity1 = models.ForeignKey( + Activity, related_name='activity1', on_delete=models.CASCADE) + activity2 = models.ForeignKey( + Activity, related_name='activity2', on_delete=models.CASCADE, blank=True, null=True) + + @property + def mentor(self): + return self.house.teacher.get(grade=self.grade) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + def clean(self): + # Check to verify that there are 60 minutes of activities + if self.activity2 and self.activity1.time + self.activity2.time != 60: + raise ValidationError( + "Please pick activities adding up to one hour.") + elif not self.activity2 and self.activity1.time != 60: + raise ValidationError( + "Please pick activities adding up to one hour.") + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) diff --git a/housewars/models/__init__.py b/housewars/models/__init__.py new file mode 100644 index 0000000..49ec7f8 --- /dev/null +++ b/housewars/models/__init__.py @@ -0,0 +1,8 @@ +from .Activity import Activity +from .Award import Award +from .Facilitator import Facilitator +from .House import House +from .PointsEntry import PointsEntry +from .Quota import Quota +from .Teacher import Teacher +from .UserEntry import UserEntry diff --git a/housewars/static/css/main.css b/housewars/static/css/main.css index 8b95fe3..9b9e6c7 100644 --- a/housewars/static/css/main.css +++ b/housewars/static/css/main.css @@ -15,10 +15,19 @@ html, body { height: 100%; } +#banner { + background-color: #fff; +} + +#step-counter { + height: 10% +} + .steps-form { display: table; width: 100%; position: relative; + margin-bottom: 1rem; } .steps-form .steps-row { @@ -59,4 +68,10 @@ html, body { line-height: 1.428571429; border-radius: 15px; margin-top: 0; +} + +@media only screen and (max-width: 992px) { + #banner { + display: none; + } } \ No newline at end of file diff --git a/housewars/templates/housewars/activity_form.html b/housewars/templates/housewars/activity_form.html index c1b46c2..d4c69f2 100644 --- a/housewars/templates/housewars/activity_form.html +++ b/housewars/templates/housewars/activity_form.html @@ -1,54 +1,40 @@ {% extends 'base.html' %} {% load i18n %} -{% block title %} - House Wars - Signup -{% endblock %} - -{% block formtitle %} -

Signup

+{% block step-counter %} +
+
+
+ +

Info

+
+
+ +

Activities

+
+
+
{% endblock %} {% block content %} -
- {% csrf_token %} - {{ wizard.management_form }} + + {% csrf_token %} + {{ wizard.management_form }} -
-
-
- -

Student Information

-
-
- -

Activity Information

-
-
-
+
+ + {{ form.activity1 }} +
+
+ + {{ form.activity2 }} +
- {% if messages %} - {% for message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - - -
- - {{ form.activity1 }} -
-
- - {{ form.activity2 }} -
- -
- - -
-
+
+ + +
+ {% endblock %} \ No newline at end of file diff --git a/housewars/templates/housewars/entry_form.html b/housewars/templates/housewars/entry_form.html index c585ef4..3f829a5 100644 --- a/housewars/templates/housewars/entry_form.html +++ b/housewars/templates/housewars/entry_form.html @@ -1,64 +1,51 @@ {% extends 'base.html' %} {% load i18n %} -{% block title %} - House Wars - Signup -{% endblock %} - -{% block formtitle %} -

Signup

+{% block step-counter %} +
+
+
+ +

Student Info

+
+
+ +

Activity Signup

+
+
+
{% endblock %} {% block content %} -
- {% csrf_token %} - {{ wizard.management_form }} -
-
-
- -

Student Information

-
-
- -

Activity Information

-
-
-
+ + {% csrf_token %} + {{ wizard.management_form }} - {% if messages %} - {% for message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - - -
-
- {{ form.first_name }} -
-
- {{ form.last_name }} -
+ +
+
+ {{ form.first_name }}
-
- - {{ form.email }} +
+ {{ form.last_name }}
-
- - {{ form.grade }} -
-
- - {{ form.house }} -
- -
- -
- -{% endblock %} +
+
+ + {{ form.email }} +
+
+ + {{ form.grade }} +
+
+ + {{ form.house }} +
+ +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/housewars/templates/housewars/general_form.html b/housewars/templates/housewars/general_form.html new file mode 100644 index 0000000..f4ec623 --- /dev/null +++ b/housewars/templates/housewars/general_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block content %} +
+ {% csrf_token %} + {{ form.media.js }} + + {{ form.as_p }} + +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/housewars/templates/housewars/pointsentry_form.html b/housewars/templates/housewars/pointsentry_form.html deleted file mode 100644 index 98fc818..0000000 --- a/housewars/templates/housewars/pointsentry_form.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends 'base.html' %} - -{% block title %} - House Wars - Add House Points -{% endblock %} - -{% block formtitle %} -

Add House Points

-{% endblock %} - -{% block content %} -
- {% csrf_token %} - -
- - {{ form.name }} -
-
- - {{ form.activity }} -
-
- - {{ form.house }} -
-
- - {{ form.points }} -
-
- - {{ form.comment }} -
- - - {% if messages %} - {% for message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - -
- -
-
- -{% endblock %} \ No newline at end of file diff --git a/housewars/tests.py b/housewars/tests.py deleted file mode 100644 index 2e9cb5f..0000000 --- a/housewars/tests.py +++ /dev/null @@ -1 +0,0 @@ -from django.test import TestCase diff --git a/housewars/tests/__init__.py b/housewars/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/housewars/tests/forms/__init__.py b/housewars/tests/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/housewars/tests/forms/test_activity_form.py b/housewars/tests/forms/test_activity_form.py new file mode 100644 index 0000000..4477f7e --- /dev/null +++ b/housewars/tests/forms/test_activity_form.py @@ -0,0 +1,33 @@ +from django.test import TestCase + +from housewars.forms import ActivityForm +from housewars.models import Activity, House + + +class ActivityFormTest(TestCase): + def setUp(self): + self.activity30 = Activity.objects.create(name='Dodgeball', time=30) + self.activity60 = Activity.objects.create(name='Dodgeball', time=60) + + def test_valid_time(self): + form = ActivityForm(data={ + 'activity1': self.activity30.id, + 'activity2': self.activity30.id, + }) + + self.assertTrue(form.is_valid()) + + def test_invalid_time(self): + form = ActivityForm(data={ + 'activity1': self.activity60.id, + 'activity2': self.activity30.id, + }) + + self.assertFalse(form.is_valid()) + self.assertEquals(len(form.errors), 1) + + def test_no_data(self): + form = ActivityForm(data={}) + + self.assertFalse(form.is_valid()) + self.assertEquals(len(form.errors), 2) diff --git a/housewars/tests/forms/test_facilitator_form.py b/housewars/tests/forms/test_facilitator_form.py new file mode 100644 index 0000000..790bd6b --- /dev/null +++ b/housewars/tests/forms/test_facilitator_form.py @@ -0,0 +1,24 @@ +from django.test import TestCase + +from housewars.forms import FacilitatorForm +from housewars.models import Activity + + +class FacilitatorFormTest(TestCase): + def setUp(self): + self.activity = Activity.objects.create(name='Dodgeball', time=30) + + def test_valid_input(self): + form = FacilitatorForm(data={ + 'first_name': 'Test', + 'last_name': 'User', + 'activity': self.activity.id + }) + + self.assertTrue(form.is_valid()) + + def test_no_data(self): + form = FacilitatorForm(data={}) + + self.assertFalse(form.is_valid()) + self.assertEquals(len(form.errors), 3) diff --git a/housewars/tests/forms/test_points_entry_form.py b/housewars/tests/forms/test_points_entry_form.py new file mode 100644 index 0000000..0b474f8 --- /dev/null +++ b/housewars/tests/forms/test_points_entry_form.py @@ -0,0 +1,27 @@ +from django.test import TestCase + +from housewars.forms import PointsEntryForm +from housewars.models import Activity, House, Award + + +class PointsEntryFormTest(TestCase): + def setUp(self): + self.house = House.objects.create(name='Hawk') + self.activity = Activity.objects.create(name='Dodgeball', time=30) + self.activity2 = Activity.objects.create(name='Dodgeball', time=30) + self.award = Award.objects.create(name='1st', activity=self.activity) + + def test_valid_time(self): + form = PointsEntryForm(data={ + 'house': self.house.id, + 'activity': self.activity.id, + 'award': self.award.id + }) + + self.assertTrue(form.is_valid()) + + def test_no_data(self): + form = PointsEntryForm(data={}) + + self.assertFalse(form.is_valid()) + self.assertEquals(len(form.errors), 3) diff --git a/housewars/tests/forms/test_user_entry_form.py b/housewars/tests/forms/test_user_entry_form.py new file mode 100644 index 0000000..fa3bd5c --- /dev/null +++ b/housewars/tests/forms/test_user_entry_form.py @@ -0,0 +1,38 @@ +from django.test import TestCase + +from housewars.forms import UserEntryForm +from housewars.models import House + + +class UserEntryFormTest(TestCase): + def setUp(self): + self.house = House.objects.create(name='Hawk', points=30) + + def test_valid(self): + form = UserEntryForm(data={ + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'test.user@slps.org', + 'grade': 9, + 'house': self.house.id + }) + + self.assertTrue(form.is_valid()) + + def test_invalid_email(self): + form = UserEntryForm(data={ + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'test.user@gmail.com', + 'grade': 9, + 'house': self.house.id + }) + + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + + def test_no_data(self): + form = UserEntryForm(data={}) + + self.assertFalse(form.is_valid()) + self.assertEquals(len(form.errors), 5) diff --git a/housewars/tests/models/__init__.py b/housewars/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/housewars/tests/models/test_house.py b/housewars/tests/models/test_house.py new file mode 100644 index 0000000..f6608f6 --- /dev/null +++ b/housewars/tests/models/test_house.py @@ -0,0 +1,23 @@ +from django.test import TestCase + +from housewars.models import Activity, House, Award, PointsEntry + + +class HouseModelTest(TestCase): + def setUp(self): + self.house = House.objects.create(name='Hawk', points=30) + self.activity = Activity.objects.create(name='Dodgeball', time=30) + self.award = Award.objects.create( + activity=self.activity, name='1st', points=30) + + def test_current_points(self): + PointsEntry.objects.create( + activity=self.activity, house=self.house, award=self.award) + + self.assertEquals(self.house.current_points, 30) + + def test_total_points(self): + PointsEntry.objects.create( + activity=self.activity, house=self.house, award=self.award) + + self.assertEquals(self.house.total_points, 60) diff --git a/housewars/tests/models/test_user_entry.py b/housewars/tests/models/test_user_entry.py new file mode 100644 index 0000000..7103726 --- /dev/null +++ b/housewars/tests/models/test_user_entry.py @@ -0,0 +1,18 @@ +from django.test import TestCase, Client +from django.urls import reverse + +from housewars.models import Activity, House, Teacher, UserEntry + + +class UserEntryTest(TestCase): + def setUp(self): + self.client = Client() + self.house = House.objects.create(name='Hawk', points=30) + self.activity = Activity.objects.create(name='Dodgeball', time=30) + self.entry = UserEntry.objects.create( + first_name='Test', last_name='User', email='test.user@slps.org', grade=9, house=self.house, activity1=self.activity, activity2=self.activity) + self.teacher = Teacher.objects.create( + first_name='Test', last_name='Teacher', grade=9, house=self.house) + + def test_mentor(self): + self.assertEquals(self.entry.mentor, self.teacher) diff --git a/housewars/tests/test_urls.py b/housewars/tests/test_urls.py new file mode 100644 index 0000000..153275b --- /dev/null +++ b/housewars/tests/test_urls.py @@ -0,0 +1,14 @@ +from django.test import TestCase +from django.urls import reverse, resolve + +from housewars.views import EntryCreateView, PointsEntryCreateView + + +class TestUrls(TestCase): + def test_entry_create_is_resolved(self): + url = reverse('housewars:signup') + self.assertEquals(resolve(url).func.view_class, EntryCreateView) + + def test_points_entry_create_is_resolved(self): + url = reverse('housewars:add_points') + self.assertEquals(resolve(url).func.view_class, PointsEntryCreateView) diff --git a/housewars/tests/utils/__init__.py b/housewars/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/housewars/tests/utils/test_get_random_string.py b/housewars/tests/utils/test_get_random_string.py new file mode 100644 index 0000000..f166fad --- /dev/null +++ b/housewars/tests/utils/test_get_random_string.py @@ -0,0 +1,13 @@ +from django.test import TestCase + +from housewars.utils import get_random_string + + +class GetRandomStringTest(TestCase): + def test_string_of_length_8(self): + string = get_random_string(8) + self.assertEqual(len(string), 8) + + def test_string_of_length_6(self): + string = get_random_string(6) + self.assertEqual(len(string), 6) diff --git a/housewars/tests/utils/test_load_pdf.py b/housewars/tests/utils/test_load_pdf.py new file mode 100644 index 0000000..a01f1bf --- /dev/null +++ b/housewars/tests/utils/test_load_pdf.py @@ -0,0 +1,20 @@ +from django.test import TestCase + +import io + +from housewars.utils import load_pdf +from housewars.models import House + + +class UnpackValuesTest(TestCase): + def setUp(self): + House.objects.create(name="Hawk") + House.objects.create(name="Snowy") + + self.test_queryset = House.objects.all() + + def test_matching_object_instance(self): + headers = [field.name for field in House._meta.fields] + + self.assertIsInstance( + load_pdf(self.test_queryset, headers), io.BytesIO) diff --git a/housewars/tests/utils/test_unpack_values.py b/housewars/tests/utils/test_unpack_values.py new file mode 100644 index 0000000..0ea214f --- /dev/null +++ b/housewars/tests/utils/test_unpack_values.py @@ -0,0 +1,26 @@ +from django.test import TestCase + +from housewars.utils import unpack_values +from housewars.models import House + + +class UnpackValuesTest(TestCase): + def setUp(self): + House.objects.create(name="Hawk") + House.objects.create(name="Snowy") + + self.test_queryset = House.objects.all() + + def test_valid_row_headers(self): + headers = [field.name for field in House._meta.fields] + + values = unpack_values(self.test_queryset, headers) + self.assertEqual( + values[0], headers) + + def test_complete_values(self): + headers = [field.name for field in House._meta.fields] + values = unpack_values(self.test_queryset, headers) + queryset = list(self.test_queryset.values_list()) + for row in queryset: + self.assertIn(list(row), values) diff --git a/housewars/tests/views/__init__.py b/housewars/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/housewars/tests/views/test_facilitator_create.py b/housewars/tests/views/test_facilitator_create.py new file mode 100644 index 0000000..b8acde5 --- /dev/null +++ b/housewars/tests/views/test_facilitator_create.py @@ -0,0 +1,33 @@ +from django.test import TestCase, Client +from django.urls import reverse + +from housewars.models import Activity + + +class FacilitatorCreateViewTest(TestCase): + def setUp(self): + self.client = Client() + self.url = reverse('housewars:facilitator') + self.activity = Activity.objects.create(name='Dodgeball', time=30) + + def test_GET(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertTemplateUsed(response, 'housewars/general_form.html') + + def test_POST(self): + response = self.client.post(self.url, { + 'first_name': 'Test', + 'last_name': 'User', + 'activity': self.activity.id + }) + + self.assertEquals(response.status_code, 302) + self.assertEquals(self.activity.facilitator_set.count(), 1) + + def test_failed_POST(self): + response = self.client.post(self.url) + + self.assertEquals(response.status_code, 200) + self.assertEquals(self.activity.facilitator_set.count(), 0) diff --git a/housewars/tests/views/test_points_entry_create.py b/housewars/tests/views/test_points_entry_create.py new file mode 100644 index 0000000..f51b81f --- /dev/null +++ b/housewars/tests/views/test_points_entry_create.py @@ -0,0 +1,35 @@ +from django.test import TestCase, Client +from django.urls import reverse + +from housewars.models import Activity, House, Award + + +class PointsEntryCreateViewTest(TestCase): + def setUp(self): + self.client = Client() + self.url = reverse('housewars:add_points') + self.house = House.objects.create(name='Hawk') + self.activity = Activity.objects.create(name='Dodgeball', time=30) + self.award = Award.objects.create(activity=self.activity, name='1st') + + def test_GET(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertTemplateUsed(response, 'housewars/general_form.html') + + def test_POST(self): + response = self.client.post(self.url, { + 'house': self.house.id, + 'activity': self.activity.id, + 'award': self.award.id + }) + + self.assertEquals(response.status_code, 302) + self.assertEquals(self.house.pointsentry_set.count(), 1) + + def test_failed_POST(self): + response = self.client.post(self.url) + + self.assertEquals(response.status_code, 200) + self.assertEquals(self.house.pointsentry_set.count(), 0) diff --git a/housewars/tests/views/test_user_entry_create.py b/housewars/tests/views/test_user_entry_create.py new file mode 100644 index 0000000..09f70d0 --- /dev/null +++ b/housewars/tests/views/test_user_entry_create.py @@ -0,0 +1,62 @@ +import enum +from django.test import TestCase, Client +from django.urls import reverse + +from housewars.models import Activity, House + + +class UserEntryCreateViewTest(TestCase): + def setUp(self): + self.client = Client() + self.url = reverse('housewars:signup') + self.house = House.objects.create(name='Hawk') + self.activity = Activity.objects.create( + name='Dodgeball', time=30, default_quota=5) + self.full_activity = Activity.objects.create( + name='Dodgeball', time=30) + + def test_POST(self): + user_info_form = { + 'user-first_name': 'Test', + 'user-last_name': 'User', + 'user-email': 'asdfasdf@slps.org', + 'user-grade': 9, + 'user-house': self.house.id, + 'entry_create_view-current_step': 'user' + } + + activity_form = { + 'activity-activity1': self.activity.id, + 'activity-activity2': self.activity.id, + 'entry_create_view-current_step': 'activity' + } + + steps_data = [user_info_form, activity_form] + + for data in steps_data: + self.client.post(self.url, data) + + self.assertEquals(self.house.userentry_set.count(), 1) + + def test_full_activity_POST(self): + user_info_form = { + 'user-first_name': 'Test', + 'user-last_name': 'User', + 'user-email': 'asdfasdf@slps.org', + 'user-grade': 9, + 'user-house': self.house.id, + 'entry_create_view-current_step': 'user' + } + + activity_form = { + 'activity-activity1': self.full_activity.id, + 'activity-activity2': self.full_activity.id, + 'entry_create_view-current_step': 'activity' + } + + steps_data = [user_info_form, activity_form] + + for data in steps_data: + self.client.post(self.url, data) + + self.assertEquals(self.house.userentry_set.count(), 0) diff --git a/housewars/urls.py b/housewars/urls.py index 6c9ffd1..f07ee1c 100644 --- a/housewars/urls.py +++ b/housewars/urls.py @@ -21,5 +21,6 @@ from . import views app_name = 'housewars' urlpatterns = [ path('', views.EntryCreateView.as_view(), name='signup'), - path('points/', views.CreatePointsEntry.as_view(), name='add_points') + path('points/', views.PointsEntryCreateView.as_view(), name='add_points'), + path('facilitator/', views.FacilitatorCreateView.as_view(), name='facilitator') ] diff --git a/housewars/utils/__init__.py b/housewars/utils/__init__.py new file mode 100644 index 0000000..42a94af --- /dev/null +++ b/housewars/utils/__init__.py @@ -0,0 +1,3 @@ +from .get_random_string import get_random_string +from .unpack_values import unpack_values +from .load_pdf import load_pdf diff --git a/housewars/utils/get_random_string.py b/housewars/utils/get_random_string.py new file mode 100644 index 0000000..4919293 --- /dev/null +++ b/housewars/utils/get_random_string.py @@ -0,0 +1,14 @@ +import random +import string + + +def get_random_string(length: int) -> str: + """Generates a random string of length n + + :param length int: The desired length of the returned string + :return: A random string of length n + """ + + letters = string.ascii_lowercase + result_str = ''.join(random.choice(letters) for i in range(length)) + return result_str diff --git a/housewars/utils/load_pdf.py b/housewars/utils/load_pdf.py new file mode 100644 index 0000000..e544838 --- /dev/null +++ b/housewars/utils/load_pdf.py @@ -0,0 +1,44 @@ +from reportlab.platypus import Table, TableStyle, SimpleDocTemplate, Paragraph +from reportlab.lib.pagesizes import letter +from reportlab.lib.colors import black + +import io + +from housewars.utils import unpack_values + + +def load_pdf(data, headers, title=""): + """Loads data from queryset into a pdf file + + :param queryset: The queryset that will be unpacked + :param headers: A list of the desired headers of the queryset + :returns A bytestream of a pdf file + """ + + # Initialize pdf object + buffer = io.BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=letter) + elements = [] + + # Create the page title + title = Paragraph(title) + + # Load title into PDF file + elements.append(title) + + # Unpack queryset data + data = unpack_values(data, headers) + + # Load data into table and set styles + table = Table(data, repeatRows=1) + table.setStyle(TableStyle( + [('INNERGRID', (0, 0), (-1, -1), 0.25, black), ('BOX', (0, 0), (-1, -1), 0.25, black)])) + + # Load table into PDF file + elements.append(table) + + # Build document + doc.build(elements) + buffer.seek(0) + + return buffer diff --git a/housewars/utils/unpack_values.py b/housewars/utils/unpack_values.py new file mode 100644 index 0000000..9240e50 --- /dev/null +++ b/housewars/utils/unpack_values.py @@ -0,0 +1,24 @@ +from django.db.models import QuerySet +from typing import List + + +def unpack_values(queryset: QuerySet, headers: List[str]) -> List[List]: + """Unpacks a queryset into a 2D array + + :param queryset: The queryset that will be unpacked + :param headers: A list of the desired headers of the queryset + :returns A 2D array of headers, then rows + """ + + data = [] + data.append(headers) + for row in queryset: + values = [] + for field in headers: + value = getattr(row, field) + if value is None: + value = '' + values.append(value) + data.append(values) + + return data diff --git a/housewars/views.py b/housewars/views.py index a596d8b..f918aa0 100644 --- a/housewars/views.py +++ b/housewars/views.py @@ -1,12 +1,13 @@ from django.contrib import messages -from django.db.models import Count, F, Q +from django.db.models import Count, F, Q, Subquery, OuterRef +from django.db.models.functions import Coalesce from django.shortcuts import redirect from django.urls import reverse from django.views.generic import CreateView from formtools.wizard.views import SessionWizardView -from .forms import ActivityForm, PointsEntryForm, UserEntryForm -from .models import Activity, PointsEntry, UserEntry +from housewars.forms import ActivityForm, PointsEntryForm, UserEntryForm, FacilitatorForm +from housewars.models import Activity, Quota, PointsEntry, UserEntry, Facilitator FORMS = [("user", UserEntryForm), ("activity", ActivityForm)] @@ -21,45 +22,114 @@ class EntryCreateView(SessionWizardView): def get_template_names(self): return [TEMPLATES[self.steps.current]] - def post(self, *args, **kwargs): - form = self.get_form(data=self.request.POST, files=self.request.FILES) - - if not form.is_valid(): - if (form.errors.as_data().get('__all__')): - for error in form.errors.as_data().get('__all__'): - messages.error(self.request, error.messages[0]) - - return super().post(*args, **kwargs) - def get_form(self, step=None, data=None, files=None): form = super().get_form(step, data, files) if step == 'activity': cd = self.storage.get_step_data('user') - filtered1 = Activity.objects.annotate(count=Count( - 'activity1', filter=Q(activity1__house=cd.get('user-house')))).filter(quota__gt=F('count')) - filtered2 = Activity.objects.annotate(count=Count( - 'activity2', filter=Q(activity2__house=cd.get('user-house')))).filter(quota__gt=F('count'), time=30) + # Retrieve custom quotas for house activities + quota = Quota.objects.filter( + house=cd.get('user-house'), activity=OuterRef('id')).values('quota') + # Set quota to custom quota or default, then set count to the total signups for each activity. Filter where quota is less than house signups. + filtered1 = Activity.objects.annotate(final_quota=Coalesce(Subquery(quota[:1]), F('default_quota'))).annotate(count=Count( + 'activity1', filter=Q(activity1__house=cd.get('user-house')))).filter(final_quota__gt=F('count')) + + filtered2 = Activity.objects.annotate(final_quota=Coalesce(Subquery(quota[:1]), F('default_quota'))).annotate(count=Count( + 'activity2', filter=Q(activity2__house=cd.get('user-house')))).filter(final_quota__gt=F('count'), time=30) + + # Render filtered as choices in activity select step form.fields['activity1'].queryset = filtered1 form.fields['activity2'].queryset = filtered2 return form def done(self, form_list, **kwargs): - UserEntry.objects.create(**self.get_all_cleaned_data()) + cleaned_data = self.get_all_cleaned_data() + UserEntry.objects.create(**cleaned_data) + + activity1 = cleaned_data['activity1'] + activity2 = cleaned_data['activity2'] + + # Build the response string. + a1_string = f'You are in {activity1}' + activity1_room = activity1.room_number + + if (activity1_room != None): + a1_string += f' in {activity1_room}' + + if (hasattr(activity1, 'teacher')): + activity1_teacher = activity1.teacher + a1_string += f' with {activity1_teacher}.' + else: + a1_string += '.' + + a2_string = '' + if (activity2 != None): + a2_string = f'You are in {activity2}' + + activity2_room = activity2.room_number + if (activity2_room != None): + a2_string += f' in {activity2_room}' + if (hasattr(activity2, 'teacher')): + activity2_teacher = activity2.teacher.last_name + a2_string += f' with {activity2_teacher}.' + else: + a2_string += '.' + + messages.success(self.request, 'Your signup has been submitted.') + messages.success(self.request, a1_string) + messages.success(self.request, a2_string) - messages.success(self.request, 'Your entry has been submitted') return redirect('housewars:signup') def get_success_url(self): return reverse('housewars:signup') + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['title'] = 'Signup' + context['formtitle'] = 'House Wars Signup' + return context -class CreatePointsEntry(CreateView): + +class PointsEntryCreateView(CreateView): model = PointsEntry form_class = PointsEntryForm + template_name = 'housewars/general_form.html' + + def form_valid(self, form): + messages.success(self.request, 'Your points entry has been submitted.') + + return super().form_valid(form) def get_success_url(self): return reverse('housewars:add_points') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['title'] = 'Add Points' + context['formtitle'] = 'Add House Points' + return context + + +class FacilitatorCreateView(CreateView): + model = Facilitator + form_class = FacilitatorForm + template_name = 'housewars/general_form.html' + + def form_valid(self, form): + messages.success( + self.request, 'Your facilitator signup has been submitted.') + + return super().form_valid(form) + + def get_success_url(self): + return reverse('housewars:add_points') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['title'] = 'Facilitator' + context['formtitle'] = 'Facilitator Signup' + return context diff --git a/requirements.txt b/requirements.txt index 8ecc000..2576958 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/static/img/IMG_5817.JPG b/static/img/IMG_5817.JPG new file mode 100644 index 0000000..6c95eda Binary files /dev/null and b/static/img/IMG_5817.JPG differ diff --git a/static/img/IMG_5818.JPG b/static/img/IMG_5818.JPG new file mode 100644 index 0000000..128ab5c Binary files /dev/null and b/static/img/IMG_5818.JPG differ diff --git a/static/img/IMG_5819.JPG b/static/img/IMG_5819.JPG new file mode 100644 index 0000000..5cd519f Binary files /dev/null and b/static/img/IMG_5819.JPG differ diff --git a/static/img/IMG_5821.JPG b/static/img/IMG_5821.JPG new file mode 100644 index 0000000..f518a50 Binary files /dev/null and b/static/img/IMG_5821.JPG differ diff --git a/static/img/IMG_5823.JPG b/static/img/IMG_5823.JPG new file mode 100644 index 0000000..67d3682 Binary files /dev/null and b/static/img/IMG_5823.JPG differ diff --git a/static/img/IMG_5826.JPG b/static/img/IMG_5826.JPG new file mode 100644 index 0000000..b6ed727 Binary files /dev/null and b/static/img/IMG_5826.JPG differ diff --git a/static/img/IMG_5849.JPG b/static/img/IMG_5849.JPG new file mode 100644 index 0000000..edf1387 Binary files /dev/null and b/static/img/IMG_5849.JPG differ diff --git a/static/img/IMG_5856.JPG b/static/img/IMG_5856.JPG new file mode 100644 index 0000000..61e0e2e Binary files /dev/null and b/static/img/IMG_5856.JPG differ diff --git a/static/jquery-3.6.1.min.js b/static/jquery-3.6.1.min.js new file mode 100644 index 0000000..2c69bc9 --- /dev/null +++ b/static/jquery-3.6.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 - House Wars Admin Page + House Wars Administration {% endblock %} diff --git a/templates/base.html b/templates/base.html index d93e526..54e7cdb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,35 +1,98 @@ {% load static %} - - - - - - - - {% block title %} - House Wars - {% endblock %} - - -
-
-
- {% block formtitle %} -

House Wars

- {% endblock %} - - {% block content %}{% endblock %} + + + + + + + + + + + + + + + + {% block title %} + + {% if title %}{{ title }}{% endif %} | House Wars + + {% endblock %} + + + +
+
+
+
+ {% block step-counter %}{% endblock %} +
+ + + + + {% block content %}{% endblock %} + + +
+ {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} + {% endfor %} + + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + {% endif %}
-
- - - + +
+
+ + + \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_facilitator.py b/tests/test_facilitator.py new file mode 100644 index 0000000..ca44a12 --- /dev/null +++ b/tests/test_facilitator.py @@ -0,0 +1,50 @@ +from django.urls import reverse +from django.contrib.staticfiles.testing import StaticLiveServerTestCase + +from selenium.webdriver import Chrome, ChromeOptions +from selenium.webdriver.common.by import By +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.support.select import Select +from selenium.webdriver.support.expected_conditions import visibility_of +from webdriver_manager.chrome import ChromeDriverManager + +from housewars.models import Activity + + +class FacilitatorEntry(StaticLiveServerTestCase): + def setUp(self): + # Headless chrome driver test setup + options = ChromeOptions() + options.add_argument('--headless') + options.add_argument('--start-maximized') + options.add_argument('--disable-gpu') + self.browser = Chrome( + ChromeDriverManager().install(), chrome_options=options) + self.url = self.live_server_url + reverse('housewars:facilitator') + + # Initial data setup + self.activity = Activity.objects.create( + name='Dodgeball', time=30) + + def tearDown(self): + self.browser.close() + + def test_form_submission(self): + self.browser.get(self.url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + Select(self.browser.find_element(By.ID, 'activity') + ).select_by_visible_text('Dodgeball - 30 min') + self.browser.find_element(By.ID, 'submit').click() + + # Assert that signup is successful + success_text = self.browser.find_element( + By.CSS_SELECTOR, 'div.alert.alert-success').text + + self.assertEquals( + success_text, 'Your facilitator signup has been submitted.') diff --git a/tests/test_points_entry_create.py b/tests/test_points_entry_create.py new file mode 100644 index 0000000..a39576b --- /dev/null +++ b/tests/test_points_entry_create.py @@ -0,0 +1,65 @@ +from django.urls import reverse +from django.contrib.staticfiles.testing import StaticLiveServerTestCase + +from selenium.webdriver import Chrome, ChromeOptions +from selenium.webdriver.common.by import By +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.support.select import Select +from selenium.webdriver.support.expected_conditions import visibility_of +from webdriver_manager.chrome import ChromeDriverManager + +from housewars.models import House, Activity, Award + +import time + + +class PointsEntryCreatePageTest(StaticLiveServerTestCase): + def setUp(self): + # Headless chrome driver test setup + options = ChromeOptions() + options.add_argument('--headless') + options.add_argument('--start-maximized') + options.add_argument('--disable-gpu') + self.browser = Chrome( + ChromeDriverManager().install(), chrome_options=options) + self.url = self.live_server_url + reverse('housewars:add_points') + + # Initial data setup + self.house = House.objects.create(name='Hawk') + self.activity = Activity.objects.create(name='Dodgeball', time=30) + self.award = Award.objects.create(name='1st', activity=self.activity) + + def tearDown(self): + self.browser.close() + + def test_user_form_submission(self): + self.browser.get(self.url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until(visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs + Select(self.browser.find_element(By.ID, 'id_activity')).select_by_visible_text('Dodgeball - 30 min') + Select(self.browser.find_element(By.ID, 'house')).select_by_visible_text('Hawk') + + time.sleep(0.25) + + Select(self.browser.find_element(By.ID, 'id_award')).select_by_visible_text('1st') + self.browser.find_element(By.ID, 'submit').click() + + # Verify that success message is shown + success_text = self.browser.find_element( + By.CSS_SELECTOR, 'div.alert.alert-success').text + self.assertEquals( + success_text, 'Your points entry has been submitted.') + + def test_no_awards_on_default(self): + self.browser.get(self.url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Verify that only null option is in options + award_select = Select(self.browser.find_element(By.ID, 'id_award')) + self.assertEquals(len(award_select.options), 1) diff --git a/tests/test_user_entry_create.py b/tests/test_user_entry_create.py new file mode 100644 index 0000000..3491203 --- /dev/null +++ b/tests/test_user_entry_create.py @@ -0,0 +1,231 @@ +from django.urls import reverse +from django.contrib.staticfiles.testing import StaticLiveServerTestCase + +from selenium.webdriver import Chrome, ChromeOptions +from selenium.webdriver.common.by import By +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.support.select import Select +from selenium.webdriver.support.expected_conditions import visibility_of +from webdriver_manager.chrome import ChromeDriverManager + +from housewars.models import House, Activity, UserEntry + + +class UserEntryCreatePageTest(StaticLiveServerTestCase): + def setUp(self): + # Headless chrome driver test setup + options = ChromeOptions() + options.add_argument('--headless') + options.add_argument('--start-maximized') + options.add_argument('--disable-gpu') + self.browser = Chrome( + ChromeDriverManager().install(), options=options) + self.url = self.live_server_url + reverse('housewars:signup') + + # Initial data setup + self.house = House.objects.create(name='Hawk') + self.activity30 = Activity.objects.create( + name='Dodgeball', time=30, default_quota=5) + self.activity60 = Activity.objects.create( + name='Volleyball', time=60, default_quota=5) + self.hidden = Activity.objects.create( + name='Hidden', time=30, default_quota=1) + + def tearDown(self): + self.browser.close() + + def test_valid_form_submission(self): + self.browser.get(self.url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + self.browser.find_element( + By.ID, 'email').send_keys('test.user@slps.org') + Select(self.browser.find_element(By.ID, 'grade') + ).select_by_visible_text('9th/Freshman') + Select(self.browser.find_element(By.ID, 'house') + ).select_by_visible_text('Hawk') + self.browser.find_element(By.ID, 'submit').click() + + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'activity1'))) + + Select(self.browser.find_element(By.ID, 'activity1') + ).select_by_visible_text('Dodgeball - 30 min') + Select(self.browser.find_element(By.ID, 'activity2') + ).select_by_visible_text('Dodgeball - 30 min') + + self.browser.find_element(By.ID, 'submit').click() + + # Verify signup is successful + success_text = self.browser.find_element( + By.CSS_SELECTOR, 'div.alert.alert-success').text + + self.assertEquals(success_text, 'Your signup has been submitted.') + + # Verify that activity detail cards are present + activity_confirm = self.browser.find_elements( + By.CSS_SELECTOR, 'div.alert.alert-success') + + self.assertEquals(len(activity_confirm), 3) + + def test_invalid_email(self): + self.browser.get(self.live_server_url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs except email + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + self.browser.find_element( + By.ID, 'email').send_keys('test.user@gmail.com') + Select(self.browser.find_element(By.ID, 'grade') + ).select_by_visible_text('9th/Freshman') + Select(self.browser.find_element(By.ID, 'house') + ).select_by_visible_text('Hawk') + self.browser.find_element(By.ID, 'submit').click() + + # Verify that there is a domain error on submit + error_text = self.browser.find_element( + By.CSS_SELECTOR, 'div.alert.alert-danger').text + self.assertEquals(error_text, 'Please use your school email.') + + def test_used_email(self): + UserEntry.objects.create(first_name='Test', last_name='User', email='test.user@slps.org', grade=9, house=self.house, + activity1=self.activity30, activity2=self.activity30) + + self.browser.get(self.live_server_url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs and duplicate email + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + self.browser.find_element( + By.ID, 'email').send_keys('test.user@slps.org') + Select(self.browser.find_element(By.ID, 'grade') + ).select_by_visible_text('9th/Freshman') + Select(self.browser.find_element(By.ID, 'house') + ).select_by_visible_text('Hawk') + self.browser.find_element(By.ID, 'submit').click() + + # Verify that duplicate error shows up on submit + error_text = self.browser.find_element( + By.CSS_SELECTOR, 'div.alert.alert-danger').text + self.assertEquals( + error_text, 'A signup with this email already exists, if you want to change your signups, please email ipatino3341@slps.org.') + + def test_invalid_activities_are_not_visible(self): + UserEntry.objects.create(first_name='Test', last_name='User', email='user.exist@slps.org', grade=9, house=self.house, + activity1=self.hidden, activity2=self.activity30) + + self.browser.get(self.live_server_url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + self.browser.find_element( + By.ID, 'email').send_keys('test.user@slps.org') + Select(self.browser.find_element(By.ID, 'grade') + ).select_by_visible_text('9th/Freshman') + Select(self.browser.find_element(By.ID, 'house') + ).select_by_visible_text('Hawk') + self.browser.find_element(By.ID, 'submit').click() + + # Wait until browser loads next step of form before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'activity1'))) + + activity1 = Select(self.browser.find_element(By.ID, 'activity1')) + activity2 = Select(self.browser.find_element(By.ID, 'activity2')) + + # Assert that full activity is not shown + self.assertRaises(NoSuchElementException, + activity1.select_by_visible_text, 'Hidden - 30 min') + # Asser that 60 minute activity is not shown in second slot + self.assertRaises(NoSuchElementException, + activity2.select_by_visible_text, 'Volleyball - 60 min') + + # Assert that valid activities are still shown in slot + activity1.select_by_visible_text('Volleyball - 60 min') + activity2.select_by_visible_text('Hidden - 30 min') + + def test_full_activity_shows_for_other_houses(self): + House.objects.create(name='Snowy') + UserEntry.objects.create(first_name='Test', last_name='User', email='user.exist@slps.org', grade=9, house=self.house, + activity1=self.hidden, activity2=self.hidden) + + self.browser.get(self.url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs and separate house + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + self.browser.find_element( + By.ID, 'email').send_keys('test.user@slps.org') + Select(self.browser.find_element(By.ID, 'grade') + ).select_by_visible_text('9th/Freshman') + Select(self.browser.find_element(By.ID, 'house') + ).select_by_visible_text('Snowy') + self.browser.find_element(By.ID, 'submit').click() + + # Wait until browser loads next form step before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'activity1'))) + + # Verify that full activity is still showing for other house + activity1 = Select(self.browser.find_element(By.ID, 'activity1')) + activity2 = Select(self.browser.find_element(By.ID, 'activity2')) + activity1.select_by_visible_text('Hidden - 30 min') + activity2.select_by_visible_text('Hidden - 30 min') + + def test_activities_gt_allowed_time(self): + self.browser.get(self.url) + + # Wait until browser loads page before continuing + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'header'))) + + # Fill in form with valid inputs + self.browser.find_element(By.ID, 'first-name').send_keys('Test') + self.browser.find_element(By.ID, 'last-name').send_keys('User') + self.browser.find_element( + By.ID, 'email').send_keys('test.user@slps.org') + Select(self.browser.find_element(By.ID, 'grade') + ).select_by_visible_text('9th/Freshman') + Select(self.browser.find_element(By.ID, 'house') + ).select_by_visible_text('Hawk') + self.browser.find_element(By.ID, 'submit').click() + + WebDriverWait(self.browser, timeout=5).until( + visibility_of(self.browser.find_element(By.ID, 'activity1'))) + + Select(self.browser.find_element(By.ID, 'activity1') + ).select_by_visible_text('Volleyball - 60 min') + Select(self.browser.find_element(By.ID, 'activity2') + ).select_by_visible_text('Dodgeball - 30 min') + + self.browser.find_element(By.ID, 'submit').click() + + # Verify error + error_text = self.browser.find_element( + By.CSS_SELECTOR, 'div.alert.alert-danger').text + self.assertEquals( + error_text, 'Please pick activities adding up to one hour.')