diff --git a/.ci-setup/pdfGen/crontab b/.ci-setup/pdfGen/crontab new file mode 100644 index 000000000..9d2f3d020 --- /dev/null +++ b/.ci-setup/pdfGen/crontab @@ -0,0 +1,8 @@ +SHELL=/bin/bash +BASH_ENV=/container.env +PATH=/tmp/texlive/bin/x86_64-linux:/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bundle/bin + +10,15,20,25,30,35,40,45,50,55 * * * * /doubtfire/lib/shell/generate_pdfs.sh +0 5 * * 1,3,5 /doubtfire/lib/shell/check_plagiarism.sh +0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh +0 1 * * * /doubtfire/lib/shell/sync_enrolments.sh diff --git a/.ci-setup/pdfGen/entry_point.sh b/.ci-setup/pdfGen/entry_point.sh new file mode 100755 index 000000000..b36221fd7 --- /dev/null +++ b/.ci-setup/pdfGen/entry_point.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Start the run once job. +echo "Docker container has been started" + +# Save the docker user environment +declare -p | grep -Ev 'BASHOPTS|BASH_VERSINFO|EUID|PPID|SHELLOPTS|UID' > /container.env +cat /container.env + +# Ensure log is present +touch /var/log/cron.log + +sleep 1 + +# Setup crontab - clear then load with file +crontab -r +crontab /etc/cron.d/container_cronjob + +echo "RESET CRONTAB" >> /var/log/cron.log + +# Ensure mail settings are accessible only by root +chown root:root /etc/msmtprc +chmod 600 /etc/msmtprc + +# Run cron and follow log +chmod 644 /etc/cron.d/container_cronjob && cron -f && tail -f /var/log/cron.log > /proc/1/fd/1 2>/proc/1/fd/2 diff --git a/.ci-setup/pdfGen/msmtprc b/.ci-setup/pdfGen/msmtprc new file mode 100644 index 000000000..d3ae41eb6 --- /dev/null +++ b/.ci-setup/pdfGen/msmtprc @@ -0,0 +1,11 @@ +account default +host (your_mail_server) +port 587 +tls on +tls_starttls on +auth on +user (your_mail_username) +password (your_mail_password) +auto_from off +from (your_mail_address) +logfile /doubtfire/log/msmtp diff --git a/.ci-setup/texlive-install.sh b/.ci-setup/texlive-install.sh new file mode 100755 index 000000000..7255c57ba --- /dev/null +++ b/.ci-setup/texlive-install.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'` +APP_PATH=`cd "$APP_PATH"; pwd` + +# See if there is a cached version of TL available +export PATH=/tmp/texlive/bin/x86_64-linux:$PATH +if ! command -v lualatex > /dev/null; then + # Obtain TeX Live + wget http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz + tar -xzf install-tl-unx.tar.gz + cd install-tl-20* + + echo "Installing using profile:" + cat ${APP_PATH}/texlive.profile + + echo + + # Install a full texlive system + ./install-tl --profile="${APP_PATH}/texlive.profile" + + tlmgr install fontawesome luatextra luacode minted fvextra catchfile xstring framed lastpage + + cd .. + + # Keep no backups (not required, simply makes cache bigger) + tlmgr option -- autobackup 0 +fi diff --git a/.ci-setup/texlive.profile b/.ci-setup/texlive.profile new file mode 100644 index 000000000..119811e25 --- /dev/null +++ b/.ci-setup/texlive.profile @@ -0,0 +1,11 @@ +selected_scheme scheme-small +TEXDIR /tmp/texlive +TEXMFCONFIG ~/.texlive/texmf-config +TEXMFHOME ~/texmf +TEXMFLOCAL /tmp/texlive/texmf-local +TEXMFSYSCONFIG /tmp/texlive/texmf-config +TEXMFSYSVAR /tmp/texlive/texmf-var +TEXMFVAR ~/.texlive/texmf-var +option_doc 0 +option_src 0 +instopt_write18_restricted 1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d8fd5e2e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +Dockerfile +.git +build +dist +node_modules +vendor +student-work diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..7f8ee5bde --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true diff --git a/.git-hooks b/.git-hooks deleted file mode 160000 index 42d9af07a..000000000 --- a/.git-hooks +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 42d9af07aff4061f53e8215de85cbc50b33a548f diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 000000000..ca831d475 --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,86 @@ +name: create-doubtfire-deployment +on: + push: + tags: + - 'v*' + branches: + - '*.x' + - 'development' + - 'main' + deployment: + workflow_dispatch: +jobs: + docker-api-server: + if: github.repository_owner == 'doubtfire-lms' + environment: doubtfire + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + if: github.event_name != 'pull_request' + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Setup meta for api server + id: docker_meta + uses: docker/metadata-action@v3 + with: + images: lmsdoubtfire/apiServer + tags: | + type=ref,event=tag + type=ref,event=branch + type=semver,pattern=prod-{{version}} + type=semver,pattern=prod-{{major}}.{{minor}} + type=semver,pattern=prod-{{major}} + - name: Build and push api server + id: docker_build + uses: docker/build-push-action@v2 + with: + file: deployApi.Dockerfile + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} + docker-app-server: + if: github.repository_owner == 'doubtfire-lms' + environment: doubtfire + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + if: github.event_name != 'pull_request' + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Setup meta for app server + id: docker_meta + uses: docker/metadata-action@v3 + with: + images: lmsdoubtfire/appServer + tags: | + type=ref,event=tag + type=ref,event=branch + type=semver,pattern=prod-{{version}} + type=semver,pattern=prod-{{major}}.{{minor}} + type=semver,pattern=prod-{{major}} + - name: Build and push app server + id: docker_build + uses: docker/build-push-action@v2 + with: + file: deployAppSvr.Dockerfile + context: . + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + push: ${{ github.event_name != 'pull_request' }} + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 000000000..0f37df429 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,30 @@ +name: test-doubtfire-api +on: [push,pull_request] +jobs: + run-unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Build base Doubtfire image + uses: docker/build-push-action@v2 + with: + context: . + push: false + load: true + tags: doubtfire-core:local + - name: Create database and run rake tests + uses: addnab/docker-run-action@v3 + with: + image: doubtfire-core:local + options: -v ${{ github.workspace }}:/doubtfire -e RAILS_ENV=test -e DF_STUDENT_WORK_DIR=/student-work -e DF_INSTITUTION_HOST=http://localhost:3000 -e DF_INSTITUTION_PRODUCT_NAME=OnTrack -e DF_SECRET_KEY_BASE='test-secret-key-test-secret-key!' -e DF_SECRET_KEY_ATTR='test-secret-key-test-secret-key!' -e DF_SECRET_KEY_DEVISE='test-secret-key-test-secret-key!' -e DF_TEST_DB_ADAPTER=mysql2 -e DF_TEST_DB_HOST=localhost -e DF_TEST_DB_DATABASE=doubtfire-test -e DF_TEST_DB_USERNAME=dfire -e DF_TEST_DB_PASSWORD=pwd -e OVERSEER_ENABLED=0 -e DF_ENCRYPTION_PRIMARY_KEY=AMLOMYA5GV8B4fTK3VKMhVGn8WdvUW8g -e DF_ENCRYPTION_DETERMINISTIC_KEY=anlmuJ6cB3bN3biXRbYvmPsC5ALPFqGG -e DF_ENCRYPTION_KEY_DERIVATION_SALT=hzPR8D4qpOnAg7VeAhkhWw6JmmzKJB10 + run: | + echo "Install mariadb" + apt -y install mariadb-server mariadb-client + echo "Run mariadb" + service mysql start + mysql -uroot -e "CREATE USER 'dfire'@'localhost' IDENTIFIED BY 'pwd'; GRANT ALL PRIVILEGES ON *.* TO 'dfire'@'localhost'; FLUSH PRIVILEGES;" + echo "Setting up database" + bundle exec rake db:populate + echo "Running rake tests" + TERM=xterm bundle exec rails test diff --git a/.gitignore b/.gitignore index b1297c4bd..38964339b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,14 @@ spec/reports config/initializers/pdfkit.rb config/ldap.yml student_work/ +student-work/ .DS_Store .env .env* + +# RubyMine files +.idea/ +.byebug_history +coverage/ +.vscode diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index ec4bf0314..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule ".git-hooks"] - path = .git-hooks - url = https://github.com/dstil/git-hooks diff --git a/.rubocop.yml b/.rubocop.yml index 6861e20c0..6c1f3d4f3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,20 +3,9 @@ AllCops: - db/**/* - vendor/**/* - spec/**/* - -Rails: - Enabled: true - -Rails/HttpPositionalArguments: - Exclude: - test/**/* -Rails/DynamicFindBy: - Whitelist: - - find_by_auth_token - - find_by_user - -Metrics/LineLength: +Layout/LineLength: Max: 120 # To make it possible to copy or click on URIs in the code, we allow lines # containing a URI to be longer than Max. @@ -25,18 +14,3 @@ Metrics/LineLength: URISchemes: - http - https - -Metrics/MethodLength: - Exclude: - - db/migrate/**/* - -Style/BlockComments: - Exclude: - - spec/spec_helper.rb - -Style/Documentation: - Description: 'Document classes and non-namespace modules.' - Enabled: false - -Style/SpaceInsideBrackets: - Enabled: false diff --git a/.ruby-version b/.ruby-version index 2bf1c1ccf..e261122d5 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.1 +2.6.7 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6a9ca1830 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1228 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### [6.0.7](https://github.com/macite/doubtfire-deploy/compare/v6.0.6...v6.0.7) (2022-03-17) + + +### Bug Fixes + +* ensure cron tasks are reported via email ([20d6ec0](https://github.com/macite/doubtfire-deploy/commit/20d6ec0f0b99d308e3f1c9371a8f713174a8ed40)) +* ensure sample compose contains institution settings ([0f0185d](https://github.com/macite/doubtfire-deploy/commit/0f0185dc0111da2056e9e7689d051c0d0b1c09e3)) +* ensure task stats uses float division ([fc75222](https://github.com/macite/doubtfire-deploy/commit/fc75222d928e6e9e868c7e520ea9d9ee5251cfc8)) +* ensure TaskStatus.count uses sql where needed ([383a904](https://github.com/macite/doubtfire-deploy/commit/383a9047aa8ce849a302ce40ecadd7848df42b49)) +* ensure there is access to task status count from database ([976ec0a](https://github.com/macite/doubtfire-deploy/commit/976ec0ad2c2e7dff63d65baf45ddc1f342aa7249)) +* post project uses default task stats ([9ba5a50](https://github.com/macite/doubtfire-deploy/commit/9ba5a5069f5c8b8d30e27156dbc0b4914c9fc413)) +* stop empty nicknames from rendering in student_name ([#364](https://github.com/macite/doubtfire-deploy/issues/364)) ([b1a6035](https://github.com/macite/doubtfire-deploy/commit/b1a60356549f14aeb6432c9aec15e0f033732dd8)) + +### [6.0.7](https://github.com/macite/doubtfire-deploy/compare/v6.0.6...v6.0.7) (2022-03-17) + + +### Bug Fixes + +* ensure cron tasks are reported via email ([20d6ec0](https://github.com/macite/doubtfire-deploy/commit/20d6ec0f0b99d308e3f1c9371a8f713174a8ed40)) +* ensure sample compose contains institution settings ([0f0185d](https://github.com/macite/doubtfire-deploy/commit/0f0185dc0111da2056e9e7689d051c0d0b1c09e3)) +* ensure TaskStatus.count uses sql where needed ([383a904](https://github.com/macite/doubtfire-deploy/commit/383a9047aa8ce849a302ce40ecadd7848df42b49)) +* ensure there is access to task status count from database ([976ec0a](https://github.com/macite/doubtfire-deploy/commit/976ec0ad2c2e7dff63d65baf45ddc1f342aa7249)) +* post project uses default task stats ([9ba5a50](https://github.com/macite/doubtfire-deploy/commit/9ba5a5069f5c8b8d30e27156dbc0b4914c9fc413)) +* stop empty nicknames from rendering in student_name ([#364](https://github.com/macite/doubtfire-deploy/issues/364)) ([b1a6035](https://github.com/macite/doubtfire-deploy/commit/b1a60356549f14aeb6432c9aec15e0f033732dd8)) + +### [6.0.6](https://github.com/macite/doubtfire-deploy/compare/v6.0.5...v6.0.6) (2022-03-03) + + +### Bug Fixes + +* ensure content disposition is accessible on file download ([0f38961](https://github.com/macite/doubtfire-deploy/commit/0f389611ae24fc4bea181ab807974d67b1b26264)) + +### [6.0.5](https://github.com/macite/doubtfire-deploy/compare/v6.0.4...v6.0.5) (2022-03-03) + + +### Bug Fixes + +* align param and field names in stream put ([919fd60](https://github.com/macite/doubtfire-deploy/commit/919fd605e148ad11a26d7c3fce5f020032d19b2e)) + +### [6.0.4](https://github.com/macite/doubtfire-deploy/compare/v6.0.3...v6.0.4) (2022-02-28) + + +### Bug Fixes + +* revert to using serialize with task comments ([371688c](https://github.com/macite/doubtfire-deploy/commit/371688c18a6914fe3fba2ac8cf9b02a7417cc462)) + +### [6.0.3](https://github.com/macite/doubtfire-deploy/compare/v6.0.2...v6.0.3) (2022-02-25) + + +### Bug Fixes + +* ensure enrolments sync script is executable ([23714d0](https://github.com/macite/doubtfire-deploy/commit/23714d0c339082eb02b8f4e9a2759e162c4766f0)) + +### [6.0.2](https://github.com/macite/doubtfire-deploy/compare/v6.0.1...v6.0.2) (2022-02-23) + + +### Bug Fixes + +* ensure students can withdraw from tutorials ([0dcab00](https://github.com/macite/doubtfire-deploy/commit/0dcab0039b5d93509a619058a112f05a615563d8)) + +### [6.0.1](https://github.com/macite/doubtfire-deploy/compare/v6.0.0...v6.0.1) (2022-02-17) + +## [6.0.0](https://github.com/macite/doubtfire-deploy/compare/v5.0.7...v6.0.0) (2022-02-02) + + +### Features + +* add script to sync enrolments ([e932725](https://github.com/macite/doubtfire-deploy/commit/e932725ca41aac68998a00a69e9e45a993fa6c48)) +* add sprockets to development to host swagger ([b928f78](https://github.com/macite/doubtfire-deploy/commit/b928f78669348f7ca0ce94ecc881f2cad6eefcd2)) +* adjust dockerfile to work without code volume ([54c8d6b](https://github.com/macite/doubtfire-deploy/commit/54c8d6bacfee0233c379961d357fec715bd4dee7)) +* allow configuration to turn off mail sending ([ed8b3af](https://github.com/macite/doubtfire-deploy/commit/ed8b3af4805a6c6f13d9cd049e1afc370dcf6e1a)) +* enhance error reporting from overseer ([b79e4ce](https://github.com/macite/doubtfire-deploy/commit/b79e4ceadcc3a733cb6d809ec73288a1f8866e93)) +* enhance rails logging ([77f76b2](https://github.com/macite/doubtfire-deploy/commit/77f76b25b633e6d5c421e3ee3f2c8d21ffb1e638)) +* move task dates when unit start date changes ([958a263](https://github.com/macite/doubtfire-deploy/commit/958a263eaaf8fddb55ae0b208d6ea3f4f268445b)) +* report errors when attempting to add student as tutor ([7d8571a](https://github.com/macite/doubtfire-deploy/commit/7d8571a4f1ef951342899afcd6bf8b9b12f67fa2)) +* update to rails 7 and rails encrypted attributes ([c99ec09](https://github.com/macite/doubtfire-deploy/commit/c99ec0942ce006361d4d4acfc93a587e584c3f66)) + + +### Bug Fixes + +* Access serialised object now uses object.object ([00e532b](https://github.com/macite/doubtfire-deploy/commit/00e532b4393d78fa0e407396a52d8488eb96a87d)) +* Add content disposition to evidence download ([b47b9ab](https://github.com/macite/doubtfire-deploy/commit/b47b9ab79fec4b6dfb2055a786aaeaeef3616493)) +* Add custom serialiser to support rails 6 ([d051bef](https://github.com/macite/doubtfire-deploy/commit/d051befdebecd41b062de60e84aed70d2b4027b7)) +* Add database populator require to units factory ([29b22b9](https://github.com/macite/doubtfire-deploy/commit/29b22b9d74312f17ce975ef863c1eedcd14f5eb9)) +* Add factory_bot_rails in test,staging ([f4a85d6](https://github.com/macite/doubtfire-deploy/commit/f4a85d66c6dfd194c72c42760a483962e391c8f1)) +* Add headers for application controllers to match grape ([71cc316](https://github.com/macite/doubtfire-deploy/commit/71cc316848743ba6dd10c430d0e0bebfcda3d55d)) +* add idp certificate to saml settings ([e5926fa](https://github.com/macite/doubtfire-deploy/commit/e5926fa9fd42277aee4be7fb81569d712d9995e0)) +* add listen gem for development environment ([1675dbf](https://github.com/macite/doubtfire-deploy/commit/1675dbfcb48b923eada172fba8831b00cb6de799)) +* add missing end from if statement in auth_helpers ([4034dd2](https://github.com/macite/doubtfire-deploy/commit/4034dd2e8be59abc21e1ae8f3e2178b38aa7bc22)) +* add missing end to authentication_api ([28caaab](https://github.com/macite/doubtfire-deploy/commit/28caaab4a93bfc0bc35f1a16028413713b51c3c4)) +* add missing overseer entities ([7f09f50](https://github.com/macite/doubtfire-deploy/commit/7f09f508428ebede4aa7f04d90ef26abe113b3f7)) +* add required config/storage.yml file ([de3df26](https://github.com/macite/doubtfire-deploy/commit/de3df26af99ae1233e9bf42eeddb7727c195ac25)) +* add saml signout URL ([879624c](https://github.com/macite/doubtfire-deploy/commit/879624c799268e6b582f871ac7218d622f0cec6f)) +* add saml to gemfile.lock ([1456597](https://github.com/macite/doubtfire-deploy/commit/145659728c619b07b7c0d74b7de967bc67bb89e1)) +* Add serializer for task comments ([6afa092](https://github.com/macite/doubtfire-deploy/commit/6afa09215b4803a659c74f33b2004d11b2143251)) +* Auth token via user ([6dc53b9](https://github.com/macite/doubtfire-deploy/commit/6dc53b9b27473a98f83b83c8235145d5729ab567)) +* Auth token via user ([75bab1d](https://github.com/macite/doubtfire-deploy/commit/75bab1d49286f863c25d632985ed2f9190de3177)) +* Authenticate auth tokens via User ([ca9edb7](https://github.com/macite/doubtfire-deploy/commit/ca9edb7896a5b8abdb633eec27842afcbcbb8421)) +* Authenticate token via user ([224e5e9](https://github.com/macite/doubtfire-deploy/commit/224e5e953e1613ff5fad6677e0a423bccfb5d1d2)) +* Authenticate tokens via User ([3bcad77](https://github.com/macite/doubtfire-deploy/commit/3bcad77319b918b6c8afafaa4943464c425ddf0e)) +* Check sending headers from auth test ([c8c7ef4](https://github.com/macite/doubtfire-deploy/commit/c8c7ef4da2e294e3049086b31fa1980d75a94fd6)) +* check userRole when creating SAML user ([fce7fbd](https://github.com/macite/doubtfire-deploy/commit/fce7fbdd604b8af5d76a942bb3016145c6f980ff)) +* check userRole when creating SAML user ([bbf7adb](https://github.com/macite/doubtfire-deploy/commit/bbf7adba1a6ddc2e683ca053477a2d59983adee7)) +* consolidate aaf/saml response format ([1a5fcb8](https://github.com/macite/doubtfire-deploy/commit/1a5fcb88bb4ce824fe37ad4361e4185a40714a41)) +* Correct assert json matches update for rails ([14e1aa9](https://github.com/macite/doubtfire-deploy/commit/14e1aa9f98ba3bc7d0bf124c9ff071ce3a59dac3)) +* correct calculation of week number for units outside teaching periods ([591780c](https://github.com/macite/doubtfire-deploy/commit/591780c74bc0238112167ebce0a8259c8280bc18)) +* correct can destroy behaviour for rails 6 ([794b6e4](https://github.com/macite/doubtfire-deploy/commit/794b6e4764d3edbb49cb2d3c49bb1176ed55257a)) +* Correct case of auth params and headers ([d3c0206](https://github.com/macite/doubtfire-deploy/commit/d3c020618d5d10acbe68560204ef11f1dfcfff48)) +* Correct error message response ([4086227](https://github.com/macite/doubtfire-deploy/commit/408622700117f5f8a942ad92f0205d940e46ce4d)) +* Correct exception handling in tests and ensure foreign key breaks are handled in the API ([81856d1](https://github.com/macite/doubtfire-deploy/commit/81856d1f2d94eee6f48325540f0df52b9b636497)) +* Correct group test with missing auth details ([820e255](https://github.com/macite/doubtfire-deploy/commit/820e255564494d83e7512ad614d0bec4674c7b9f)) +* correct issue in foreign key migration ([a70fb57](https://github.com/macite/doubtfire-deploy/commit/a70fb570d2f52f5af91c3e54a99c5d2345f1aa91)) +* Correct issue with double adding user if to auth token ([b3afad6](https://github.com/macite/doubtfire-deploy/commit/b3afad6c64ae1d45775d40dde88189e6fd4a5823)) +* correct issues in schema and migration of discussion comments ([799f5cc](https://github.com/macite/doubtfire-deploy/commit/799f5cc2e413eefce903876238671cb34622a75d)) +* Correct issues with sign out ([e47e05b](https://github.com/macite/doubtfire-deploy/commit/e47e05bee7b52da04fe410fe3f935a163aff4878)) +* Correct json model compare to work with hash ([070e677](https://github.com/macite/doubtfire-deploy/commit/070e677f4a7bf26b35e1b87b516e7712bf44821a)) +* correct key env settings in development ([19d7cc9](https://github.com/macite/doubtfire-deploy/commit/19d7cc9633dfda45b358ac9b74f165f8ddd00cf7)) +* correct logging of aaf login and add to saml ([90d2608](https://github.com/macite/doubtfire-deploy/commit/90d260865c8e87776fbee5195d0ca21060602f00)) +* correct migrate index creation with bigint switch ([81287ea](https://github.com/macite/doubtfire-deploy/commit/81287ea9bc16e8b34731c1fb0dc925115a810680)) +* correct migration of auth token. ([11c3235](https://github.com/macite/doubtfire-deploy/commit/11c3235fd613fbbac26c2f4fdd966ad75b0fb028)) +* Correct populator use of BigDecimal ([f7f5aa3](https://github.com/macite/doubtfire-deploy/commit/f7f5aa3f6999fd2485df6a80da4c959627776be2)) +* Correct reference in auth token migration ([6a4f643](https://github.com/macite/doubtfire-deploy/commit/6a4f6434bb26a3847e6691b113659900586e9ff7)) +* correct remove index migration ([09c402f](https://github.com/macite/doubtfire-deploy/commit/09c402f7ac8ece9108ed5cbbbadbace6633f82ab)) +* correct task definition entity ([0d8f3d1](https://github.com/macite/doubtfire-deploy/commit/0d8f3d1173b68824662fe49b6f087efaf87b2397)) +* Correct typo in user for reminder test ([8609444](https://github.com/macite/doubtfire-deploy/commit/860944409b79e1f8a147695b74c791ff9059f85c)) +* Correct unit model test to work with new environment ([91d29fd](https://github.com/macite/doubtfire-deploy/commit/91d29fd79db130ae06d405f11938592895edbfe0)) +* Correct use of assert nil in task status tests ([63852bb](https://github.com/macite/doubtfire-deploy/commit/63852bb6c61a5398e65a59878ddaa945befdc654)) +* correct use of doubtfire logger ([6ae0220](https://github.com/macite/doubtfire-deploy/commit/6ae0220edff3bc7a2f44035321cbbc236cc5df41)) +* Correct use of present to use with key not using ([4c60841](https://github.com/macite/doubtfire-deploy/commit/4c608418d38b4148e8c16f88e1ab68b36f359dfb)) +* create saml config hash ([f478102](https://github.com/macite/doubtfire-deploy/commit/f4781021290c369709adb3e97c44c06fea5c08ca)) +* Default remember to false on generate token ([62d1785](https://github.com/macite/doubtfire-deploy/commit/62d1785fccb8542de6a8b62a955a7c0262ac3dd9)) +* Delete duplicate .object in serialisers ([4225dbf](https://github.com/macite/doubtfire-deploy/commit/4225dbf5d90aa29cae9efa04257632de9eca631c)) +* don't specify bundler version ([c665cb7](https://github.com/macite/doubtfire-deploy/commit/c665cb729df8b26c81a008001cd4c9dbcd822517)) +* ensure all routes use presenters ([c05b624](https://github.com/macite/doubtfire-deploy/commit/c05b624660d4da63a0f86114115b42965edf0717)) +* ensure authentication token is unique for a user ([eb73851](https://github.com/macite/doubtfire-deploy/commit/eb738510e70093954aa4c890fdabc4ca235fa3cb)) +* Ensure error on invalid token ([314b269](https://github.com/macite/doubtfire-deploy/commit/314b269d71fa6df7dcd9db3d2b78b0a32ada845f)) +* Ensure generate tokens is passed remember value ([0ece967](https://github.com/macite/doubtfire-deploy/commit/0ece967f278db75d48a87ed8177d5270bb4bbb29)) +* ensure init includes aadmin ([faf8255](https://github.com/macite/doubtfire-deploy/commit/faf8255ebc381d920fb3cf029bb5d0ee887f5315)) +* Ensure key is 32 bytes only in encrypted attributes ([9d9eb11](https://github.com/macite/doubtfire-deploy/commit/9d9eb111e5633f8dc86f9a13f73b418d37096fea)) +* ensure migration tests for foreign keys ([fca64c6](https://github.com/macite/doubtfire-deploy/commit/fca64c6b6628c6fe2ffc572e0ad591bfb7fb1e61)) +* Ensure migration works for auth tokens up and down ([7d4dbcd](https://github.com/macite/doubtfire-deploy/commit/7d4dbcdfa18ed7c28402fbe3948a3d9b6e4b5431)) +* ensure overseer comment entity has current user ([91f8430](https://github.com/macite/doubtfire-deploy/commit/91f84300d1a232f3130a533256bfa8dfed5d273a)) +* Ensure pdf generation works for tests ([f06e371](https://github.com/macite/doubtfire-deploy/commit/f06e371aa7ac733a96c187ed7e3e9c751708e249)) +* ensure rake db:init is available for new deployments ([e5e1092](https://github.com/macite/doubtfire-deploy/commit/e5e10924531453407e07e236e080021dab0fa145)) +* Ensure signif works on integers ([011478f](https://github.com/macite/doubtfire-deploy/commit/011478fce31370e03897b0df482276f7a3f5da8d)) +* Ensure status comments are marked as read on create ([171ec76](https://github.com/macite/doubtfire-deploy/commit/171ec76110df0357a2878382e7047ba1fdd5d5ab)) +* Ensure that group tests work with new ([7aa14e6](https://github.com/macite/doubtfire-deploy/commit/7aa14e6e25fe8690a810a56d55976be36ee49eab)) +* Ensure that students can reply to others in group comments ([83bc4d7](https://github.com/macite/doubtfire-deploy/commit/83bc4d73cc1772baab704d266508773218ce0154)) +* Ensure that updating task status returns update only task details ([5c35915](https://github.com/macite/doubtfire-deploy/commit/5c35915fc84b2e17bde0656ca497331f44b6746f)) +* Ensure that user is saved on generate of auth token ([0627780](https://github.com/macite/doubtfire-deploy/commit/062778017ee7d7df6e90f507d0181a96e0e9309a)) +* ensure unit roles always include the unit id ([9b624e5](https://github.com/macite/doubtfire-deploy/commit/9b624e579f8d60bace7d5f0d1a8d04f49b6e474d)) +* Ensure user accessed correctly in unit entity ([98a2d59](https://github.com/macite/doubtfire-deploy/commit/98a2d59334421f0445b7ecdb542d1999df84d816)) +* Ensure using throw abort in before destroy on error ([e503ca2](https://github.com/macite/doubtfire-deploy/commit/e503ca221fba5b12875b6478a7e4e62ac29d2d75)) +* Ensure webcal method use correct auth methods ([71773b6](https://github.com/macite/doubtfire-deploy/commit/71773b6bf17b21c993ab41856a51d7668c89eaa3)) +* Fix and use bundler2.0 in CI ([631d339](https://github.com/macite/doubtfire-deploy/commit/631d339beaec125aeb2e63201609d99e19fa6f48)) +* Fix error when change code to factorybot ([59fc722](https://github.com/macite/doubtfire-deploy/commit/59fc7224be6fcf934d9db660b1f1450372b63148)) +* fix logger syntax error ([e87fcc1](https://github.com/macite/doubtfire-deploy/commit/e87fcc1e00400384d6190afadb21963a7793adc6)) +* Fix Travis CI error ([ae6e151](https://github.com/macite/doubtfire-deploy/commit/ae6e1518dc5c84a2def625771a95ac85529ed31a)) +* fix typos in authentication helper doc ([7cdb854](https://github.com/macite/doubtfire-deploy/commit/7cdb85453bf64bf296f8ca613e6f8ff8cfa4f2fd)) +* Grape now uses *body* to return data update in teaching periods ([a7ebcda](https://github.com/macite/doubtfire-deploy/commit/a7ebcdaee8457d7f98268efe52c393b6a3440034)) +* Grape params are now hash - update file upload field access to read from hash ([b4366fc](https://github.com/macite/doubtfire-deploy/commit/b4366fc2cbab4998e3b4aaf17b6efe4ce7e1f134)) +* improve reliability of get awaiting feedback test ([318c9bc](https://github.com/macite/doubtfire-deploy/commit/318c9bc367986b66e09a95117e91a760f40cddf2)) +* Incorrect use of self to access activee record rows ([8424272](https://github.com/macite/doubtfire-deploy/commit/84242721379b159ad07f9b14bc905f7376a57c76)) +* lock listen gem version ([49c907c](https://github.com/macite/doubtfire-deploy/commit/49c907c88aa4edccae96ad6113a398acbb3ab5f8)) +* Migrate to unit entity and fix unit api tests ([f38c377](https://github.com/macite/doubtfire-deploy/commit/f38c3774ed17153f65291c3c98984b07c5921ae4)) +* pass required username to sign_in page ([58a9fc2](https://github.com/macite/doubtfire-deploy/commit/58a9fc2ecebb902ede084dd3db62d4bb8182f684)) +* read saml config from hash correctly ([0e17743](https://github.com/macite/doubtfire-deploy/commit/0e177433a8ef14420e7d4b955878d944fa96409b)) +* reenforce bundler version in dockerfile ([0d5341b](https://github.com/macite/doubtfire-deploy/commit/0d5341bad96025ab30f3fbe7a4032d6c9e8644fd)) +* remote username mangling ([e7accd9](https://github.com/macite/doubtfire-deploy/commit/e7accd96cfbe18bf8d9a548752edf1a8b3ed9130)) +* remove all use of active model serializers ([f628ff4](https://github.com/macite/doubtfire-deploy/commit/f628ff463306cf018b1304bfd34cfffd02883e86)) +* remove clock drift from settings object ([1d63e9c](https://github.com/macite/doubtfire-deploy/commit/1d63e9c597956eac94f93d3bb39de59cee35ee0b)) +* remove create_and_post_user method ([12237a8](https://github.com/macite/doubtfire-deploy/commit/12237a828ca8bfa5ffba3784d4c92fdc4e642a13)) +* remove down migration data update for auth tokens. ([bbeef86](https://github.com/macite/doubtfire-deploy/commit/bbeef86da00749554af0c8fbf4562b3522152318)) +* remove duplicate index on learning outcomes ([6d23243](https://github.com/macite/doubtfire-deploy/commit/6d23243c38f5d4fe3815d8ffc63a2fdda210799e)) +* remove old bundler version specified ([348b9c2](https://github.com/macite/doubtfire-deploy/commit/348b9c26e67470b917ef0c162df07e3f5994ce2a)) +* remove postgres from Gemfile.lock ([83150d9](https://github.com/macite/doubtfire-deploy/commit/83150d94a3b39341adedc2cbf3964aa852420525)) +* remove references to discussion comment table ([d6507cf](https://github.com/macite/doubtfire-deploy/commit/d6507cf2e6949a8be6af66f1ad8b9b46c9d8126e)) +* Remove return from TaskEntity other projects ([f396596](https://github.com/macite/doubtfire-deploy/commit/f3965963fc6844bd131de3a3be065af02784e2ce)) +* Remove sample html file ([51ef0d6](https://github.com/macite/doubtfire-deploy/commit/51ef0d62d2568e4fc4b84f2dc8f8311bc2c1e8dc)) +* remove task comment foreign key in id migration ([58c264e](https://github.com/macite/doubtfire-deploy/commit/58c264e89c72848ab569b4fb4dc22f53731c2293)) +* Remove trace from group tests ([deed1e7](https://github.com/macite/doubtfire-deploy/commit/deed1e76c7a1374b909e02912e1e748feb8406e5)) +* remove unnecessary access token from action ([c4c34de](https://github.com/macite/doubtfire-deploy/commit/c4c34de1c023de25a72b1a2f685d9c4b2bc654fc)) +* Remove unneeded email from tutor in tutorial ([00f5082](https://github.com/macite/doubtfire-deploy/commit/00f50821217a65244c90d9a6326953be366962c7)) +* remove unused devise routes ([a270d25](https://github.com/macite/doubtfire-deploy/commit/a270d25be16c3c4c9db983f2f9eb67b576f0f15c)) +* rename not_queued enum constant ([55a86e3](https://github.com/macite/doubtfire-deploy/commit/55a86e37b25386bd172809ad9abe9ee8f40e380e)) +* revert gemfile lock ([73721d2](https://github.com/macite/doubtfire-deploy/commit/73721d290d901809a492548e3fde87141246c416)) +* support zeitwerk mode loader ([15e3f96](https://github.com/macite/doubtfire-deploy/commit/15e3f9662493a6c1839aa7daa50cc12a0dc301ec)) +* Switch auth and user to grape entity ([b92d3db](https://github.com/macite/doubtfire-deploy/commit/b92d3dbdca50f7d0a4613327d78138504c801cc5)) +* Switch file access to hash ([38d7a3c](https://github.com/macite/doubtfire-deploy/commit/38d7a3ce953938a61c21d6504a3dd0578cfa81fc)) +* Switch from after to before update where changed used ([3bd7d4b](https://github.com/macite/doubtfire-deploy/commit/3bd7d4bfcbcb35e1113229ee414bfe299ef66b47)) +* Switch from around to before update ([69cbe06](https://github.com/macite/doubtfire-deploy/commit/69cbe069399be64a5fa60370753a4abf6afde837)) +* switch latex config to use recipe ([7e75c15](https://github.com/macite/doubtfire-deploy/commit/7e75c15b14a189f785c50902007fc55b044cf677)) +* switch scopes to methods ([989e8c2](https://github.com/macite/doubtfire-deploy/commit/989e8c297300fc3f0f5782367d4dc5591fe70af1)) +* Switch tutorial api to use new entity ([be9ead0](https://github.com/macite/doubtfire-deploy/commit/be9ead090d6abdef0e12446b7e672a4c4e533758)) +* Update authentication in Sign-out API ([1e843e2](https://github.com/macite/doubtfire-deploy/commit/1e843e2d37d7db49c8870b96778dd2d6f00738dc)) +* update bunny pub sub to fix subscriber launch ([37de3bc](https://github.com/macite/doubtfire-deploy/commit/37de3bccff94cad2be1a607f173ba5580bc10016)) +* Update faker deprecated methods ([f19928d](https://github.com/macite/doubtfire-deploy/commit/f19928d50080a895babec3f496f66d41a50f79d1)) +* update migration for ralls 6 ([348b4b3](https://github.com/macite/doubtfire-deploy/commit/348b4b38619fbf068252c7a470cdb7020bb68a26)) +* Update migration to work with new code on migrate without data loss ([c032ec3](https://github.com/macite/doubtfire-deploy/commit/c032ec3f1eeea20d439258f7718a89a2ca1f1702)) +* update model object to work with ruby 3.1 ([ebc5832](https://github.com/macite/doubtfire-deploy/commit/ebc5832b3ccda6769329b5f4ae701bc351fcf07b)) +* Update status tests based on new read by everyone on status comments ([ebc8694](https://github.com/macite/doubtfire-deploy/commit/ebc8694086730c15a6294079397866d53115244a)) +* Update tutorial enrolments to use new entities ([1fe02c3](https://github.com/macite/doubtfire-deploy/commit/1fe02c328bf4938155e01e70363a88eab120e9d1)) +* Update use of update attributes ([b5e5d46](https://github.com/macite/doubtfire-deploy/commit/b5e5d464d760c18d3881f07783010d7b39780623)) +* Update zip filename access ([77f7b70](https://github.com/macite/doubtfire-deploy/commit/77f7b70a0f488537f30988584c62e28d115d197f)) +* Updated activity types, campuses and csv tests ([5710753](https://github.com/macite/doubtfire-deploy/commit/5710753b95de8805a0aa61fd4122b6e37aa6ca5e)) +* Updated comments and units tests ([e760fff](https://github.com/macite/doubtfire-deploy/commit/e760ffffd8c551c384d9c00b94f61b525e8eeb05)) +* Updated tutorials, unit roles, units and users tests ([8d2035f](https://github.com/macite/doubtfire-deploy/commit/8d2035f6ba3e60968da7063bf3676333c469ae35)) +* Updated unit testing for all /api/activity_types_api_test endpoint ([1df78d9](https://github.com/macite/doubtfire-deploy/commit/1df78d9b5b34e6bd4403fe5e1b360f4e2c6d2638)) +* Updated unit testing for all /api/auth endpoint ([ae3e8cf](https://github.com/macite/doubtfire-deploy/commit/ae3e8cfb3fe4987e0a0ecc969855d17361f7c9c1)) +* Updated Unit tests for groups, projects, students and api ([0aac2de](https://github.com/macite/doubtfire-deploy/commit/0aac2defb2db602b5fd36e22bd4e51a45e4ba4a7)) +* upgrade callbacks and use of _changed? and _was to new rails 6 behaviour ([20f779b](https://github.com/macite/doubtfire-deploy/commit/20f779bfe111c17b81aac940720578eef75a8980)) +* Upgrade ruby version to 2.6 in CI ([7603cb8](https://github.com/macite/doubtfire-deploy/commit/7603cb83d35d41b5fda99d28bb4da9cae7526655)) +* use new Grape Entity model for overseer ([9632b88](https://github.com/macite/doubtfire-deploy/commit/9632b881d1e7d14653742ca5af83094d2a2033fe)) +* use password auth if not aaf or saml ([aee8d6b](https://github.com/macite/doubtfire-deploy/commit/aee8d6bb73e41489093ff189f3cd375f151c04a3)) +* Use presenter in portfolio evidence api ([a8ee43e](https://github.com/macite/doubtfire-deploy/commit/a8ee43e6cea5b21808a925287d44c447bfb54caa)) +* use puts instead of y ([312390e](https://github.com/macite/doubtfire-deploy/commit/312390eda2631142b439e61473fe50d88dc51443)) + +### [5.0.7](https://github.com/macite/doubtfire-deploy/compare/v5.0.6...v5.0.7) (2021-12-09) + + +### Features + +* Add support for files from Elements of Computer Systems ([#333](https://github.com/macite/doubtfire-deploy/issues/333)) ([a3742a5](https://github.com/macite/doubtfire-deploy/commit/a3742a5058dac2c34abd8ea28370e7abbde54c75)) +* make portfolio evidence relative ([701f58c](https://github.com/macite/doubtfire-deploy/commit/701f58c9c30cb54199c823dec34eb63a50264714)) + + +### Bug Fixes + +* correct convert submission to pdf to use new folder by default ([f216834](https://github.com/macite/doubtfire-deploy/commit/f216834bb2bb8906e823282dba064372a8463ef0)) +* esure changes to portfolio evidence apply to task ([6ade5f1](https://github.com/macite/doubtfire-deploy/commit/6ade5f13d1206d72142a19c2c7984dc46b4fd9ae)) +* remote username mangling ([789fed1](https://github.com/macite/doubtfire-deploy/commit/789fed1c67010ad8c1b8a46a36b3965de3565c7b)) + +### [5.0.4](https://github.com/macite/doubtfire-deploy/compare/v5.0.3...v5.0.4) (2021-12-06) + + +### Features + +* add script to sync enrolments ([bc65722](https://github.com/macite/doubtfire-deploy/commit/bc65722c7c22670f5e522d12c472c75c227c43b6)) + + +### Bug Fixes + +* remove set environment from scripts ([f3437bd](https://github.com/macite/doubtfire-deploy/commit/f3437bde40d7042080528d57a16428103e260602)) + +### [5.0.3](https://github.com/macite/doubtfire-deploy/compare/v5.0.2...v5.0.3) (2021-11-19) + + +### Features + +* log messages to stdout on all environments ([31041c6](https://github.com/macite/doubtfire-deploy/commit/31041c627de2db7c5e5cc0d3d81ebe68235b5120)) + + +### Bug Fixes + +* correct error message on submission process fail ([b789239](https://github.com/macite/doubtfire-deploy/commit/b78923996cf0cd4987a54dc1fa563e54abe2ffd1)) +* ensure populator works with init ([89ae68b](https://github.com/macite/doubtfire-deploy/commit/89ae68bc40c75ff6c7fd44a1452df42761592581)) +* update bunny pub sub to fix logging and launch issues ([a3ae105](https://github.com/macite/doubtfire-deploy/commit/a3ae105f45ea699eeae2b16557a91b0209b7116c)) + +### [5.0.2](https://github.com/macite/doubtfire-deploy/compare/v5.0.1...v5.0.2) (2021-11-19) + + +### Bug Fixes + +* update bunny pub sub version ([f594bdf](https://github.com/macite/doubtfire-deploy/commit/f594bdfef57323cfb9490584ca955e79b0e07bee)) + +### [5.0.1](https://github.com/macite/doubtfire-deploy/compare/v5.0.0...v5.0.1) (2021-10-27) + +## [5.0.0](https://github.com/macite/doubtfire-deploy/compare/v5.0.0-2...v5.0.0) (2021-10-13) + + +### Features + +* add ability to export auth tokens for migration from 5 to 6 ([b40bdd9](https://github.com/macite/doubtfire-deploy/commit/b40bdd96924f0e308850f72d27440e0d305c0759)) +* Add ability to init production ([bd200e6](https://github.com/macite/doubtfire-deploy/commit/bd200e6295db8f55dbcd77f6bad6b462a4e98964)) + + +### Bug Fixes + +* Add missing comma in unit.rb ([36c4fac](https://github.com/macite/doubtfire-deploy/commit/36c4fac7c04b50e4db1f7de12540a9206e858660)) +* Add missing self keyword ([f4be643](https://github.com/macite/doubtfire-deploy/commit/f4be643108b30cf43877d3342341837288712f46)) +* Add rescue/ensure block to ontrack_receive_action ([53d8d3c](https://github.com/macite/doubtfire-deploy/commit/53d8d3cf0dfe5353cdfd73bcf222088efea2bdea)) +* align overseer assessment with single assessment comment ([25dbaf8](https://github.com/macite/doubtfire-deploy/commit/25dbaf802a410863d44db26192511788988728ee)) +* Allow protocol to be included in host configuration ([142d80d](https://github.com/macite/doubtfire-deploy/commit/142d80d78a71d5fd33a87bbd64e0f348f15e8d58)) +* Change password back to what it was ([73e6bd8](https://github.com/macite/doubtfire-deploy/commit/73e6bd884f6a558dba1a099f20e235558f5a1a1b)) +* correct convert submission to pdf to use new folder by default ([a4ed642](https://github.com/macite/doubtfire-deploy/commit/a4ed642cf4c4db29b01e14319a3652314126c972)) +* correct development redirect url on aaf auth ([badf14f](https://github.com/macite/doubtfire-deploy/commit/badf14f43542bd30759708d68a3756d0801d4cbc)) +* correct hard coded path to overseer instance ([627d257](https://github.com/macite/doubtfire-deploy/commit/627d2576616be6e7de52d17cc106deb1df105d35)) +* correct overseer env settings in compose ([1daabe8](https://github.com/macite/doubtfire-deploy/commit/1daabe800ef1cafd36b2391dfcdce2e8e03012dd)) +* correct port for developemnt to use 3000 ([778442d](https://github.com/macite/doubtfire-deploy/commit/778442d146ad7db35f7182ae5d9be53bab44aabe)) +* Correct request in skip prod to use STDIN ([6c2c8ef](https://github.com/macite/doubtfire-deploy/commit/6c2c8ef0d1d985b151f38995c430bebbcc892158)) +* Download the task assessment resources authorisation ([d2ae227](https://github.com/macite/doubtfire-deploy/commit/d2ae2274d9f73067b5972ccf251933e7077773eb)) +* Ensure docker image installs bundler ([26110d7](https://github.com/macite/doubtfire-deploy/commit/26110d7eddae3e490c76ff3b6363912bd82ab2ea)) +* ensure tasks with assessments can be destroyed cleanly ([e322b8d](https://github.com/macite/doubtfire-deploy/commit/e322b8d21bbc99a63a928ef9b5b6b7e4bed34bc8)) +* ensure that init does not add task status twice ([86d9dd4](https://github.com/macite/doubtfire-deploy/commit/86d9dd41605fea50d30a4a07bc742ab123d74b8c)) +* ensure two newlines between comment text in assessment comment ([3dd7021](https://github.com/macite/doubtfire-deploy/commit/3dd7021ea5c1bc1329f674669ef7f95a6f96fb2e)) +* File permissions for overseer to operate upon ([e3708aa](https://github.com/macite/doubtfire-deploy/commit/e3708aa03f0fb273a0642b7544115b842d97e8d7)) +* Gemfile.lock revision for bunny-pub-sub ([c5b137a](https://github.com/macite/doubtfire-deploy/commit/c5b137afe473e4a23da4857f81eb87b715dda904)) +* Include :has_task_assessment_resources? ([6975790](https://github.com/macite/doubtfire-deploy/commit/69757903ab217f4f96efdb6f9c095523ffd03953)) +* Let config.overseer_images exist without OVERSEER_ENABLED flag ([56e6874](https://github.com/macite/doubtfire-deploy/commit/56e687422e9987858058b078688603f0835d4b04)) +* Missing `sm_instance` method error ([94fd461](https://github.com/macite/doubtfire-deploy/commit/94fd46166a4bfe96ce988f323c309d040a6fe73e)) +* New assessment API bugs ([0f4273c](https://github.com/macite/doubtfire-deploy/commit/0f4273c3a39d8fd8d0117720f9fc3e18c076ae84)) +* Quotes in application.rb ([d55ec13](https://github.com/macite/doubtfire-deploy/commit/d55ec134928bc5994dd4539d2144c71532e9b9b9)) +* Remove breaking empty test ([97a9c3d](https://github.com/macite/doubtfire-deploy/commit/97a9c3df1025aa7587f4c5fce5929fe2271a8ebc)) +* Remove bundle exec rake db:setup step ([fcc7e26](https://github.com/macite/doubtfire-deploy/commit/fcc7e267227a5d6238fd817d1d480b71f9dc3082)) +* Remove clutter from submission API ([15f0361](https://github.com/macite/doubtfire-deploy/commit/15f0361d8a15dd8885a0dfc082ac5af9e1f8ec02)) +* Require bunny-pub-sub in all environments ([bf75374](https://github.com/macite/doubtfire-deploy/commit/bf753749b99a58e2af5731b8519c54b5fa7a5d3d)) +* Routes param description for docker_image_name_tag ([eec9ed3](https://github.com/macite/doubtfire-deploy/commit/eec9ed399407d1b2d7fd9bd4f8e8e06ef4faea88)) +* Set default units.assessment_enabled to true ([d2b1c89](https://github.com/macite/doubtfire-deploy/commit/d2b1c89211daf4d746d1b753bcbc7dd40b924507)) +* Strip path till submission history for easy mounting ([c3d43b0](https://github.com/macite/doubtfire-deploy/commit/c3d43b01c1a4d9fdc2b68ca048c4a364bdf03f4a)) +* Strip till /doubtfire-api instead ([e0b599c](https://github.com/macite/doubtfire-deploy/commit/e0b599c9012fcf7f24d4166e22119e4d23d27103)) +* Switch task definition to use docker image model ([68212eb](https://github.com/macite/doubtfire-deploy/commit/68212eb7093cdb4b36b678e8125a770cb64e29b3)) +* Task comments generation ([91eacae](https://github.com/macite/doubtfire-deploy/commit/91eacae50a5b5989e975034421bf65378efef963)) +* Task status error ([01a0f9e](https://github.com/macite/doubtfire-deploy/commit/01a0f9edceae11674898abf1700f055ba20d97f9)) +* test moving file into place ([fe0681d](https://github.com/macite/doubtfire-deploy/commit/fe0681d5c40965f64ac481255106bbeb340bbf49)) +* Typo in application.rb ([9e8e930](https://github.com/macite/doubtfire-deploy/commit/9e8e9301e6a437f8b7705eacd059ea19c9c92599)) +* Update bunny-pub-sub version ([6e3cbe5](https://github.com/macite/doubtfire-deploy/commit/6e3cbe557959e28823b558ec7bdc514beb4579bf)) +* Update bunny-pub-sub version in Gemfile.lock ([971eb80](https://github.com/macite/doubtfire-deploy/commit/971eb802a37871296228fdfca02a8347f85f1fa6)) +* Update config in compose to include production ([941ea1c](https://github.com/macite/doubtfire-deploy/commit/941ea1c2b760f5a4fdcda9f80632f16471efcfca)) +* Update overseer actions to work with new structure ([96836d2](https://github.com/macite/doubtfire-deploy/commit/96836d200dbcdc9bc1c11e4e99dc09e71890b7c4)) +* Update overseer config to use new fixed settings ([a520b64](https://github.com/macite/doubtfire-deploy/commit/a520b64a53e026caa8828777ac998fd5218a01f8)) +* Uploading new files replaces existing files in student_work/new/task_id folder ([a8a4563](https://github.com/macite/doubtfire-deploy/commit/a8a4563fa372479e4b61c4a9646d19753e711702)) +* Validation for docker_image_name_tag now respects nil values ([23408f7](https://github.com/macite/doubtfire-deploy/commit/23408f76b6d173ff5e36c8a8072096f234e8af1b)) +* Validators for docker_image_name_tag ([372fe97](https://github.com/macite/doubtfire-deploy/commit/372fe97235584a291b4a8ddb97d109d4cfe50595)) +* Variable name ([5303ceb](https://github.com/macite/doubtfire-deploy/commit/5303ceb48be6d9e768a67560b84bd5a8e20c2f7c)) +* YAML Docker image name, disable image ([3fe985b](https://github.com/macite/doubtfire-deploy/commit/3fe985bc6fc8b223eb9e6f8c5927d0def4612f64)) + +## [5.0.0-rc1](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v2.0.0...v5.0.0-rc1) (2021-08-03) + + +### Bug Fixes + +* Correctly assign webcal reminder descriptions. Fixes [#316](https://github.com/doubtfire-lms/doubtfire-deploy/issues/316) ([fa178b7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fa178b793090de80dd2359258e81d90354dac114)) +* Ensure Deakin star activities only include associated teaching period ([1e3e981](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1e3e981d2bff0ae714c527143cd18307a1a9a6c9)) +* Remove harcoded user ID in webcal exclusion update ([d7e66aa](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d7e66aa353dd88da98d51c30732ad4ad674090da)) +* Run migration ChangeDoNotResubmit ([6354aff](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6354affa6162a474c7caf6fb2ee769e204727340)) + +## 2.0.0 (2021-07-29) + + +### Features + +* Add ability to init production ([bd200e6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bd200e6295db8f55dbcd77f6bad6b462a4e98964)) + + +### Bug Fixes + +* Remove empty check for potentially null group set on import ([c771c7b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c771c7b1d3e631d2ed6143c230daf03e264ae255)) +* .ceil of an integer not returning what was expected ([686e08d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/686e08d8ba6320cd6ad3de4d6b9034b45a8c31ad)) +* `task-webcal` review by [@macite](https://github.com/macite) ([c5f8d90](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c5f8d90431027828f45c8265de3c0fb9e2e71274)) +* Add :download_csv to tutor_role_permissions ([45dc74d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/45dc74da79cf7254341ba82998def6d0c785c15d)) +* Add a page break to LOs in portfolio ([28922fc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/28922fc260769c5b9de8f194366c6c46e5d30214)) +* Add a preliminary line before the weekly summary ([b1a6d23](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b1a6d2395c186f8e8b194139d94ad53c057b6844)) +* Add ability to redirect on AAF signout ([9d071f2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9d071f2107f1d6fd5841495ee651573b79633262)) +* Add additional character support to latex ([6c13ea8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6c13ea8dd9d7cd4e41bb245a314e82655f4f482a)) +* Add additional packages for latex install in CI ([dbc085b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dbc085bb45d0822a9de2fd7dd00eecc3c060b3c5)) +* Add anchors to duplicate dev secrets in test & replica ([19255c7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/19255c79943f96851eba15256160e4da79167e17)) +* Add application csv as mime time for csv upload ([88b784b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/88b784bc119c0a0e3a1683ab36d07a3373dcd351)) +* Add application/ogg for audio files ([ceaf809](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ceaf809741dad4de75e197a3e9d4897b7ff60bb7)) +* Add attachment to task sheet and adjust attachment logic ([6fe7f00](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6fe7f00afbccd40d261246546a00bd35a7f950c5)) +* Add auto extension to params for unit put ([92d39bf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/92d39bf6935fd1eff245b1d2fd337f136d214ad8)) +* Add back plagiarism pages to the ui ([236d1f4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/236d1f4a776332d1e14c2b8568c315f3c86a0efc)) +* Add campus and capacity while importing tutorials from csv ([1f9bf57](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1f9bf5718da7912db16ce82e0817fcb754894931)) +* Add campus id to add tutorial ([863507e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/863507e051faae7d9990fe0a8da09d43731457e8)) +* Add capacity to group set serializer ([023d14e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/023d14ec8cb14da560dfe40e9ab6564022342c26)) +* Add ccheccks for group tasks on batch marking ([09f4da3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/09f4da367128a8fde7b6786133d06e0d1e711878)) +* Add check to ensure some rake tasks fail in production ([2c2efe3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2c2efe3ed0ac4fbea96696d159536ccc67b549f0)) +* Add comma to assertion ([0e28203](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0e28203624bb6df4a423f8fffccc9df6cf9adb46)) +* Add converters to student import ([7da182f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7da182f61b75615d9712b219eaa53bceb86e6443)) +* Add correct conditions for recipient ([75b56e7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/75b56e73b5bd377907bde43a94d30b51915c8acd)) +* Add correct conditions for recipient ([2f61e80](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2f61e80d334e64e97b371780c2a7fe6db4b152b0)) +* Add correct table names to migration ([20a5824](https://github.com/doubtfire-lms/doubtfire-deploy/commit/20a58240789b4dfb850903506a48390af0aecea6)) +* Add correct table names to migration ([a8f149e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a8f149e00c41e5787e93bcb2e6fb29930faf644d)) +* Add correct validations to TaskComment model ([f6662bc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f6662bc0b75c3553e3cfb972f24efacb0bb073ee)) +* Add default coment for audio and image comments ([3b63a5d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3b63a5d27589ff2fb7cd6dc586461fa5e7b3dea5)) +* Add exception details for unhandled exceptions to log ([eca1389](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eca1389e352bf460500d0dc3424325c42e16a423)) +* Add from_existing_unit to duplicate task def call ([7096cae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7096caeb727cb861a27c4baa072038b8d407919f)) +* Add guard so only one team member can submit at a time ([2a43a35](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2a43a35346378e9c3c82a6034e295846579b2c62)) +* Add lock check to permissions on group set ([58baed3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58baed3cf795df6ef27d9895955d177b553d4e31)) +* Add log info to report MOSS urls ([fb7d859](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fb7d85988c91a8e4e8a3a0de1766a3e8a86e9b15)) +* Add migration to extend text size ([4947bbd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4947bbd103cdb75de4eab486a66181266dfbe345)) +* Add minitest-arount back to gemfile ([b1a1b95](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b1a1b955379dd606a731b25ae4b013d3fcda9fae)) +* Add missing bcrypt import to user model ([dafaac2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dafaac282e68f29a952a1441df6982370cdf617a)) +* Add missing bind option to accept all connections ([200b0c9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/200b0c994c699fab78c356d7c497351050f47433)) +* Add missing comma in unit permissions array ([ea89bdb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ea89bdb822cf5be99bb9fe433f1219d455ac1d34)) +* Add missing comma in unit.rb ([36c4fac](https://github.com/doubtfire-lms/doubtfire-deploy/commit/36c4fac7c04b50e4db1f7de12540a9206e858660)) +* Add missing copy of files processed in accept submission ([c87c016](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c87c0164db298b11e80e7cdb20e505fe830312c6)) +* Add missing CSV header ([4a57bc5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4a57bc5ab2c4b2b7e730f4f31b36d4e1b59f2be7)) +* Add missing default on extend_authentication_token ([2626b99](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2626b997228258a7b665ababf5be00730d6d5bff)) +* Add missing desc: parameter in tasks.rb ([153a71c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/153a71c9785637e2e8ca3fb08dab393c8e60f52b)) +* Add missing grade to shallow task serializer ([1620b00](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1620b000d54031584ce88c4f313a4f116836c515)) +* Add missing logger to PortfolioEvidence ([8cf4ee9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8cf4ee933099dffa756dc43e7d8a05f963ab92d6)) +* add missing migration for unit_role change ([714a9c7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/714a9c7428c7441394c7100544cc2499bfd3389f)) +* add missing migration script ([bf77914](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bf77914037ed1db987831d004da1568095da96a4)) +* Add missing module function for put_json ([bcedfd4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bcedfd4021ca304c63ec55edd4f3d534040d009a)) +* Add missing module function for with_auth_token ([3f2db3c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3f2db3cd9acf7eb964cc7b2d5710f36f787d144b)) +* add missing optional task parameter details to learning alignment api ([e760fe8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e760fe8ad6007b83e7a9d2a1ba61e00819c14293)) +* Add missing password setter for dev environment ([99f5b4e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/99f5b4e56719e26c5312396df8dc2473c02975ed)) +* Add missing RAILS_ENV for docker api ([8db9e70](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8db9e701f565d9bd76beb5106e50a6725081c73e)) +* Add missing self keyword ([f4be643](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f4be643108b30cf43877d3342341837288712f46)) +* add missing serializer ([a11029a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a11029a4f2e63aca98af381ccd76fed8241df967)) +* Add missing static function returning singleton logger ([c7d0d33](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c7d0d33e53745d3145ebf08e89c81f134afa62e2)) +* Add missing static function returning singleton logger ([28b4cd7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/28b4cd7f932f06f82733fb74348e5ff80de48bac)) +* Add missing status weight for time exceeded ([0fd4aaf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0fd4aafdb9b52b6a8cca5a858fab0bdced847445)) +* Add missing task argument to task script ([e1847a8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e1847a84d097070a087292b797e7b9847a212d64)) +* Add num_new_comments to shallow project serializer ([cc54dcf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cc54dcf456a4937c2eca8b37cca686fbfad91e93)) +* Add permissions for admin and convenors to exceed capacity ([4dade19](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4dade194c6af3ecfcb8843bb1dc21eb726654050)) +* Add post test for group_sets_api ([185ed0f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/185ed0fa0bcbbca774cea1230dea9c8c67eaa3c8)) +* Add required start_date to populate script ([b628cfd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b628cfd707a6a5659daa2b21eb7ff09acd6b0a80)) +* Add rescue/ensure block to ontrack_receive_action ([53d8d3c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/53d8d3cf0dfe5353cdfd73bcf222088efea2bdea)) +* Add seperate DB for minitest tests ([3448abe](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3448abeaa3973afb92024d97a0abf88609789d9d)) +* Add seperate DB for minitest tests ([dd676fd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dd676fdad9113b362669f920fe9a7673a6282481)) +* Add similarity count to task inbox/feedback API ([2108db2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2108db2e2dfb27754c7cd196cc499513bd710817)) +* Add space in between nickname and name for project serializer ([0072400](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0072400bf7e27d3cfaa8b69dd8b628cb4d6cc1d7)) +* Add sql file type and move mime types checks to helper ([5f33ac5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5f33ac5e40e15f041eaa028f5cb5eb9be7a31b51)) +* Add task definition validation before update ([c4f950a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c4f950a41192739106bf549d7b3be9d2ddd01ffa)) +* Add task_comment_id to where validation ([472b0e6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/472b0e6edc25e4de301bb004880e84548bfcfa67)) +* Add task_comment_id to where validation ([e44557b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e44557b55da99cbb98eb33b05f0c562e19e60e8d)) +* Add tests and fix issue with rolling over group tasks ([c4825d0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c4825d0940063ada6f248ef5139ceaa06cdd808d)) +* Add thread variable for current user in task serializer ([c704dde](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c704dde54d1b5d7c74d0951620d847757a593a76)) +* Add time exceeded to ilo scale calculations ([91082ba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/91082ba0a949902c5996c8a561b01a223a26cdc7)) +* Add tutorial stream to task definition on create ([1c9de29](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1c9de294375fc5c9acb859c634a353dc0e53b50e)) +* Add ui changes to handle sql and vb files ([27d5e21](https://github.com/doubtfire-lms/doubtfire-deploy/commit/27d5e21c9d1e6e6fb395ec16fef0af543947b7d6)) +* Add units to teaching period serializer ([71c263c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/71c263c2d3897869850a9d5480cdc0ad40537c71)) +* Add user parameter to feedback test ([4dca03d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4dca03d3d5cea4ef1b7a4c7cef63cf32a5d6d252)) +* Add validation that weighting must be present ([2bad398](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2bad39804da58e67b41bb58e40a98b3dbbc9b5f0)) +* Add validation to check uniqueness of tasks ([0be2114](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0be211445f24afb8075795bc2f19d6c6d3b48716)) +* Address issue of breaks updated in the wrong teaching period ([ef2700c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ef2700cb5c66a2e59907699b341d969776053631)) +* Adjust dates when current teaching period present ([b1a5f44](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b1a5f449f83fba655749c2232f201eeeb28d0fa5)) +* adjust error message on comment post rejection ([61e07f0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/61e07f01febb609eceee42202a7984990ff47dde)) +* Adjust faker unique clear order to attempt to address duplicate issues ([8fc016c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8fc016c5e2966cdf1543cc383d603e8baf8b9c79)) +* adjust filters for portfolio tasks ([08c7fa5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/08c7fa580d5815130c247f0af5ee38759fe18805)) +* Adjust force ssl for staging and use in AAF redirect ([55aaae1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55aaae172ca451ec47706102c4158a9e72bd91e3)) +* Adjust force ssl for staging and use in AAF redirect ([7c39055](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7c3905543fadf8ed178d28d22541d249b210130c)) +* adjust processing time for ghostscript to 30 seconds ([83cbcb2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/83cbcb2e62c2d330014aae386ce5998e88aba261)) +* Adjust pygments language to include python and use extension if unknown ([1d7ecda](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1d7ecda50da6c60c18527a3cbdfbdca411b19079)) +* Adjust task test to make sure full path is used in tasks test ([1bdc229](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1bdc2291bf9f7cd1923e56c5080e1653964714ba)) +* adjust upload file limit to 5MB ([d0fe0e3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d0fe0e3bf5882f5ef3156a9603b193ffa9cbdb20)) +* align overseer assessment with single assessment comment ([25dbaf8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/25dbaf802a410863d44db26192511788988728ee)) +* allow alignments to have no description on import ([ad8af0b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ad8af0bbfe59a461fbbabefc382fe3c97524df4a)) +* Allow development to redirect AAF auth ([58381ec](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58381eceb81c56223f4602e0998f77ea53f32c0b)) +* allow due date to be optional on task creation ([85a7262](https://github.com/doubtfire-lms/doubtfire-deploy/commit/85a72623f8a1d4d0309e90d51a54eaa1d1103530)) +* allow grade_task to work without ui from csv upload. ([8768dc7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8768dc72c289d2ec55f195d74312f34067674815)) +* Allow indifferent access to institution config hash ([6d04780](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6d04780908b08c4acbd30d94c4ff60f6c8d34d38)) +* Allow institution imports to customise tutorial change behaviour ([574115f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/574115f5a93bc32b6588139d471119e34522c3b8)) +* allow portfolio tasks to include some tasks in higher grades if they are included, aligned, and have a PDF. ([8db0ee2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8db0ee2a986b03ec404e4dc44c30268d5a555bec)) +* allow portfolio tasks to include some tasks in higher grades if they are included, aligned, and have a PDF. ([43bf33d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/43bf33d46d50237c841684758b223045dc65ab69)) +* allow portfolio tasks to include some tasks in higher grades if they are included, aligned, and have a PDF. ([ed87a77](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ed87a77029bc34ec61c262dfe50cd2707693b119)) +* Allow protocol to be included in host configuration ([142d80d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/142d80d78a71d5fd33a87bbd64e0f348f15e8d58)) +* Allow special characters in task files ([eb2adb6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eb2adb66119121bfe0b8b0db054adede778ce6a3)) +* allow students to work on group tasks independently (task status) ([17d226c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/17d226c567624fd61d1aab87d5f2eb5b6baa476f)) +* Allow text file upload for code ([f5207ed](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f5207edc2a48c9803e49750f918ace120e19a361)) +* Allow tutorial to be set to no campus ([8f9076f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f9076f7894ff396ce100ee4eb61e753839a6410)) +* Amend API URL to Deakin test server ([92c83a5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/92c83a560784953a25d7a5ae369bd04fca7754de)) +* Amend API URL to Deakin test server ([8be1064](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8be1064d9fc7f6e436f42a0b1436077326a267f0)) +* Amend incompatible Ruby versions in Dockerfile ([297d025](https://github.com/doubtfire-lms/doubtfire-deploy/commit/297d0251d2c3128aefa4e8974f6b443ce7bb6844)) +* Amend invalid migration for is_graded ([cdc3a0f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cdc3a0f43b2143e2a7625cdba85309f5d360b794)) +* Amend invalid syntax for grade_task method ([f1f17a8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f1f17a8aa92dff92e43cdc06480cdb449a9d5260)) +* Assign and use expected_auth in put test ([14b8a5f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/14b8a5f995b17a28283c69f3ecbd5da18f05e593)) +* Attempt 2 at converting active to boolean in Project api ([ccaf855](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ccaf8557e7df25d479036028919fcfb351226ac2)) +* Attempt 2 to convert Project active to boolean in MySql ([f93b0c1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f93b0c1e3c5412c007ffb0d42be8bd1402f17e2f)) +* Attempt switching group's tutorial only if tutorial ID specified ([546a9ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/546a9ce914c5108f901421d673c68f207f5e421d)) +* Attempt to fix username factory issue ([701f8fd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/701f8fddd4aeaf5ab4b7712c90a75dc5bcd1d9da)) +* Bump web server with latest changes ([dcbec16](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dcbec167d8e0e6b9f4db747a0842e0a3ae637092)) +* Bump web server with latest changes ([8510870](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8510870bd8a852aa86af115f9f1e3f66be5957d7)) +* Bump web version ([bf34e28](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bf34e28de0502f6c40ec731088c81324c6453371)) +* Call add_teaching_period before the save ([1a113ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1a113ce66554746c5c2b0bfbd81b77a07ae07e0e)) +* call to student_target_grade_stats ([f6b49f0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f6b49f00cb8ab9d7b80a4359ea9c0df284dbc79f)) +* Cannot destroy a campus with projects and tutorials ([e9af00b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e9af00bed40621da80441999f8111a8dc413833b)) +* Change copy of files to move dir ([13fb77a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/13fb77ac5e2b25daf0b35bc827f9a55b11a9c0be)) +* Change get tests for group_sets_api ([65099e9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/65099e92291ba77e7eb3d91fa55e358440181850)) +* Change get tests for group_sets_api ([bee0ba1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bee0ba1b2c8c3e1440a9cf02b9d7cf3f85fb0c10)) +* Change get tests for group_sets_api ([702c367](https://github.com/doubtfire-lms/doubtfire-deploy/commit/702c367b9837db770031abc9c538c841c1f7a12e)) +* Change get tests for group_sets_api ([16ab11e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/16ab11efdf9c11b5413e7013f0dcbdd7b66e1fdb)) +* Change get tests for group_sets_api ([f59848e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f59848ef8428d58bb524f30861315657dda4bc05)) +* Change get tests for student_api ([ce04c12](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ce04c122e5eb6e2d5fd0a9f0f0d836bcf03df33c)) +* Change get tests for student_api ([00abdc7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/00abdc73630a1b6145ada0c686746a07db1cd98c)) +* Change get tests for student_api ([1b1e691](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1b1e6915cc6d21dbc3e0e223dba82ffc7d737ae6)) +* Change get tests for student_api ([3aae341](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3aae3415ac6e755d66d7683638ec3827c2d4db50)) +* Change get tests for students_api ([b86964f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b86964fccc2010ce25fc7ae2dae8145e97cc1fd9)) +* Change get tests for students_api ([efde42f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/efde42f5c71e01ec2389738470b753979975b6c6)) +* Change get tests for students_api ([0eaac76](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0eaac76c6eccc7277c83b03f60dcdfd5bd6c2517)) +* Change get tests for students_api ([2f8c7cf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2f8c7cf087ac5b49ea4edd6a902f73929188c6c5)) +* Change get tests for students_api ([5163a71](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5163a717050fa27ea806592692eb3767b2f20845)) +* Change get tests for students_api ([53131cb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/53131cb379f193228e9958fa331de887e9918f0a)) +* Change get tests for students_api ([e77f8e7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e77f8e73cef55d753428d7c80a8eb1f7f93d37b5)) +* Change get tests for students_api ([4d92402](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4d92402a4e446a11948205d94e97b9d5ba0034ce)) +* Change get tests for students_api ([1b3e6de](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1b3e6de7041578aef4704a6180b2dd6d2734c544)) +* Change password back to what it was ([73e6bd8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/73e6bd884f6a558dba1a099f20e235558f5a1a1b)) +* Change post test for group_sets_api ([dbc8aa9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dbc8aa9040d33ff39e3017e3800fe515180161cd)) +* Change post test for group_sets_api ([50bc51a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/50bc51a32a5dac25906f09bff33595d361f58b12)) +* Change post test for group_sets_api ([3a0abab](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3a0abab04c8508f526fd165fca405a2e20868083)) +* Change post test for group_sets_api ([aee466e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/aee466e6106b729559958c922784aedb4a23a266)) +* Change post test for group_sets_api ([bed8bbf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bed8bbf429f20347c10e32d200ea35c3759b2438)) +* Change post test for group_sets_api ([bdd3085](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bdd30859badec6a3435078f881792b3b52718493)) +* Change post test for group_sets_api ([bcf9cc9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bcf9cc9ba03a6eb08cbf12537649f3690ee073fd)) +* change task sequence to use start date ([fff536d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fff536d34754d3f0b96fc08d05282a68c805bbac)) +* Change task_definitions to support date submitted and count ([de6138d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/de6138d74cbaa8a075d18f7826016487b888e893)) +* Changed string quotes ([d8f21a6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d8f21a6ef22e917cbbc2d0ad16249057d9d5ac6d)) +* Changeget tests for group_sets_api ([efb4295](https://github.com/doubtfire-lms/doubtfire-deploy/commit/efb4295b67fa23191515c3f8f540107d882e1b89)) +* Check error count of b2 in test break not colliding ([34b24d9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/34b24d936e7621ec318180014a342ef0fa2e831b)) +* Check file upload on unit student csv ([724e1c6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/724e1c6c4253cd57ca8f766122dda67909e4cd40)) +* Check if path exists before removing in zip submissions ([feb2e30](https://github.com/doubtfire-lms/doubtfire-deploy/commit/feb2e3002ac80a543e9a3e9dcc6778911c399cb0)) +* Check if task pdf files exist with . replaced with _ ([91d869d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/91d869d4ab381e42aec2629fa9fdfab0f6d2fb20)) +* Check if tutorial stream is present before serializing ([01ca99a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/01ca99a51116f7dc7df6f0c91ef49498c4ba06ef)) +* Check object has hasKey? in group serializer ([d836d3b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d836d3bea687b63ecabe86f136556046902d9e77)) +* Clear and update plagiarism data on td change ([9ff0792](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9ff07926139721ae75a4efa6fe8916a8d9478b61)) +* Clear and update plagiarism data on td change ([e88b59b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e88b59b37da913243fa65b11314d7afad6904a37)) +* Code in test unit import file ([9a15317](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9a153178c1393b88d3d712fc7d20e61843bd3f13)) +* Comment Serialize ([d3ff751](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d3ff75158df2f9ae5862bb2ceb10d01ca187fc1f)) +* Concurrency issue with project tasks duplication ([952c213](https://github.com/doubtfire-lms/doubtfire-deploy/commit/952c213dd749b0e695a6c746f5ceb39a1d97197d)) +* Conversion of file encoding for code ([80fafa8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/80fafa8421b2b85a52d1fb2cdb0280e4e6370856)) +* correct API for getting task status -- return data in task/tutorial groups ([13c8e7f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/13c8e7feccd6ba0381ed904f96557a357e0a6b5c)) +* Correct auto extension param syntax ([bf7b512](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bf7b5128adc1dd6d424fb9e608264e6e4950eae6)) +* correct convert submission to pdf to use new folder by default ([a4ed642](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a4ed642cf4c4db29b01e14319a3652314126c972)) +* Correct CSV tests to use new response codes ([21f7d3a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/21f7d3ac2a1aa3c27260e1f5bc4162d5a148e8ec)) +* Correct date range for ahead tasks ([2edb15e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2edb15e7156f212e44ec0d6249e4e5f5474a7306)) +* correct development redirect url on aaf auth ([badf14f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/badf14f43542bd30759708d68a3756d0801d4cbc)) +* Correct enrolment into groups restricted by tutorial ([5c715fe](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5c715fe87948d4a8ae48146905934146fbc1f538)) +* Correct erbs with product name ([7a8ef48](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7a8ef48662e6ebe2b0f2df5f078e98104205b494)) +* Correct error message for grade fail on task ([3c6ba6d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3c6ba6d02340d372887aebbf5c93149e151ff0ef)) +* Correct error messages when group or groupset locked ([d48aced](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d48aced49dc2b14b539609b7e56eed7f3f73c068)) +* Correct error reporting in sync enrolments ([76b0334](https://github.com/doubtfire-lms/doubtfire-deploy/commit/76b0334b925c421d48a083ee9fa66826cd057a84)) +* Correct error with checking mime types for a file ([6a5059a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6a5059a14d8107b83a1a743e227915ad3d2ccbb2)) +* Correct extraction of tutorial id from params on switch tutorial ([3aa75e0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3aa75e00a31ab7d6e5d0b2ce53603d02ad8eec7a)) +* Correct file name of portfolio in zip ([a498fa3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a498fa32630174c731068bd7f3eefe008d262fca)) +* Correct filenames for task sheet and portfolio downloads ([2bc28a4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2bc28a4836b012bbde58e708a7304e9927b2220f)) +* Correct grade of time exceeded tasks ([962b7e6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/962b7e63497f42cf67916cdced19d3495fd91af2)) +* Correct group modification when same tutorial true ([c824d44](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c824d440308afc8f2a2155c9784ec89ed5565a0d)) +* Correct group serialisation of student count ([0721277](https://github.com/doubtfire-lms/doubtfire-deploy/commit/072127766b182c30634530f78f754eeeb50fecf1)) +* correct hard coded path to overseer instance ([627d257](https://github.com/doubtfire-lms/doubtfire-deploy/commit/627d2576616be6e7de52d17cc106deb1df105d35)) +* Correct importing of task definitions with groups ([f921271](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f921271402fc7415ee4228c1379809648c82942c)) +* Correct indentation in sync code ([636e21c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/636e21c231018515c2723c6b3722ea0da21f10ab)) +* Correct invalid code within group set test ([4ee7d46](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4ee7d46dad21273999481a2caa76e425c847ae08)) +* Correct issue causing task feedback test fails ([b59e868](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b59e868830e335cb3ea54b0a0ebae0b97f584c45)) +* Correct issue in breaks api test ([3fc3e4a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3fc3e4ac10925025e5e2764c2e7f446b61aa789d)) +* Correct issue linking students to timetable data ([b71e355](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b71e3553213484674e63a507b51883bc1ce8393c)) +* Correct issue when no tasks failed to query students ([72158f8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/72158f83d4a65f5a34a41bc974aafa95b9456586)) +* Correct issue when uploading empty comment attachment ([e59b7d8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e59b7d8fdb28d0a688321e9cf757fe234b8653ca)) +* Correct issue with adding auth token in tests ([3c8b12c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3c8b12cc0f192e54043d4e2dd887290bbcedcdf4)) +* Correct issue with changes to group task with extension test ([8152dfb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8152dfb4255e9830435fc7a5c52c3423f3158439)) +* Correct issue with compressing images ([74bc733](https://github.com/doubtfire-lms/doubtfire-deploy/commit/74bc7332987803bc940cf7b47c1792f09777828f)) +* Correct issue with concurrency bug on team submissions ([b21d7ec](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b21d7ec003b55e6dc25374e1a251e8db0f14bac4)) +* Correct issue with conversion of due date to day based ([dec0d4a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dec0d4a4ee65498598c01ef59b518265da1da5ed)) +* Correct issue with creating users failing as no password ([7f5d3ba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7f5d3baf87fe4ebe49dae4a74949ff9315e3746c)) +* Correct issue with delete and foreign key relationship for breaks ([17c6e5f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/17c6e5f2ddcc352c642d68c04821814557bd4466)) +* Correct issue with duplication of cloud tutorials ([6fa167e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6fa167efaeae6b6387ed9626b4d8de903537b3cc)) +* Correct issue with empty alignment rational ([63aa732](https://github.com/doubtfire-lms/doubtfire-deploy/commit/63aa732d98d381189dc7eb237042deae52b34ab5)) +* Correct issue with limiting tasks by grade in unit student tasks ([842ca5b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/842ca5b0e620dcbac428af3236920a471d2ba9aa)) +* Correct issue with name of new tutorial stream ([456e829](https://github.com/doubtfire-lms/doubtfire-deploy/commit/456e82966ab279d656e728c90b33ad8130000313)) +* Correct issue with pdf erb ([b6a263a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b6a263a50a817979c4276b4ee91a321716050167)) +* correct issue with populate script failing ([4c94a88](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4c94a884437e44bc7bdffd3ddd3a09ad4922d98a)) +* Correct issue with portfolio creation ([8dabf0c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8dabf0c17e444567a0a7b32be249090335d391d3)) +* correct issue with student created task alignments ([b11eda3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b11eda3fb07fa3024e7257051d94b4b638742d2b)) +* Correct issue with task completion CSV ([ad2ba69](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ad2ba6993798c4f10ccf634210ae1a0f9dcfe1a9)) +* Correct issue with task pdf creation ([9f0e2e7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9f0e2e72bf38b0d481013637a5c1257f32ff7547)) +* Correct issue with task stats including tasks for higher grades ([7d29f65](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7d29f65bfa7afe23924fcd820e7b61c3337e5631)) +* Correct issue with tracking of task submissions ([3fa2d37](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3fa2d37d2cb80f8818413ab375b3abc66b2cf381)) +* Correct issue with tutorial enrolment change validations ([b43ace3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b43ace3f955a69bc2077ddefbb8a88192efe2221)) +* Correct issue with use of unit in group import ([07ab84a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/07ab84a8f88f22d732ab19c39673b63745ca1a50)) +* Correct issue with variable use in enrol ([ad99b16](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ad99b165f91e893dcbf086b4d777670a03e218d3)) +* Correct issue with withdraw students ([a517502](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a51750298f917ec3dd4730481fefc70c180eaa79)) +* Correct issues with role checks to ensure all paths return values ([4c6f69e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4c6f69ebb4354fc6eb36f017d8468df837387394)) +* Correct issues with task stats inc withdrawn students ([b95de0f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b95de0ff4c336e6b6a509840fc327e05183c8740)) +* Correct issues with task status tests ([5b9eab5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b9eab51de337377ca1807b570c9d9a56b25f085)) +* Correct logging of stream in timetable sync ([93e4728](https://github.com/doubtfire-lms/doubtfire-deploy/commit/93e4728487858fbe27a4a698527cdaefe40bd75c)) +* Correct logic error in group membership ([f6bd82d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f6bd82d0cf803d04eaef1372d21f188be90d0408)) +* Correct missing end in settings ([5e9af5a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5e9af5a975ecc70a43acb6c9c0479379916e63c8)) +* Correct order and remove typo in unit ([5bd67b9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5bd67b99482a10b956eef2c4c82163b988459dcd)) +* correct overseer env settings in compose ([1daabe8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1daabe800ef1cafd36b2391dfcdce2e8e03012dd)) +* Correct placeholder pdfs and responses when files missing ([a16bed7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a16bed7c59e64dc5310a4b9c558cec87f431f158)) +* correct port for developemnt to use 3000 ([778442d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/778442d146ad7db35f7182ae5d9be53bab44aabe)) +* Correct request in skip prod to use STDIN ([6c2c8ef](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6c2c8ef0d1d985b151f38995c430bebbcc892158)) +* Correct return codes for csv tests ([d490faf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d490faf88111dff60225c33152a491eebb809abb)) +* Correct schema version ([6fcc3cb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6fcc3cb2681a46009c0f05b867e5dbbfdd44cfd1)) +* Correct sending of portfolio emails to come from main convenor ([91ca609](https://github.com/doubtfire-lms/doubtfire-deploy/commit/91ca60905cf9a72d9390bea599c91bedb6dc8176)) +* Correct sort order issue in ui ([69d176f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/69d176fac94b3c3862611e14b14649c3a6798840)) +* Correct star import creation of tutorials ([f046233](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f046233fb8df851bd767d76b826adbd1568631dc)) +* Correct status upon submit ([987b37f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/987b37f397bc9d8cd12546751f6dc000b0b520ee)) +* Correct student list query to ensure all students appear ([5ee1cbf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5ee1cbfd63866486ecf6e987fe8c62ddc106f2bb)) +* Correct student tutorial list ([ad4ec4b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ad4ec4bc22cd8b2230db15a8858664c45e166c50)) +* Correct sync enrolments with to work with tutorial list ([98c6f3b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/98c6f3bc1047e3620753228eaf4b4bb8b5323741)) +* Correct sync of classes to only skip if not in streams ([2d6b3fa](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2d6b3fa4bcb8631a21694f53a298b5ed192b1ed7)) +* Correct syncing of cloud units ([4dc96bd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4dc96bdba1e5f111626af47ec4439c8d374c6ad8)) +* Correct tasks API for steam changes ([a00743c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a00743c095ccb60f3637ad41b1a3fe95208eb809)) +* Correct tests to make use of FactoryBot ([73e1833](https://github.com/doubtfire-lms/doubtfire-deploy/commit/73e18331a6f23946573b5224f2b9d54f60666635)) +* Correct tests to use FactoryBot ([5601b5d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5601b5dd813f21b4648a1e33f5c430c3fe385847)) +* Correct tests to use FactoryBot ([34947fa](https://github.com/doubtfire-lms/doubtfire-deploy/commit/34947fab17ffb385c963c186418c91c05fd56f0a)) +* Correct top tasks and revert to use extensions ([516edae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/516edaea2291657ac2951fd50138d21c7e4c25e7)) +* Correct tutorial tests ([8cae99e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8cae99eada4155aced1df4d22336456639273b23)) +* Correct typo in tutorial day for cloud ([9417915](https://github.com/doubtfire-lms/doubtfire-deploy/commit/941791520b0d942a433b253f0519805bf2c8b786)) +* correct unit role serialiser to work with or without start date ([f6b75f1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f6b75f1dabd48f8aef993bc4e2494d953c332efa)) +* Correct uploader to allow txt submissions ([1376716](https://github.com/doubtfire-lms/doubtfire-deploy/commit/13767167a63af75e96f98a7408b2d86d0ad09013)) +* Correct use of project tutorial id in summary email ([ded0868](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ded0868074592c35362aeb4eaaea8912d62a9d02)) +* Correct use of tutorial enrolments for tutor assessments ([0fff45d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0fff45dd0d2240794c1c1e6548031527fd83c135)) +* Correct user import to avoid error on blank row ([1761c57](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1761c57a91d0d8fccbd0ebf61f5048a1d75619cf)) +* Correct username from Deakin STAR import ([7332810](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7332810d2cc07a4df848aabee1038d460974f077)) +* Correct validation for tasks past deadline ([58e02b3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58e02b3f51b97d43833611231c76144c2d903040)) +* Correct variable name for allow revert ([8a4272c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8a4272c4c03592c44c1e7ce9477777377aa0888c)) +* Correct variable name in task def tests ([6a6591e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6a6591edae50df06b61235624fb08c0534a5017b)) +* Correctly reference host symbol ([5af5c3d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5af5c3d8615da37c5e06e89f2b48584b542e1cf5)) +* Create 1 enrolment to avoid duplicate error ([908a455](https://github.com/doubtfire-lms/doubtfire-deploy/commit/908a45535da6ceeed87f5e07a603914e4aa17320)) +* Create Teaching Period before rolling over ([f5a3e8a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f5a3e8a284e53334a7c862815ee2433a47e6c3e7)) +* Create tmp portfolio dir when copying ([08ba248](https://github.com/doubtfire-lms/doubtfire-deploy/commit/08ba2482783ef474583ffca728edc6d5a53a112f)) +* Create unique username and password ([4584380](https://github.com/doubtfire-lms/doubtfire-deploy/commit/45843804d32fcb8f0b524c6c25656143408e9353)) +* Date format in latex ([26b36b5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/26b36b5f7a9dd9b865950680cda7d3c5fc19949d)) +* Delay redirect for token time out ([176d407](https://github.com/doubtfire-lms/doubtfire-deploy/commit/176d407dd334a51cf16786139ae8ce975d02cd00)) +* delete useless code and useless test ([8db1523](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8db152336f7b7c5f68cea82d06ad3aa060583a64)) +* derp... add migration ([c46b21d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c46b21daf9550e7bdd24d2d28cad488bd0ebf34a)) +* Determine the content type of attachment comment ([ef59b02](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ef59b02b62062422f537274d413bf5a7099fa615)) +* Do not create tutorial with empty string ([df54b02](https://github.com/doubtfire-lms/doubtfire-deploy/commit/df54b0235d54e67c3afc79c42c99c125ed2fd2c0)) +* Do not recreate zip downloads if they exist ([9d6d269](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9d6d269b8f95e2c1b98ab967c8992798af29aa50)) +* Do not resubmit name in database ([087f0ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/087f0ce0a740d78d94ac88ac70951e7f0c5dd6c5)) +* docker-compose.yml ([48ec2e6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/48ec2e6f67ef06999755f87cc5beed2e4fc9b7fb)) +* Download tasks for task def ([7b163b9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7b163b9df1667208e8cae2fe3d74ff9604f886fa)) +* Download the task assessment resources authorisation ([d2ae227](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d2ae2274d9f73067b5972ccf251933e7077773eb)) +* Email validation for user model ([b7ef5f2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b7ef5f2eed8a7753ced4662d1feff8d1735ac132)) +* Enable comment attachments for group tasks ([9e138c4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e138c46611ecc77cad195ef74dfb2f8f32e031f)) +* Enable video/webm audio for chrome recordings ([ba9ef6b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ba9ef6bedef9ae5b3dcbb3678d253cbf123085c9)) +* Ensure a project only has a portfolio when it is available ([5ec938c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5ec938c69716a2178979f6bfd8a06baaeb1a2e3e)) +* ensure achievement stats include 'all' students and work with small numbers of task completions ([21fd3fc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/21fd3fc61a9216b90479183c9aba4ed1dcd1836e)) +* Ensure active is returned as a boolean in projects ([98223fe](https://github.com/doubtfire-lms/doubtfire-deploy/commit/98223fee7b85bad297bb4bdccfdd6a65e4723edc)) +* Ensure add_auth_token respects user provided ([16e1b17](https://github.com/doubtfire-lms/doubtfire-deploy/commit/16e1b179f4bcc8af606fb79c6756952033a307b6)) +* Ensure add_response methods return true and cleanup temp files ([80ad993](https://github.com/doubtfire-lms/doubtfire-deploy/commit/80ad9938e72dadf25aa8b61f8ddac86d92a3d16b)) +* Ensure all images are converted to jpg on upload despite filename ([007cb32](https://github.com/doubtfire-lms/doubtfire-deploy/commit/007cb32c2c6812e12f6853b9e1e081d8649e67bc)) +* Ensure all latex escaped text is raw in erb ([527185f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/527185f064e66223886d3def0ae12ecf65f6dd8f)) +* Ensure backports is only updated ([cb04e40](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cb04e404870f0f6f215d5cab95309731496c7d2e)) +* Ensure better timeouts for PDF creation ([810611b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/810611bdfd50802a1eb61bb67165752abc131e2e)) +* Ensure better timeouts for PDF creation ([9895773](https://github.com/doubtfire-lms/doubtfire-deploy/commit/98957737c3c4dcd5842dcb7dc5f59b63a92e6198)) +* Ensure break is added after save ([59c0e00](https://github.com/doubtfire-lms/doubtfire-deploy/commit/59c0e005e81addef8e601846d048300255398392)) +* Ensure campus is correctly cached ([b1d546b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b1d546b46b5872773c252e8d94f615684dec7f4c)) +* Ensure comment type defaults to text ([52531ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/52531cee0ddda765d87872414d25eb8620a7c715)) +* Ensure comments are not saved in transcode fails ([869cb9e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/869cb9ee0969c6de0ca76e0adf55a02cd91dea5a)) +* Ensure comments are propogates for comments when group ([769cc13](https://github.com/doubtfire-lms/doubtfire-deploy/commit/769cc13cc1d465aafc20ca164182555f532b51a4)) +* Ensure comments can be deleted across project teams by tutors ([8dca331](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8dca331969d00f0f6562edc77546a113e2962a16)) +* Ensure compress returns false if zip does not exist ([3a261ee](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3a261ee508d7cdee910eae3e7f7b9f67fdb23a7e)) +* Ensure correct files in the zip download of tasks ([16f3035](https://github.com/doubtfire-lms/doubtfire-deploy/commit/16f3035501989f5211607112b072776fcb1283df)) +* Ensure correct propogation of status comments for tasks ([6acc140](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6acc1409ac73e5806cce4f3e8df3ee5cadac53e0)) +* Ensure count is before date in should revert test ([7dbce57](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7dbce57b156a89d9e06b0cdc3336382353f294f2)) +* Ensure created messages are read correctly ([da3984c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/da3984cb2636ded1fbce96bac7ba5e9e632423f8)) +* Ensure created_at is only accessed if the comment receipt exists ([5b8a6d1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b8a6d111e88d869885fa055dd5917928044ed57)) +* Ensure CSV check works with Tempfiles ([1349ca2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1349ca29825f249a1a316f64fccd21f7b3b2faa4)) +* Ensure date checks work in invalid cases ([87a8d09](https://github.com/doubtfire-lms/doubtfire-deploy/commit/87a8d09f90e3e727226540a84bc5b1339bbbe0ba)) +* Ensure db populate can create user roles and task status ([e030f1c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e030f1c52f7ae710a7f866192118f4d828491229)) +* Ensure db populator has ULO ratings between 1 and 5 ([04e4689](https://github.com/doubtfire-lms/doubtfire-deploy/commit/04e4689df5f64edea2582dc23e44aadf4007f1f4)) +* Ensure deleting a stream deletes tutorials ([f089700](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f0897007fa3e6aef4dfb3fc68a1038279ad34091)) +* Ensure DiscussionComment is returned in post ([4556d50](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4556d501440b3846d7465aa2bd2ff1e8f3df88a8)) +* Ensure docker image installs bundler ([26110d7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/26110d7eddae3e490c76ff3b6363912bd82ab2ea)) +* Ensure DoubtfireLogger initialises with nil ([04fe736](https://github.com/doubtfire-lms/doubtfire-deploy/commit/04fe736f35d29db67f29bb6afe1d58ad52b6fadc)) +* Ensure DoubtfireLogger initialises with nil ([f52816d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f52816ddf0e1ddab90b8f8b40bcacc882e4cb9c2)) +* Ensure duplicate student ids raise exceptions on csv import ([e039b84](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e039b84b7fa3fb0ec5d5430ab919f24c7ebe7157)) +* Ensure empty groups can be returned ([aa305cc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/aa305cc81ad786e7211878100e2383ded8d3e71c)) +* Ensure enroling in tutorial replaces other enrolments if needed ([aa854a9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/aa854a935e6e17521f595f610718ad0ecc9b364f)) +* Ensure enrolment updates tutorial ([092faa2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/092faa25390d7d37315ea7b8303d94fef4f2f86c)) +* Ensure enrolments are encapsulated in an object on post ([3bf2abe](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3bf2abe3cb01a98e09bf739d9ec11fd34727db1d)) +* Ensure enrolments correctly returned when no stream ([47402cf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/47402cf74dd140fe1e9f2712f3e77ad8b97f09fb)) +* Ensure error handling of rollover to past occurs ([9f3d5d5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9f3d5d54cc8f69c173f35b8f3931ddcb8d029d52)) +* Ensure errors are handled ([f18b12b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f18b12b910ae8880dbd67f71f9e9d717bccf5274)) +* ensure feedback tasks have has_pdf attribute ([ce60acb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ce60acb1eb56f64c78bb16c2f701aec58a24624a)) +* Ensure file checking of audio uses list of allowed types ([8a4b7df](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8a4b7dff1ce2d7d0e99b8c76e8239668736e1514)) +* Ensure file only checked when provided in comment ([d72f08f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d72f08f0cda5b1b9c00a18259ee13a69bedddbf6)) +* Ensure FileHelper uses extend rather than include for logger ([55dc966](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55dc9665ee562b798f5f07de2545b69a00689031)) +* Ensure force_ssl production config is false ([ff10c7b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff10c7bdd9ffd2de1c998bf9b52c93cc665bfa02)) +* Ensure group capacity checks project enrolment ([6fb7c76](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6fb7c7640007847ca8a58091161c3396fb369ad6)) +* Ensure group capacity is checked on re-enrolment of students ([4cecbd1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4cecbd1d1b8a0ed884dbaa0e69b1486c455c5ca4)) +* Ensure group membership checks project enrolment on validate ([1526cb8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1526cb897e7b838299f85bfa2944533bc7c93531)) +* Ensure group saves on switch tutorial ([0650920](https://github.com/doubtfire-lms/doubtfire-deploy/commit/06509206ba111b72591d3fab2d1010cf2839a50e)) +* Ensure group set api works when tutorial nil for group ([cf2cd8a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cf2cd8ab5e3680990a5ede22c9b4d8c8521b1d7e)) +* Ensure group submissions cleared when group removed from td ([5927711](https://github.com/doubtfire-lms/doubtfire-deploy/commit/59277115664c28b59ee0a33400353de329e6881d)) +* Ensure group switch tutorial validates membership tutorial appropriately ([d4386ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d4386ce33eff11cc36fae9e42846387c8d47a70f)) +* Ensure group task marking uploads work ([e33f3ca](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e33f3ca17fee458e3411256b524822de1651b13e)) +* Ensure groups are mapped correctly to task completion CSV ([b2574cb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b2574cb975422ef7b6554eb1ebc9975ca5f018d0)) +* Ensure groups delete memberships on destroy ([079c905](https://github.com/doubtfire-lms/doubtfire-deploy/commit/079c905a3df9451ca0ad837ec1fb03e89a40345a)) +* Ensure headers in CSV parsing are in lowercase ([7fc9180](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7fc91809cdb3c4e47f1ced11dd9a1cba3cafc2c4)) +* Ensure import works with campus and streams ([6de8cac](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6de8cac63144472ed2901af56669242015445d25)) +* Ensure institution config can be overriden by ENV ([92af516](https://github.com/doubtfire-lms/doubtfire-deploy/commit/92af516629638d91e09762a4bd1bfd1d6c93d391)) +* Ensure institution config can be overriden by ENV ([7abb482](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7abb4822a12038b87bdc3da3446f4c6dd33e81a2)) +* Ensure left outer join is used on get_all_tasks ([a361f34](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a361f3417608bd1000d503e26a67ec003b0e0068)) +* Ensure lists are cloned in unit factory ([d802e30](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d802e30cf20688d87cb394a93997e989d92a5a08)) +* Ensure localhost used for AAF in development only ([9ece7af](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9ece7af8ae72198ff73b718feaf40a6c4150739c)) +* Ensure localhost used for AAF in development only ([7b49bae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7b49baed1b03f8f97955f9ce74d77a196bd0f5a8)) +* Ensure logger writes to default log file ([ca8d5e4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ca8d5e43027be6315422de5d60b727458466d9e6)) +* ensure median works when there are no task alignment details ([e44e152](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e44e152e0aa6347c4c67bd16097b405ae2c76ef4)) +* Ensure member exists and is active before add ([8c2fc54](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8c2fc54a3a50da751b32ddf83191acb9350d35e8)) +* Ensure minitest framework is used for all test files ([5b77519](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b77519846a5af8543a241e10f8ae135b11af814)) +* Ensure names are always capitalized ([09313ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/09313cecc74c67d43d289e4d9557ffd364ba645d)) +* Ensure new checks dont override dismissed ([a011fc8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a011fc8cb2b9eb15a46191ab2a94e0de16431781)) +* Ensure new comment creates read receipt ([4db68b3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4db68b358aa4e9fa85d3e6399d32ed81047385af)) +* Ensure new comment creates read receipt ([6eb665f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6eb665f2a1af015fe505c7b0809a1c6d0f4802db)) +* Ensure new files override unprocessed files ([d89fa20](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d89fa20ebad21c9e1d10f09487f58d6692de408e)) +* Ensure new group number is dependent on previous num ([ea993da](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ea993dadb8d0c1a53428e1e09022c8a03b475dfd)) +* Ensure new group submissions do not override marked work ([0d7cf91](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0d7cf91a9d6880671f990cae9c64f3fb32ac57ed)) +* Ensure only staff can change group tutorial ([487d022](https://github.com/doubtfire-lms/doubtfire-deploy/commit/487d022cf2e485970ffa1c440cef0f08191295ef)) +* ensure only started projects are included in ilo progress stats ([4673e0c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4673e0c9dd94e38f4b443541406292b80e0cf279)) +* Ensure only tutors with classes reported in convenor summary email ([5560491](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55604916920877fcaa04cf0e6b54456965211941)) +* Ensure password fields checked on user creation ([8cfbdbd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8cfbdbd1a37463ea8491d8e0fc0e20f5aac8f79a)) +* Ensure PDF seek checks file size ([c232ae1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c232ae1dcfc85a6f1d9b144cd4e0eebcafc017de)) +* Ensure plagiarism links are removed on task updates ([7ac5b90](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7ac5b90911c469afcbb4caf1cff4bef1bfe0a623)) +* Ensure populate works as expected ([a43540e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a43540e31f20b239c4cc6597f60c8e797ce886bd)) +* Ensure portfolio can compile ([eb95576](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eb95576a61c5d456af89f1e07e1211f840acc60a)) +* Ensure portfolio compiles if no additional files ([cb0c0e4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cb0c0e4cbdf86f3d069090d850358554c3c97b99)) +* Ensure portfolio creation works with dnr color ([49e21f9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/49e21f9f49dd400242f9513a29765f3c59537154)) +* Ensure portfolio includes additional files ([69b6ff8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/69b6ff841357fa759cdd076d3540cfe40278c8bf)) +* Ensure portfolio zip uses tutor details ([362777b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/362777b0479a30a143fda976d02840261024e34a)) +* Ensure project select only returns distinct units ([449e6d5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/449e6d5fb1321e646cb5a690f9a0ca0cdf65f0c0)) +* ensure project stats are updated when tasks are added or removed ([c3eddcf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c3eddcfa1ece76336b8bfab117a8d5303a26d3da)) +* Ensure relation order to allow unit destroy ([470d04a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/470d04a834040a32d7f1fa322bd4a6ce80577a09)) +* Ensure reply_to is an integer in database ([01935b2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/01935b25725163716d66e20136bed91f31d88181)) +* Ensure resources work with sanatised paths ([06e1374](https://github.com/doubtfire-lms/doubtfire-deploy/commit/06e1374df4bb1e315beb7c448e10ad6a9e9f1df4)) +* Ensure resources work with sanatised paths ([ae5f51f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ae5f51f6eb96d220f165ccb35abce7c9f86e6d62)) +* Ensure rever to pass uses dates ([33820b2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/33820b2c1af914891b0de9604d8e33ea976e8cc7)) +* Ensure revert does not occur at start or end of unit ([67f45db](https://github.com/doubtfire-lms/doubtfire-deploy/commit/67f45db64587b8ee3eec2fa8ddee65fd3dd1cceb)) +* Ensure scans only run once until next upload ([188341f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/188341f1709c90ce78a2ff929de6fe8f3c3176d4)) +* Ensure Star day test is not case sensitive ([c2ab887](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c2ab88797f6654643d897744b1bfce050f2cfc63)) +* Ensure start and end executing use correct scope ([510e2b2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/510e2b278c5805f3f701ee592e53a84fa49be435)) +* Ensure status emails are not sent after the unit ends or before it starts ([5aecd0f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5aecd0f31401846934a3e03097f1b7aac814ba68)) +* Ensure status key is returned in task mappings ([dc7ee7a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dc7ee7a241ec7fe5a749e31f5fe371603513b699)) +* Ensure student download only includes active students ([1465bb4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1465bb4e399213cafa66b3c9ded70ec4ed43197c)) +* Ensure student grades does include student id ([e76c5ea](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e76c5ea3368b2ba66237c54a67400f7b35ab6f3d)) +* Ensure student id is added if not present ([dae20a7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dae20a706035b01b8fda17e7cd5818c5d0cf3ae0)) +* Ensure students from the right tutorial can only be add ([54a574d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/54a574d17c09bb0c4705d624572d4f2d4f6b9aeb)) +* Ensure students only get feedback emails if option selected ([421bd28](https://github.com/doubtfire-lms/doubtfire-deploy/commit/421bd281cf14191918190fd6cc7590e39dba100d)) +* Ensure students without tasks are include in unit student list ([b9fd92d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b9fd92d6348d5721c1d3928a7efb613093e8229a)) +* Ensure target grade used in task stats on student query ([85c3e82](https://github.com/doubtfire-lms/doubtfire-deploy/commit/85c3e821b2411cd4fb29531f968dda5b31a8bfa4)) +* Ensure task assessment csv works with streams ([b0a09e3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b0a09e3516e74bbd9922dbd141619630704fb835)) +* Ensure task completion CSV works when no tasks for one stream ([8f18139](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f18139548529ea25870b3754553657f0460cb5a)) +* ensure task ILO stats only include enrolled students, and round values to 1 decimal place ([7839925](https://github.com/doubtfire-lms/doubtfire-deploy/commit/783992543657f9fabbf49b1b976a1b7c804e160a)) +* Ensure task is stored before using it in endpoints ([04188dd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/04188dd3f68ef0dbbc148f2795174c528363e289)) +* Ensure task sheet can be viewed ([6303f57](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6303f57a21d4137f5a31e9ac3c577a5fb3667ce1)) +* Ensure task sheets are copied on definition copy ([f863e7e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f863e7ecc927ba0d0626786851f8d2ef3fbfabd8)) +* ensure task stats have correct values on new student enrolement and remove progress stat values ([97592e1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97592e184b75cd4895e8619405dac93f0eb00e12)) +* Ensure task status id in selection for task inbox ([8f34f68](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f34f6813a4d7bfa2037f9e975ba66ae065b0ca9)) +* Ensure task status is not case sensitive ([87f4cd9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/87f4cd93832122d04877d93c2f361cf93e43fac7)) +* Ensure task status only notified to students ([9c3ed72](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9c3ed72f99eddcbd5cc2f250bd42308749557fcc)) +* Ensure task uploads work when no group set ([3954a1d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3954a1db5a9e492ffd9396cc1777d26010ab51eb)) +* Ensure task validation does not need due date ([17c28c0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/17c28c01bb40fc7d7201505013010b55c1ef7f23)) +* ensure tasks can be switched using demo for demonstrate ([ea09dda](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ea09dda900e52d47ec764e02b5eb9c70fa724d30)) +* Ensure tasks have dismissed similar count ([cd1c674](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cd1c674e927637a30734c2b761aa839b0f04846c)) +* Ensure tasks un/pinnable only by tutors or convenors of relevant unit ([f3b5dca](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f3b5dcadc619b18bd0b6926b9c741b7bf090a621)) +* ensure tasks with assessments can be destroyed cleanly ([e322b8d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e322b8d21bbc99a63a928ef9b5b6b7e4bed34bc8)) +* Ensure teaching periods can be retrieved without auth token ([56bed7a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/56bed7a26eb0d1ff6cadd560033a7e59c0ccc292)) +* Ensure that activity type cache works ([2d9fa42](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2d9fa423efdb0b8518098214e3c3daacace56c82)) +* Ensure that add text comment is used to add automated comments ([4c2e6d9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4c2e6d9e125ba6156a2d537d9d20d30d1fabdead)) +* Ensure that additional files dont include "." ([c3c35c0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c3c35c03ffe7b6fc3877e2dbe8167d0778d39512)) +* Ensure that all escaped latex text removes non-printable characters ([3a2f6e2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3a2f6e2aab9bf47228471601c9642cde03bc21d9)) +* ensure that all group members can see comments ([ca56e2e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ca56e2e596e5b525c8342ca22df4975dbef58b58)) +* Ensure that campus is correctly synched for units ([9386073](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9386073dd1009b4480ec9b4bb77e64b1a62474af)) +* Ensure that capacity adjustment works on group import ([55611d6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55611d663a67a16cda802571fefeabdeb5547b1a)) +* Ensure that code files are UTF8 in portfolio creation ([1c2cd9b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1c2cd9bc51e0c1637ae1619cf1297281cba0958f)) +* Ensure that comment recipient uses correct email ([7d36e8a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7d36e8a59c0fd59a45278b15c2c454b6112cb560)) +* Ensure that convenors can create tutors ([eedafb3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eedafb3b11f2ed4e810077b800943ca00b655347)) +* Ensure that CSVs are ascii without BOM ([34fd070](https://github.com/doubtfire-lms/doubtfire-deploy/commit/34fd0703289861c60685f20221fb984142570169)) +* Ensure that date changes for teaching periods are propogated to units ([e99401a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e99401a864f5c90a4d4633c6c448d970d633d528)) +* Ensure that deny records a extension response ([f941bb3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f941bb3d33f291b9411c7ca2875d11c4f2ee19f3)) +* Ensure that Doubtfire logger does not rotate ([ceeed68](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ceeed686f096fc6024ffcb502d9f52d9b43a5067)) +* ensure that end of week processing updates both discuss and demonstrate tasks ([197899e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/197899e56f7b44085ed14297a18d4fd3476d6f9d)) +* Ensure that grade details can be downloaded with task data ([d96eb75](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d96eb75555f140713a7ba2ee4ee6e9ce98464722)) +* Ensure that grade details can be downloaded with task data ([7df917e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7df917e991dac370a594e4ca0f5e4020694fbf56)) +* Ensure that group status of task cannot change after submissions ([ce1ac7a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ce1ac7a8cae239d4849bca16e4b6952da3348345)) +* Ensure that group submissions do not replace complete tasks ([25dfe25](https://github.com/doubtfire-lms/doubtfire-deploy/commit/25dfe25127c46256cf752b8f8258173be275c7cc)) +* Ensure that importing does not change tutorials if any tutorial present ([cff31e4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cff31e4fd88bff80385098699f745fd0f47f19e7)) +* ensure that init does not add task status twice ([86d9dd4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/86d9dd41605fea50d30a4a07bc742ab123d74b8c)) +* Ensure that languages map to pygments lexers ([0073cb2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0073cb26679c64902b94d79fce1b4f07af1f899b)) +* Ensure that main convenor is cleared on destroy of unit ([2b6eab3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2b6eab3e009d7a1ea0f2902ab00d3cb8d0be543f)) +* ensure that members are restricted to the group tutorial on group change ([12da642](https://github.com/doubtfire-lms/doubtfire-deploy/commit/12da642a30e3cdd5cc556206c76bc09ce702f23f)) +* Ensure that multipel assessments create multiple submission ([5190845](https://github.com/doubtfire-lms/doubtfire-deploy/commit/519084594de385f91245da2501ffbeada9222b38)) +* Ensure that mutli-unit handles withdrawal from one unit while enrolled in the other ([6edc8b8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6edc8b864143d4e5e3d76d2332daea47fc97b0a5)) +* Ensure that new tutorial streams check for name or abbreviation uniqueness ([19df3e2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/19df3e212c1a15e533a566620696e1f2f072162e)) +* ensure that null header on CSV import succeeds without error. ([c6f43e2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c6f43e2426cb0a967a582f2b0f1abf70eeed9875)) +* Ensure that plagiarism data is json in csv ([1591cab](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1591cab171921ef7a8afa44590d18548c65220db)) +* Ensure that role for works with nil user in project ([33cf184](https://github.com/doubtfire-lms/doubtfire-deploy/commit/33cf1845b32ca31653e98ac3ac73d01f3113aafb)) +* Ensure that save of task definition raises any exceptions ([a7b833e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a7b833e2157752c579ec1602b3b60ef8ea19260e)) +* Ensure that status emails are send with better date range ([4c19983](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4c19983e540ed0ddc8459a2465b023093d94d385)) +* Ensure that student CSV import promotes enrol over withdraw ([e9e289e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e9e289ec9e335ba16fea5b788bedc1edfe3a6c32)) +* ensure that student project query only returns active projects ([a6b9215](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a6b9215d126704f13b283b0d385f8aa55864d716)) +* ensure that student tasks only includes tasks that students will attempt based on their target grade. ([6275455](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6275455bf67b3826b73ffb34d5333a7868eae349)) +* ensure that students are in a group before updating task state, and allow student to change tutorial when in a group (removes them from the group if restricted to same tutorial) ([d1c2fba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d1c2fbaf761b2081f0d1df4901c70767acf9374d)) +* Ensure that students can change tutorials ([85b2b22](https://github.com/doubtfire-lms/doubtfire-deploy/commit/85b2b2232aaecc673ffa3e316f2c6c299eff6fb1)) +* Ensure that students include those with no tasks ([1d92a74](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1d92a740a48580b7158abf363ecf76118b629e53)) +* Ensure that task can return zip path when definition group changed ([2ac1ca3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2ac1ca3460911802acfac214aa0eb9ce8f2648c8)) +* ensure that task definition CSV contains is_graded for import/export ([b7d388c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b7d388c15ba568f2bbea71935d10e61dde401559)) +* ensure that task outcome alignments can be updated by students even if a task id is not provided -- we know it is related from the link details ([3b6b43b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3b6b43bf3513a28bffd831d3520155fb7d7bfba6)) +* ensure that task resoures and task sheets can be uploaded if the task folder does not yet exist ([d9593b1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d9593b16e7cfbcd54747df25d0d0b5be39ccea9e)) +* ensure that task transition triggers work with fix and include as well as do not resubmit ([5f60fd3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5f60fd38959bd8031406f2718f126e96afb00fad)) +* Ensure that tasks can have -1 quality pts ([1b4a1fb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1b4a1fb7245ec790a123ba410f03a55f1cb18a80)) +* Ensure that teaching period destroy is graceful ([56018d2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/56018d2cb8cb97b7cc4a0c6b4157437599f5e7a7)) +* Ensure that tutorial enrolment changes are present for project validation on enrol ([3e20a40](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3e20a403680979fd25cee9805374bc80b4092ad9)) +* Ensure that tutorial enrolment checked on for enrolled students ([9ddf35a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9ddf35aaac2b87e6dea18402ccfe62d142bf2e57)) +* Ensure that unit can add student when multi code ([88711d1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/88711d1329dd18a97076276c7d86dd7017c21f73)) +* Ensure that units with tutorial enrolments can be destroyed ([56a82c7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/56a82c7e8c562ba9d505f540ab95f18c3b3350f7)) +* Ensure the server pid is only removed if it exists ([0c2c522](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0c2c52261a3a28b9c30967e03db082256533d592)) +* Ensure time exceeded can be resubmitted ([434f00a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/434f00a90ad1fa3a7431ee8649fae87c54011875)) +* Ensure titleize retains text where possible ([7285f2a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7285f2aed611a2daee893d0463389e7c292ce54a)) +* Ensure top tasks are correctly calculated and displayed ([9358c9f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9358c9fca6e7cad29ba8d157d63134b1829d6bfa)) +* Ensure tutorial abbreviation works on group import ([2f0c636](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2f0c63630260409ea32f1ea22b2faad5d1c4c52a)) +* Ensure tutorial change check uses project ([a531e2b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a531e2b2a2cb9ee7dc9ee2d1444920fd7393e4e8)) +* Ensure tutorial enrolment changes validate project ([bdd4642](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bdd4642481f5201d7e68aa0cf43dbee2b2497055)) +* Ensure tutorial returns id when no streams ([f0d368d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f0d368d031a099ddc3deae0b154cb35207ce9521)) +* ensure tutorial validations raise errors on creation ([1c9df6c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1c9df6c1a89f050053f24c1dae022e9e70940c5b)) +* Ensure tutorials are correctly linked in task completion CSV ([f58dea0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f58dea018dd856a24d0d87144680a6f1dd735c1b)) +* ensure two newlines between comment text in assessment comment ([3dd7021](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3dd7021ea5c1bc1329f674669ef7f95a6f96fb2e)) +* Ensure unit role tasks awaiting feedback matches details from unit ([8396d36](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8396d36c69c6f1444929e94b7aae4c711b8c8dea)) +* Ensure unit shows students when no tasks interacted with ([9c47175](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9c47175a3cf7ec790705503ee0b27dbc1e2111ed)) +* Ensure upload group can update group details ([a85a10d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a85a10d0b87034f58b8649615e4e0049782d5e34)) +* Ensure upload of users works with xlsx ([bf4a7d0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bf4a7d06727df86bbd6f2f4a6aa33a6c61ca7c37)) +* Ensure upload task definitions uses exported CSV for xlsx ([8f80197](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f801979c3babab37a59a611eb319636aac8e70d)) +* Ensure urls are encoded for tests ([0245aca](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0245acaa3410b23cd88e12923df23117154ac070)) +* Ensure user notification preferences must be set ([f5da5a1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f5da5a15152446b74b75ce2a9be2c0180b0b7158)) +* Ensure we have the campus for tutorial sync ([e288d6b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e288d6beb143e649a0ad79cd7f85609260e2fc8d)) +* Ensure withdraw on sync checks if student is enrolled ([48574dd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/48574dd7a227ca3db5689b91ad3671ae633826af)) +* Ensure withdraw only occurs for existing users on sync ([8f5b307](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f5b3077ff9553f21500edf8b1593cb1cf48024e)) +* Ensure withdrawn students are correctly reported on csv sync ([bb757a8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bb757a855d080e1d0848a6c4979f38ecb769266e)) +* Ensure you can delete tutorials without enrolled students ([ed28c43](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ed28c439b1ec17c902fee7a4106ef27fa1920232)) +* Ensure you can remove a tutorial from an unenrolled student ([1340b55](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1340b55d9c463ef8c20207beed2451b8dfbfc6b3)) +* Entend burndown 3 weeks past the end of the unit ([5a9108f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5a9108ffd02301c9803b9029b0111b9e6f998a41)) +* Error reporting fail in portfolio evidence ([152ea6b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/152ea6be20dd036e5c0f6b18da321959c08b0785)) +* Expand search for EOF in PDFs ([8125a54](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8125a543d5b3a805a24cb10fe0d16fa6817ce52f)) +* Expose postgres port for database container ([243ac9c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/243ac9c41c51c1a84183fa5af8bbfc850035c8f0)) +* Expose user id of author and recipient of comment ([aaa3ef3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/aaa3ef323749b0df0427d0e2fef0be6896a079ae)) +* extend the size of the description for an ILO ([5003ebc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5003ebc0d3b7baa196984f31a4d7a9f0519912c0)) +* extend the size of the description for an ILO ([a7338fa](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a7338faf3f506caff3213353e4ba6092be2cae00)) +* Extend time for ffmpeg conversion ([888f0a2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/888f0a2a4332617e7a2f0b07588ea0a8b7764233)) +* Fetch loop for single project/unit users ([71a2d60](https://github.com/doubtfire-lms/doubtfire-deploy/commit/71a2d60e5802a2d1b0357c0f3c305ca7926dcafe)) +* File permissions for overseer to operate upon ([e3708aa](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e3708aa03f0fb273a0642b7544115b842d97e8d7)) +* Find correct task definition when updating draft task definition ([125a9f1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/125a9f11e867a2eb23f120484646dcfc641b6071)) +* Fix allocation of number to group ([905b519](https://github.com/doubtfire-lms/doubtfire-deploy/commit/905b51918670459faa1e7245d8c3003f88091999)) +* Fix bug where grading a task was not persisting new grade ([331cb79](https://github.com/doubtfire-lms/doubtfire-deploy/commit/331cb797f5de924f4dafbad4c63138d55d3fecd6)) +* Fix buggy serialisation of student nickname ([55aa5f4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55aa5f46f472de340bef147709bfdbaac31686fc)) +* Fix corrupted web deploy in 45077dd ([ff49ce0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff49ce05cb0041e2557f539e2b71360846d57aeb)) +* Fix cyclic stack overflow when referring to self.logger ([38386ae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/38386ae3d360ba8aa7a71a3c7246ecd47b1ddade)) +* Fix dependency of last group if no groups ([9c602ff](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9c602ff138d000afca159b62afad94cb0c0fc97f)) +* Fix deprecated all call with order ([dd94fc9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dd94fc96389fb4c1ef7a81b3e0567aa5fba24b89)) +* Fix discussion variable names ([8585be8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8585be82f344a1b07ab6512da5705875ae202a21)) +* Fix error in string formatting for test_auth_put ([7ae25c9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7ae25c973023859c109141aedaee4059ccd3d91b)) +* Fix error with generating unit data properly ([014874c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/014874cbf38a9bf75ac24183acbdd087a83c4ef9)) +* fix errors ([092dc1c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/092dc1cd3f35c1c0faf0cd450f451739e66f1de9)) +* Fix get all tasks and comments ([6e775f8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6e775f809a6d2b702c8d3a628cf54ac0d43ed18b)) +* Fix incorrectly replaced find_by_auth_token ([c9c5790](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c9c5790bf7e2d057d8f6b850a366f3df333c0fc4)) +* Fix indetation issue ([1eda74a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1eda74aa71967e022c2b964f3c51b23201891165)) +* fix indetation issues ([352eb29](https://github.com/doubtfire-lms/doubtfire-deploy/commit/352eb29bbfba3e7606be42500f479b7441753ad4)) +* Fix installation script for Sierra compatibility ([229ca9b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/229ca9b25dde384f60bafeba15d68d16e7bc8fe7)) +* Fix issue with getting units from get /api/units ([893e9dc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/893e9dc7cbc8022455d7d375f39e00bc2b1e456a)) +* Fix issue with webcal primary key being defined as an integer upon `db:schema:load` ([9c6c147](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9c6c1476772d20237837a45023c970d76bf07d47)) +* Fix manifest merge issues ([06146a0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/06146a081a4fe2551b332f12bbcf19bfe42f3bae)) +* Fix name and ensure file helper test uses temp dir ([0848a97](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0848a97c9f1c884e91ae32bf606b7e31892335eb)) +* Fix populate rake task for unused extend argument ([076a45a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/076a45aa9ab09e3db35fa38541718d4b7512d355)) +* Fix problem with sorting ([f3556b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f3556b090e3f79a255c2945755496d3d58909830)) +* Fix rubocop breaking portfolio PDF generation ([3cc865e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3cc865e1797284c6666eaec6f71d3e3bd3531db3)) +* Fix rubocop breaking portfolio PDF generation ([647f247](https://github.com/doubtfire-lms/doubtfire-deploy/commit/647f24713d9aabf3e2e7c049edda9517adb10cc0)) +* Fix rubocop syntax change which breaks authorise? ([7fb3967](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7fb3967982d5b176740c2c6db33c782ebe49235b)) +* Fix spelling mistake in API error message ([38f76a1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/38f76a1982c474746dd16472db0308781606e00c)) +* Fix submission with extensions for group tasks ([22a5c0e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/22a5c0e42427197f88cc0dbd3758dd0d762de572)) +* Fix switching group tutorials with group members in it ([4332598](https://github.com/doubtfire-lms/doubtfire-deploy/commit/43325983bd6722131183e3beaf2d7d80da403f2a)) +* Fix task comment_by field to user name ([b749253](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b749253b0fb707393b9a70774dc568436bb41e24)) +* Fix tests to user appropriate data in feedback test ([2684fc6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2684fc61d243f218e0e0c24345e52ef3c6e7a041)) +* Fix tutor permissions to download statistics for units/users ([eee7b6c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eee7b6cde6b10e6d1995d2c465a1358d62487518)) +* Gemfile.lock revision for bunny-pub-sub ([c5b137a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c5b137afe473e4a23da4857f81eb87b715dda904)) +* Get campus from tutorial ([afe357b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/afe357be96713bc7e17327358a7c4b60a0459cc4)) +* Get the campus from tute in expand first unit ([ebc1d21](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ebc1d216f50f378cb85cba86040f5f24b8ae842c)) +* Get the campus in the tutorial enrolment factory ([fdfb24d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fdfb24d54481d36ff6cee8ee7af5dc80c35df84b)) +* Get tutorial and tutorial enrolment in target grade stats ([9e6367d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e6367d6882dd77e1c74a3fc7592d42cc49beb3c)) +* Get tutorial for all tasks ([1a50d92](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1a50d9257623aa2c2bde31dff3a54e650b33e31c)) +* grade range in task completion stats ([2af36ed](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2af36edfc0b432cc730a5bae7e6068e5e7e2ebf3)) +* Group capacity checks on reenrolment check beyond capacity ([0b939a6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0b939a680ec58c1fe02600b3117906a9dc87b8b3)) +* Group factory names to enaure unique ([7e65fa4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7e65fa4d2569c8197b426c617241b7b2cd903a81)) +* Handle additional grape exceptions ([90119e0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/90119e02553d3e582a35212e3e1311d0ed7baf90)) +* Have generate pdfs check pid of process ([aebcbdf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/aebcbdf94cc8ce906ba6d1adef7098dbf6a2fedf)) +* Helper name ([551727e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/551727ef164ad2879e8512a02f35ac580e2f9a2f)) +* ILO progress summary stat calculation ([a025c98](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a025c98e71afcfb03cfeb50db4c07df728a3c45d)) +* ilo_progress_summary will not return an error ([a761592](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a7615928f443d8592af31e6fbbe5a356d844e213)) +* Implement WIP fix for tutorial delete ([c72cbb5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c72cbb52e93cef5fab31d1dfa5d67f3dd2c7fefd)) +* Include _current_ units in webcal ([e7ee874](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e7ee8744da398bb0458445c8c0fe0eb4d34a39b4)) +* Include :has_task_assessment_resources? ([6975790](https://github.com/doubtfire-lms/doubtfire-deploy/commit/69757903ab217f4f96efdb6f9c095523ffd03953)) +* Include delete frames and use jpg on compress of images ([8c8bd27](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8c8bd278c7772de7329598445b5f9b457a682793)) +* Include only tasks of the user whose webcal is retrieved ([b2d97c9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b2d97c990f0f82031f613d95af693a1a65c4c6c1)) +* Include Rails `ID` in `WebcalUnitExclusion` entity ([869b416](https://github.com/doubtfire-lms/doubtfire-deploy/commit/869b41696f9c035e8aefc1cfeb66d9caa1fdeb91)) +* Include time exceeded color in portfolio creation ([e4956b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e4956b039b988462fa392e0aab825f5f4b41815a)) +* Incorrect return in task transition ([dbd2794](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dbd2794c3f6d21e237a605094fe77f4b631b494b)) +* Incorrect use of .nil ([41be46c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/41be46c014dd709a4eecd7b00f670be7cb571cb6)) +* Incorrectly using environment variables in config YAML ([9c47948](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9c47948522c42f068fe8b95af94fdc1af0619558)) +* Incorrectly using environment variables in config YAML ([04c870d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/04c870d1f76db2d8216b5eb724f5769629db4d2d)) +* Increase teaching period length ([65a45e0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/65a45e06be1eab4aec3367ea61bf9a3d7fb3eb07)) +* Increase year window to 1000 ([0bb730a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0bb730a4b0327b176dec48a343ed5d3d9bc9e694)) +* Increment times submitted only if status not ready to mark ([af0611a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/af0611ac39b33b3fcf0aeb0a86c8557bd8103c6c)) +* Install ffmpeg which is required by newer api server ([d40ec4b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d40ec4b2ff0f51ee3e159489c73c267aba7fcb07)) +* Issue exporting grades with not started tasks ([b4781b2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b4781b270856ec45ad6fc60df182587d2709ae85)) +* Issue exporting grades with not started tasks ([5083cc8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5083cc809d1a76f5da7734d67a96899671983f11)) +* Issue with comments for not submitted tasks ([f6d19ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f6d19ce37bae9a8a4bdb46a9a6a42837fb3eb1fb)) +* Issue with directory check ([ecbd14c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ecbd14cbe7291887fa50bd5c8c9ed9aa473e3d02)) +* Issue with import of student campus data ([3ddf25d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3ddf25dad166d63fce29acade0984098dcb48884)) +* issue with importing tasks from CSV due to column consgtraints ([8f0a0b8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f0a0b802cf0f870fc6674039ef57cfb87dff2cd)) +* Issue with main_tutor if student tutorial has no tutor ([1f3158c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1f3158c15221d45e5cf54dbd7b7693943fb8e71f)) +* Issue with re-uploading evidence ([6ed5cdf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6ed5cdf7bef3614ab6e944f59a749c58139fadd8)) +* issue with task completion stats when low submissions ([6d3e930](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6d3e9309cf03f81021aa5bdac1b7c6bb83fe0748)) +* issues identified by lack of tasks for student projects ([08c9807](https://github.com/doubtfire-lms/doubtfire-deploy/commit/08c98078824409771dfec611e6090eb4f760bf9f)) +* issues with ILO progress stats. ([8b3fb82](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8b3fb822a0a04ec738ceed527244cc493d0deee1)) +* issues with unit_role on student CSV import ([7ce89b9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7ce89b951bac76e4d559c82755c4e2883af91e4d)) +* Join tutorial enrolment in task completion data base ([8047aad](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8047aad3c8e7abd1110be7a29d08da97f0fadc0d)) +* Join tutorial enrolment when task def matches tutorial stream or it is null ([4d9f213](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4d9f213bb9bb8f8a08c6881821b3dac2f42b4dcf)) +* Join tutorial enrolments for student ilo progress stats ([4ccbd08](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4ccbd08f8f132429cd1feb3b894981659ca3a993)) +* Join tutorial enrolments in get all projects ([ceedf80](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ceedf80df88682e549c09bbb2e64ee53451dc1e5)) +* Let config.overseer_images exist without OVERSEER_ENABLED flag ([56e6874](https://github.com/doubtfire-lms/doubtfire-deploy/commit/56e687422e9987858058b078688603f0835d4b04)) +* Limit project list to only enrolled units ([462a9c4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/462a9c4d8144c85280349d26626ea9512789e8b1)) +* Limit the access of return teaching period information ([1929e94](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1929e943f4bdc5e85a43c7f7416e2069072ac82e)) +* Mailer url in notifications ([eed80c7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eed80c788920e278b474fea659e1a8018f9a7844)) +* Make action_date use last student comment date ([8a3e1e6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8a3e1e6d5714559148f23c24f3d15ad4e21ca3d3)) +* Make campus and activity type factory unique ([fbb32e6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fbb32e65a3b2b99efe351cbd9977ec8669d6026c)) +* Make delete of group member consistent with POST ([f14822b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f14822b516ad86ae9fe2718f86eeef77f09c5df7)) +* Make docker-compose working again ([29abae8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/29abae8d9978a1fd2216b8491f9615a8337b916c)) +* Make grade's default value nil not 0 ([d0b4149](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d0b4149a74408ed9ad5e35e43b0edd7bb8fb6feb)) +* Make login_id nullable ([cb8ce3a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cb8ce3a2ab2d19c4538849a93029cc06226f1a4f)) +* Make plagiarism_warn_pct optional ([031d624](https://github.com/doubtfire-lms/doubtfire-deploy/commit/031d62414a2b7076966f522f8cc6ea5aa57a0417)) +* Make sure combination of year and period is unique ([cc0d900](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cc0d9007721b7a2f9e3393eac9d9c3c7081bdfef)) +* Make sure that only admin and unit convenor can rollover unit ([118430f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/118430f3b8be5c31577e00f57e791ebd47754f31)) +* Make Teaching period hash require instead of optional ([33566fc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/33566fcdcc308e5442ee57ed6299e46c5d851702)) +* Make use of new id to status in task ([46500d5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/46500d53fea7f26068b5782d5248e93333036493)) +* Mark discussion comments as read when replied ([e8f603d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e8f603d1aae169dac9ae4a41ebe0e3c50cc4cd8d)) +* Mark discussion comments as read when replied ([1b36acc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1b36acce420deaccf00b91ec84969d96a07d271a)) +* Max one validation when tutorial stream is null ([dfdfae8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dfdfae8fb643898d6c42d9854c2feeae7f6c9ae6)) +* Merge in UI fixes for portfolio, alerts, and filters ([7dec01b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7dec01b9fed0c087e1f70b45051ddcc32f810083)) +* Messages from task processing fail ([f6e86b3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f6e86b364581e25afcef191a645dafb06ae6214a)) +* migration that removed students from unit role -- cannot use project link once unit role code removed ([b779a0a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b779a0a0d1d0cf9c0c26930f601888b2e11b8067)) +* migration that removed students from unit role -- cannot use project link once unit role code removed -- attempt 2 ([b87b67b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b87b67bf65b723a8b514790c2b3e85f94309e16d)) +* Minor issues with UI for task status and plagiarism ([f077f61](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f077f61acf4e6b386b38754ea668d18dcecdabd0)) +* Minor UI fixes for task signoff ([97300c0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97300c0e7caafe6abf7bb9d8d542ee3f280b1222)) +* minor ui issue related to portfolio not showing in grading ([c86b312](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c86b312fa9871fabd16276a2c8ea06f828c6997a)) +* minor ui issue related to task url location ([1ce5ee4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1ce5ee493b9a35b02e8b75e908097f7d829010a9)) +* Minted output to add line breaks and change tab size ([c73373c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c73373c36a6f2d7a0734cf4c6418e5c4876766ff)) +* Missing `sm_instance` method error ([94fd461](https://github.com/doubtfire-lms/doubtfire-deploy/commit/94fd46166a4bfe96ce988f323c309d040a6fe73e)) +* missing details for fetching a unit role ([a0c2c91](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a0c2c91c03eba98b11608e815ba5e346748a6f40)) +* Missing do in mailer action ([722bf19](https://github.com/doubtfire-lms/doubtfire-deploy/commit/722bf19a79db271a2fd1589690664b9c59cdc785)) +* Missing end in unit sync code ([70910b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/70910b01cbc2a25886f51f1af5ee2f9f0f7bc66c)) +* Missing reference to grade parameter fixed ([5b73228](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b732286e4f6462160452abeaf51b6aff550ac1d)) +* Missing user in non-AAF POST /auth ([c8214d7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c8214d7a9d2699e6a9b16bbbd5ec21d633db7909)) +* Move convert output params to before out file ([80f4aef](https://github.com/doubtfire-lms/doubtfire-deploy/commit/80f4aefec8e15ecadb1833c6e6f316df42178971)) +* move reloads of tasks on group submission ([221b1f8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/221b1f8bd97c4dfd4538bd9495c425e9662403dd)) +* Move the roll_over method to unit.rb ([9e604c9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e604c96d25a792c3fcd5fa5234001c2f1166254)) +* Move the rollover unit API call to units.rb ([486483d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/486483d87042279d572b639353aa70a6e626d7a9)) +* Move the teaching period info to settings ([5b3a305](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b3a3050580d0d056930250d402814574348edc2)) +* Move tmp files out of tmp ([d1af91e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d1af91ef9c84b6bd344b00062d89333f723247fa)) +* Move todo comment on top ([ebc6912](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ebc6912d1f925947e759a02ae162852104bc093d)) +* Moved dev only gems to dev and test ([21465f2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/21465f2830676e3fcc84525fe7c4bef38102033c)) +* Moved email validation to User model from User API ([3c6871a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3c6871a1868cb40e3dd45df461a24d433e823d6c)) +* New assessment API bugs ([0f4273c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0f4273c3a39d8fd8d0117720f9fc3e18c076ae84)) +* Only check that require AAF config vars are set ([9f3c167](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9f3c16782c1e5609a0a5fbaf80beb3284a3b051f)) +* Only send tutorial stream in task def if it is present ([073124d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/073124d36b12dd364e73a5802989b2fba06f49a7)) +* Only use password field in User if AAF is not enabled ([e0ea3d7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e0ea3d7fa29c92652fd0b78b5a277493e6d78fa6)) +* Output to cron on skip generating to help identify cases when processing fails to run ([8f8f9ff](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8f8f9ff85f1222ffdfada8aa395e3bf02987dbea)) +* Overcommit excludes ([218d6bf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/218d6bfe6dcb8ed0e40f718d397e3bda6d7732b0)) +* pass week paramter to grant_exztension ([bdb225c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bdb225c756944e9d35c6efb20f30d6d024df5d68)) +* Patch issue with text comments ([7d2ad8c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7d2ad8c82853ceeb5147e6bb6763454091e97f7b)) +* Permit `tutorial_id`, `capacity_adjustment` params when updating groups ([a80501c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a80501cf58d0748d58a7c24cebd1e3ef95f0153e)) +* Portfolio creation to support more outcome names ([c2cca19](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c2cca1961c89f5b9ff4dba51335244a612265998)) +* Portfolio creation with odd abbreviations ([4d1ac3e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4d1ac3e356cd0c7fb249663def7f2785e69c47a9)) +* Portfolio review step UI ([abe9ff1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/abe9ff18dd3f66c1ecd0593d604d18bae8e7f358)) +* Portfolio review step UI ([606d1f6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/606d1f6b91b9a3ff9ff1fbef85ebaaa8e5c94244)) +* Prevent different institutions from signing in ([2adc19b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2adc19b34f82144f447569757c015d78a23d9ed5)) +* Prevent group member from being added to group twice ([a578a19](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a578a194072bb865a98866bfbd6c75b77d524d14)) +* Prevent member from being removed if not in group ([02e823b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/02e823b4f8e0d92554400d15f4772f69e6cd8553)) +* Prevent submitted grade update after portfolio submission ([797765f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/797765fc1073d2fd2360ea44c527d7f3968c1093)) +* Prevent task def check when id is null ([091b7d7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/091b7d7dc9d25d50e67d68a0f5fb5345cff82031)) +* project serialiser if there are 0 tasks ([619f329](https://github.com/doubtfire-lms/doubtfire-deploy/commit/619f32953fbce240321a3db6409e0711a7ce198e)) +* Project task stats when not started ([9cbf395](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9cbf395c3a8aa7cbad9a2b6f3181b11667766c1b)) +* propagate grade updates then update current task's grade. ([ed11d61](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ed11d612725e80253de7384ca144b6f827ea5dc9)) +* provide a new tasks csv with start dates added ([4fd3fbd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4fd3fbd6b32d063467f597db571f7a109cd5a8ec)) +* Provide current user via stubbing first user ([7b35118](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7b35118b8e3087dbd51c961c6c6d73e68361c27e)) +* Push lazy project tasks to project.tasks if created ([1acc7c4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1acc7c4bb74a8a2851cecd3d757ee7793d55bb62)) +* Quotes in application.rb ([d55ec13](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d55ec134928bc5994dd4539d2144c71532e9b9b9)) +* Raise error when break is not added ([884b6a9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/884b6a92d57679f407cc000a7fdcba26e6e0651f)) +* Randomly stub if a task is new for a user ([5f33bff](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5f33bffb27c00c1c8ba2df3a45e85b4af8bdec68)) +* Read CSV data from file outside of CSV parse ([ef90adc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ef90adcadc5eff526988887d6d685309df5bf88f)) +* Redirect users back to front end after validating JWT ([78d3e6f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/78d3e6f8f081064a817473dd6f387e12373523ff)) +* Reinstate seeds from master ([31376f3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/31376f3ca333d5cc307d1d46b735e08be82a8f5b)) +* Reject authorisation where role_obj is nil ([1eb9e2c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1eb9e2c4e1c650eecac7fbe973c65480d8a12ce2)) +* Remove add_teaching_period from teaching_period.rb ([23bac26](https://github.com/doubtfire-lms/doubtfire-deploy/commit/23bac26b44669248349049fdcbe470d6afedb172)) +* remove additional awaiting signoff references ([4af4603](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4af46036259a881f76fc2790d40669db2d0f8a39)) +* Remove an unavailable gem minitest-osx ([899671f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/899671f54317f49e5ae29d97d10bec81913a021f)) +* Remove an unavailable gem minitest-osx ([6aa5d5d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6aa5d5d399a15e7cd6ccb9430cf0c6a48360079c)) +* Remove attachment comment type from task comments ([ff473b6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff473b61e3b281a091d0deed1d307da1c072e5c3)) +* Remove bad auth method calls ([455e9a0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/455e9a0845e1bf715b6c0c3f6c694500aa2a7f5a)) +* Remove bad table drop from migration ([2d9021f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2d9021f365800cc6309bc0bd41a4f32dad0354bc)) +* Remove belongs to relation between project and tutorial ([839cc76](https://github.com/doubtfire-lms/doubtfire-deploy/commit/839cc76720c4025be472c48210c5107c1d7e3c8b)) +* Remove breaking empty test ([97a9c3d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97a9c3df1025aa7587f4c5fce5929fe2271a8ebc)) +* Remove bundle exec rake db:setup step ([fcc7e26](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fcc7e267227a5d6238fd817d1d480b71f9dc3082)) +* Remove bundled with command in Gemfile.lock ([1227d40](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1227d4010566614ee53af7a178cd493682b941c9)) +* Remove calls to puts for debugging ([c588c6b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c588c6b4e34da4e36f21d482cb6b323d6b12002d)) +* Remove campus from tutorial sync search ([c461968](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c4619680005b3e4b47d8448d430952019616bf4f)) +* Remove ci/reporter/rake/rspec from Rakefile ([0b0dff8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0b0dff840e139ccf49d77d20831a4c887bd1ced8)) +* Remove clutter from submission API ([15f0361](https://github.com/doubtfire-lms/doubtfire-deploy/commit/15f0361d8a15dd8885a0dfc082ac5af9e1f8ec02)) +* Remove comma from Get all teaching periods ([ff39c42](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff39c426555bfd1bdf10869deca6c78923773783)) +* Remove creation of any user on init ([de2af7a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/de2af7a92b1ce6d3bcb09e332f0b4b28e401a3eb)) +* Remove destroy tutorial enrolments on tutorial delete ([8ce1afc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8ce1afcc8c5c68f57eb949458bf83738e132b159)) +* Remove duplciate count method ([8765903](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8765903971f276c1dc8b29b2f5942719f992108e)) +* Remove duplicate task inbox ([8cddbea](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8cddbeaf1a9a0512e879e87a0331ea016241c1f2)) +* Remove duplicated libmagic-dev ([e829554](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e829554f1d8349d91ceafc23da1f2822e2bcbeea)) +* Remove empty .gitmodules file ([ff99a7c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff99a7c47e0f5e68c53740c0799107842d493da2)) +* Remove extra param to fix circular dependency warning ([6dd5d4b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6dd5d4b2169db8d7a1df9512360e7a3990631db4)) +* Remove git modules to fix Travis error ([a18d7a1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a18d7a1f9096a003b7c004f2bafb1b966965909e)) +* Remove group number from group import ([d99aaf1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d99aaf16a22e8d7caa6f029bd3c5f82ffd84d2ef)) +* Remove has_draft_summary_task from project serializer ([25eb27b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/25eb27b6e93fc18f970d890725a982dc0fc7863b)) +* Remove limit on stats for low student numbers ([fe9e395](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fe9e39588853e9cd6e25377c547a20843b4496c6)) +* Remove main tutor from PlagiarismMatchLink ([f9bb866](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f9bb866a8b84fb18ab6b62328142f92b9abb9ee1)) +* Remove main tutor from portfolio evidence ([5ee4f8f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5ee4f8fe6ced9a724a562573ea3616a95d0a4c86)) +* Remove main tutor in test cases ([39acd18](https://github.com/doubtfire-lms/doubtfire-deploy/commit/39acd182a5810420f75d4a0857f28b6a386e526a)) +* Remove multiple loggers as it is a helper to the Api class ([ad140ea](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ad140ea8527f06fb20fcdcbfab2291f09aeac47f)) +* Remove need for cached similar to and task stats ([50016ba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/50016bac69c7c830a10730142285e8a32b426940)) +* Remove need for PDFTK ([7b9cb00](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7b9cb00a5e8f35e3c15daa0e54284cba9d55a162)) +* Remove need for PDFTK ([08f456f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/08f456fca8277d6b93068803124e28da75331937)) +* Remove non-printable characters from comments in portfolio ([cfe0947](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cfe09477e28d11f387c1a16092287eb723864a6e)) +* Remove old code that copied files into place ([639e12f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/639e12f9209b628caaa30ae69b1e30bccbe0ed50)) +* Remove old spec ([185df83](https://github.com/doubtfire-lms/doubtfire-deploy/commit/185df831ad4f3d7e54e6147add361605aec90ae1)) +* Remove old Task serialisers to fix task comments read in projects ([8e8299a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8e8299a677951ac9f25684a768d8d24a95268f99)) +* Remove output from db helper ([5f5f14f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5f5f14f57b69f88ffc878776934be353db48e904)) +* Remove paperclip extension from gemfile ([bc049b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bc049b08d9598bb394eadf408c22fb0ce358f9b1)) +* Remove populate command from Dockerfile ([142ff28](https://github.com/doubtfire-lms/doubtfire-deploy/commit/142ff28ea9dc23a9a75fcf1107ae7d185b588db3)) +* Remove populator and faker gem for production ([0f15cca](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0f15cca92bcf8b7941832270993280a5ea954966)) +* Remove portfolio compression ([eb28fa2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eb28fa26f170c611c34bcdc7478a524952e9fa9a)) +* Remove puts from task def post ([3580f30](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3580f30ac6bfb768749ca1a6e1018baf814498c0)) +* remove reference to awaiting signoff in enrol student ([9f7ddf5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9f7ddf599909f48766328cbf9dd7bdc9ef5b4e88)) +* Remove referer check due to inconsistent browser behaviour ([2c91834](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2c91834691283bbe900235696c37bbf6ee78b801)) +* Remove replica environment ([29c19ca](https://github.com/doubtfire-lms/doubtfire-deploy/commit/29c19caa3d744b6a689c2d78888bcb93fe7c0850)) +* Remove student from group when withdrawn from unit ([4f2b72f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4f2b72f073717063a4389834709d38161cc461b6)) +* Remove symbols to use values correctly ([95e4e41](https://github.com/doubtfire-lms/doubtfire-deploy/commit/95e4e416eb1440ffdda22ef5547f8cf324ac822a)) +* Remove symbols to use values correctly ([ea764ad](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ea764ada5f7408495cc9631d4e44db21115757a2)) +* Remove the authentication for teaching period information ([17b3f6d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/17b3f6db8b5f50df43bb9be423b927bfbb53f5c7)) +* Remove the extra assignment line from add_task_definitions ([394a4af](https://github.com/doubtfire-lms/doubtfire-deploy/commit/394a4af778d6800f2a5d7523a97c1b619afd71cf)) +* Remove theunit associations from teaching period ([21915b8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/21915b81492215d8043ffa568d550307024a6770)) +* Remove tracing code from teaching period ([3084182](https://github.com/doubtfire-lms/doubtfire-deploy/commit/30841826e5ffe082bbed191b28f1e1e03a9398b2)) +* Remove tracing statement from image compression ([47e0674](https://github.com/doubtfire-lms/doubtfire-deploy/commit/47e06742c4bb4bf7d1fa28206dec1683f373f5bc)) +* Remove tracing statements from tex debugging ([3b33542](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3b33542f38cb04319d8176bfcd8285437ac2a6ab)) +* Remove tutor name from project serialize ([e0603c4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e0603c4669020844dc41a7bbae49bbeb8ba115e8)) +* Remove tutor name from projects api ([9d89d47](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9d89d47417b3bd3dc33e0726a193ebdc18302e2f)) +* Remove tutorial email and main tutor from email ([60fb2e8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/60fb2e8a3c8717f233269392f74ae882cbcde3e8)) +* Remove tutorial id from project serializer ([60b28b3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/60b28b33d9b546a017ba655dce1f05cdcfffa21c)) +* Remove un-needed code from project.rb ([f3f84e8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f3f84e85b25959e66d3a91e1643afa3c719e63c2)) +* Remove unnecessary test code from previous commit ([528c897](https://github.com/doubtfire-lms/doubtfire-deploy/commit/528c8977fbd9e77fbd5d523532f732d5aba8f975)) +* Remove unused 'notasks' ([0be129f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0be129fa3f52250dbb442b7ace28d5516d2ed34d)) +* Remove unused replica tag from gemfile and install ([8001a3f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8001a3ff84f05bc9f97a347d20768741b97530fe)) +* Remove unused replica tag from gemfile and install ([0dfd46c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0dfd46c84a17d894c86823e8d297379fba76ea0a)) +* Remove unwanted spaces ([96b6a3f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/96b6a3f2fa9641698f0c2c199d1ea7409121bc26)) +* Remove unwanted spaces from task.rb ([711eda2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/711eda2d6f0aaaceaac18667112b8d60b4de30dc)) +* Remove unwanted spaces from units API ([6db3971](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6db3971df027c30f2a18eb15252fa4e70b9959d9)) +* Remove unwanted spaces from user.rb ([0e3e095](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0e3e0953e3138230983bdecb52b830049ba60bd5)) +* Remove use of Student Project Serialiser ([642ff86](https://github.com/doubtfire-lms/doubtfire-deploy/commit/642ff86bfa6ec109896118c9e96eee4c0daa5e72)) +* Rename column group_number to number ([c83a62c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c83a62c1b5e1736b2ae32d95bbe1bf3febed7812)) +* Rename setting to config.serve_static_files in production ([c811da8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c811da8183ca0565a048775b39337966a0e1b56f)) +* Replace all Doubtfire references in the erb files ([f9c5f41](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f9c5f415016f8799796941ef8f2814cfada6a4f9)) +* Replace assert with assert_equal ([efed739](https://github.com/doubtfire-lms/doubtfire-deploy/commit/efed739d8ff63a9899d1203720f16326216f3344)) +* Replace has_one relationship with belongs_to ([5848588](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5848588f7844495a1f2593ac56601d5c46a8fb83)) +* Replace main tutor with convenor in mailers ([1b5f9ae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1b5f9ae8c3382d3669e7e71df45f9cf6eb3fd0cf)) +* Replace main tutor with tutor for task def in erb ([6a505cd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6a505cd063c2e0e3d88f79078c72ccee2da8a616)) +* Replace not equal with greater than in max one tute enrolment ([59d1d89](https://github.com/doubtfire-lms/doubtfire-deploy/commit/59d1d8944ce8e5ed0efa7925d6ea8e0ef0b13aa5)) +* Replace OnTrack with the doubtfire product name ([0d1239a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0d1239ae8ba560bcc89d34c0429a864a0fd39516)) +* Replace roll_over with rollover ([696e495](https://github.com/doubtfire-lms/doubtfire-deploy/commit/696e495e6460432fb3b6884b7c090fdedd523292)) +* Replace type with activity_type in the model ([8b5536d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8b5536dc5a754146c26a1b2ed9c9f940e5c59245)) +* Require bunny-pub-sub in all environments ([bf75374](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bf753749b99a58e2af5731b8519c54b5fa7a5d3d)) +* Restart postgres after Linux installation via setup script ([9dec0bf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9dec0bf6d1c770f625a174c4ac1ba50db7ae1ee8)) +* Retry code convert on fail using ascii ([1e22eca](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1e22eca2954f795ff7138f3ea5206c16f9649921)) +* Return error if replying to invalid comment ([045e2f4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/045e2f4d64c46081b38ed5c7c29b00c494d7f434)) +* Return main convenor when tutor is nil ([39ddce5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/39ddce5686c38069b40172868bff342ec010c2bb)) +* Return result in teaching period information ([30a6b2e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/30a6b2ea2de3549f737aa99a594f5e286013d7a4)) +* return start_date for unit role and project. Move authorisation to helper location ([a36e934](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a36e93400cdec04d527ce7f7e88a68c86f4df37b)) +* Revert "EHHANCE: Modify api to support ..." ([41ba3ec](https://github.com/doubtfire-lms/doubtfire-deploy/commit/41ba3ec279a7605f14f4e2a985271f56946fc0cd)) +* Revert changes to Gemfile.lock ([30e7e41](https://github.com/doubtfire-lms/doubtfire-deploy/commit/30e7e4199eff2d42d0ff346c947a4e70d253e2fb)) +* Revert changes to schema ([22a58f1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/22a58f1ac22d8c84a44d2dcc9aeb6fbc66f54d9a)) +* Revert error related to submission date ([623e5ec](https://github.com/doubtfire-lms/doubtfire-deploy/commit/623e5ec64b4ff7d926c6364469535e48c2624249)) +* Rollback all db changes ([4eb5351](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4eb53516feafae51076f00fe1d5de684665c2d82)) +* Routes param description for docker_image_name_tag ([eec9ed3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/eec9ed399407d1b2d7fd9bd4f8e8e06ef4faea88)) +* Save the task def after current break ([1de336f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1de336f07c2291bfd0b07842c55f1daa6eb70f89)) +* Serialise main convenor id ([f93054f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f93054fe5edb9bb8058a13a713ed57612dfb9dd7)) +* Serialise main convenor id ([ce45dae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ce45daed0cd3d3180379c3023b23ceda0d620e3e)) +* Serialise main convenor id ([d627e34](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d627e3401d0b137c35655584653e95be29b56751)) +* Set content type of discussion audio responses ([c4b9aef](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c4b9aef545d5f7b4ad03eeba3ff5c149447b8b48)) +* Set default RAILS_ENV in Gemfile to be development ([b16943b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b16943b5fa9f1147c88b2fb6f71f7dba52c1383e)) +* Set default units.assessment_enabled to true ([d2b1c89](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d2b1c89211daf4d746d1b753bcbc7dd40b924507)) +* Simplify creation of streams based on activity type ([724255b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/724255b077ebd2ab417bbf0b52a66c8df70fd564)) +* Simplify fetching a tutorial ([1959196](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1959196fe24b484c94440613893cc477af4f6c66)) +* Simplify test for validation of extension ([1010eb0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1010eb0387297c0bd7709809092185cf08e02cad)) +* simulate signoff to better represent real usage ([e2b79db](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e2b79db36b09add5bc0c7946c89aaf729e0e7fbe)) +* some performance issues and add task completion stats ([bfb002c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bfb002ca98b318c0e64994c1c56f0f7b885b81ea)) +* Staff count in tutor for task test using new factory ([7d93e9b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7d93e9bf148fbc2d74531243d3dfa7db512a01e0)) +* Start and end dates for weekly emails ([e0fdedb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e0fdedb4bbc09eb35b3b978f7650f1dd40d65eb1)) +* Store the campus created by Factory girl inside unit factory ([fc5dc11](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fc5dc119ab76df50efbd033dc4c4564f8c626c33)) +* Strip path till submission history for easy mounting ([c3d43b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c3d43b01c1a4d9fdc2b68ca048c4a364bdf03f4a)) +* Strip till /doubtfire-api instead ([e0b599c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e0b599c9012fcf7f24d4166e22119e4d23d27103)) +* Student returned on enrol ([13d7b91](https://github.com/doubtfire-lms/doubtfire-deploy/commit/13d7b91ba606a3b505c33401b8f47287359f1e45)) +* Submission date check in burndown data ([1aef3e5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1aef3e5513a262c3d797421f8fbc3ee9326af877)) +* Submission for needs help sets submission date ([32641a9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/32641a96af0b432aa1232a0a12cc752c3a4f63e0)) +* Switch gsub in lesc to ensure running on text ([6084c45](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6084c454aaef28c2db66874c184574a7cd36863a)) +* Switch Portfolio generation to Latex ([c0de443](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c0de443d8b312e49d86f147486de22635e0514d9)) +* Switch queries to fetch tasks based on id rather than name ([e24248b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e24248b3d3a2b2b29de302eb167071134f045e03)) +* Switch rollover to use day/week for task defsSimplify the logic to make use of the date for week and daycode. Going forward this should be the way oftracking task dates. ([38cb540](https://github.com/doubtfire-lms/doubtfire-deploy/commit/38cb5408476ee76e690b5642369f27c385086469)) +* Switch task definition to use docker image model ([68212eb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/68212eb7093cdb4b36b678e8125a770cb64e29b3)) +* Switch to error code 400 for known client errors ([70a42a0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/70a42a0c903f7281ea897059a0c834bc44098333)) +* Switch to hashed resources to help keep clients up to date ([1bfedbf](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1bfedbfeaa165678fb9770e8daa0253eaeb10ecb)) +* Switch to lualatex to better support UTF input ([65ba465](https://github.com/doubtfire-lms/doubtfire-deploy/commit/65ba46569a203920fb8a55eba3f4493cef07c91b)) +* switch to rails logger in log helper. Ensure failures in pdf generation are written to the terminal for cron to email admin. ([662cc4c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/662cc4cd369cf37ff9ccb5e16e11e7bc8d748e16)) +* Switch to unoptimised and hashed output ([3b47d0e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3b47d0ed59a9e60faf456646557d93cde7e4209a)) +* Switch to use due date in burndown chart ([46f4b9e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/46f4b9e29da9920e5226347edef86bc73dffa12b)) +* Switch to use new portfolio creation with latex ([abccb6a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/abccb6a1ed74cd13e3ef50b2728c6f87962d3080)) +* Take dates either from period or manually ([3946574](https://github.com/doubtfire-lms/doubtfire-deploy/commit/39465741ac9d21f260694cd3c8ea0211ab7013cd)) +* Task comments generation ([91eacae](https://github.com/doubtfire-lms/doubtfire-deploy/commit/91eacae50a5b5989e975034421bf65378efef963)) +* Task csv columns to match student task status ([bb534f6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bb534f643fc1897b88ae67668f752cf2970cbace)) +* task for task definition to use object ([339ba2b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/339ba2bd8bf176f5c8e24a3c0b2c5640c45ca7a5)) +* Task inbox to return unique with multiple streams ([0e2eaab](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0e2eaab16dc59e329a4e2366eac49a6ed3365de7)) +* Task PDF importer report success and be more flexible ([fa3d667](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fa3d667ef53e9a85706c574e6f57eee70fbf9d72)) +* Task status changes bug and limitations ([2b4b09b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2b4b09bc4d4a7e85f5c16015790ebc58bdbda5bc)) +* Task status error ([01a0f9e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/01a0f9edceae11674898abf1700f055ba20d97f9)) +* Teaching Period create method ([8dd2681](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8dd2681c20093944d9034c0a572c00f4623ace7f)) +* Teaching Period create method ([9f1dfbd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9f1dfbd7f114626f013f629f72ba203edc71b6fc)) +* Teaching Period errors ([23b25df](https://github.com/doubtfire-lms/doubtfire-deploy/commit/23b25df97d7f11e5e92c2004ca193e9174aaad97)) +* Teaching Period Indentation ([b5c96ed](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b5c96edf4a5a2ec247b923dd263d3e651c29134f)) +* Teaching period typo throwing error ([9df4c96](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9df4c96cc06fa60905135766dc67d0f119655bf4)) +* Teaching Period update return value ([0456ead](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0456eadc282d90f31d733e553e05503ad9ab8107)) +* Ternary operator in plagiarism match link ([595a65a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/595a65a45a74e36b5b262aa67090de8ca4bc631d)) +* Test for file upload in task def csvs ([1939c91](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1939c91377df961582d6ab99a751048e3238b741)) +* test moving file into place ([fe0681d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fe0681d5c40965f64ac481255106bbeb340bbf49)) +* Test task csv with new columns ([5f893ee](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5f893ee47812fd85e8d5a582e0ad23b254e45420)) +* The error statement in add break ([6c7eb01](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6c7eb01d2d14e288ab6cba6f21f00beb443184ef)) +* The preliminary line in the summary ([4b7694a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4b7694a836880ce62c21e216f099070ece0ba58c)) +* Timeout of token should redirect to sign in ([bb9b0d8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bb9b0d889cbf9150203b50a87141c450b0e4f174)) +* transitions on tasks propagate even when no prior group submission -- assumes even distribution ([b2a53d0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b2a53d0af7ab072998fcf65cfaddfe5912b767b6)) +* Trial CI PDF creation fix ([e5f8248](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e5f8248dcc27189c56c4bbf29f4ba3ffee3e14bb)) +* Tutorial check in groups validation ([27b4835](https://github.com/doubtfire-lms/doubtfire-deploy/commit/27b4835702fed90debb41cc722b8b4bd956b2508)) +* Tutorials without streams mapping in student query ([2341ba5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2341ba5c9a071bbbf3a14a24062e9af47dbd6fb7)) +* Typo in application.rb ([9e8e930](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e8e9301e6a437f8b7705eacd059ea19c9c92599)) +* Typo in grades url on frontend ([23521d0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/23521d0fce2b87bccdadb45adbc61572ef17c405)) +* Typo in grades url on frontend ([aea3829](https://github.com/doubtfire-lms/doubtfire-deploy/commit/aea382992e1f46e2de0bd1452bd3e2a2aba88a01)) +* Typo in Tasks.rb ([dfc3517](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dfc3517569f76176fb2b50e5379b84c30f176345)) +* UI fix to add missing enrol modal ([8655809](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8655809daee6f6294a35cf5c861a159ac880adc1)) +* ui issues related to pdf viewer size, and pagination on grading tab ([96fb606](https://github.com/doubtfire-lms/doubtfire-deploy/commit/96fb6066171bfa5a8e23465383d51220cd3476d1)) +* Unit dates accessor ([0aeb4a8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0aeb4a8f595bb7f9410b0bcdc4d434eab45192a5)) +* Units start date errors ([204a0e1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/204a0e18d73410a00d29a43b21951c13694a5095)) +* Update ./setup.sh ([c693a88](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c693a880c8062e1a38f2596ab920967a8715617a)) +* Update ./setup.sh git source for rbenv ([5ad2a64](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5ad2a646044dc32c3a21a59e030158d3c3ce7167)) +* Update adding and removing to active group members ([bb0d405](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bb0d405f65d4f37c766a2c36baed704092a9220a)) +* Update bunny-pub-sub version ([6e3cbe5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6e3cbe557959e28823b558ec7bdc514beb4579bf)) +* Update bunny-pub-sub version in Gemfile.lock ([971eb80](https://github.com/doubtfire-lms/doubtfire-deploy/commit/971eb802a37871296228fdfca02a8347f85f1fa6)) +* Update calculation of task status ([b2cfef1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b2cfef162070f72786fb2110f7c6f1727570467d)) +* Update callista data import format ([6dd489e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6dd489e8e0d059c4287e58d1f915af08edfeb971)) +* Update compress image use of convert ([ae597d9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ae597d956bb38c62340c692143ecf2208c6039a0)) +* Update config in compose to include production ([941ea1c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/941ea1c2b760f5a4fdcda9f80632f16471efcfca)) +* Update database schema ([dd5dbf9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dd5dbf91d1e63fb217dff576b348f0ee8ba1f8e4)) +* Update docker-compose run command to use rails ([97c45df](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97c45dfefd13a1063d5c3e082c9e2b112cf7c754)) +* Update error message in unit details editor ([3f7b71c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3f7b71c6517be74d7dbf5c9a6635eeb6ac8f1e08)) +* Update exception handling to capture exceptions ([5086a19](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5086a19c0b6a7d97515686ad23dbf67c54e62af4)) +* Update font awesome ([b509be3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b509be32cff51deea1765579fb3734e424bd6d45)) +* Update get campus to avoid duplicate error ([27fc53c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/27fc53cb1812367878d9c77c40d056fc4eeb48e5)) +* Update message before stats in html email ([3662193](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3662193abd70d018bfa74faaeac2051383e9c5d7)) +* Update message before stats in text emails ([be0cff3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/be0cff3fbeb04c7a9e8ec615b6c1b64a06e4d680)) +* Update overseer actions to work with new structure ([96836d2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/96836d200dbcdc9bc1c11e4e99dc09e71890b7c4)) +* Update overseer config to use new fixed settings ([a520b64](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a520b64a53e026caa8828777ac998fd5218a01f8)) +* Update path checking in files for submission test ([295d7f6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/295d7f6c4ff6f5e8529e4cfe097d5c31a3a1ac42)) +* Update PDF viewer api ([a0ead5a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a0ead5a3abb0a112bc64e20d579b205b14e5550f)) +* Update production server API URL to root ([ba8f9b9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ba8f9b97e59d2469d31d079a1b887415d436d43a)) +* Update production server API URL to root ([34d4d99](https://github.com/doubtfire-lms/doubtfire-deploy/commit/34d4d991e6af705d7c67e98040a0dc34615dfa6f)) +* Update some log messages to include additional info ([7406ee2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7406ee2ea6ca6039a1729ca048e5c524e1dd3e91)) +* Update sync script for importing students ([b11d9cd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b11d9cdbd90b1224ce26e766fd49df85a7d6b422)) +* Update task completion csv with group and stars ([0f74fa6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0f74fa607e65ba9dd3d90ba0bf431d5e5c23f64a)) +* Update task dependencies ([c59bc20](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c59bc207f7c046d068b13bf08ece5276e78e11b0)) +* Update task returns a value to indicate success ([e1243cc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e1243cc52a4462d893fdc2a726a2ed645b96ad04)) +* update task stats to have fail and demonstrate ([1a4a43a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1a4a43a37e68e3ce92ed5634835f84e3d2be90a0)) +* Update task status trigger text ([813c31c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/813c31c64ffbe04f1c5632e8be99025a1ba3af9d)) +* update task status weights for ILO progress calculations ([ca9c1fe](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ca9c1fef5b8d1127f639c9eb45cbfddb518bfe7b)) +* Update text size to 4096 ([b0c989f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b0c989f15c1a13ecb34bf38f0a7f8deed45d8c1b)) +* Update tutorial enrolment factory to ensure unit and campus are same ([8dde407](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8dde407b18f701126a5cd356b1f709ca82b6851a)) +* Update tutorial should not stream ([138af9b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/138af9b617181adadd3eeac2468dfbf2be9cf6ab)) +* update UI resolving duplicate enrolments ([96aa913](https://github.com/doubtfire-lms/doubtfire-deploy/commit/96aa913e20b1116440ddfe39d9afe4cfe47536ec)) +* Update UI to accept text code files ([db34f90](https://github.com/doubtfire-lms/doubtfire-deploy/commit/db34f909dbaa005712209d34b55cbf60a1567556)) +* update ui to fix issue with unit creation start dates/times ([acc8446](https://github.com/doubtfire-lms/doubtfire-deploy/commit/acc844619f1f5fe9c74d032782f150be49be2416)) +* update UI with changes to fix issues identified with task admin ([9014afd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9014afd29dc86640ef896bffd8809ddbbc79c029)) +* Update UI with fixes ([5535199](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55351992232ba0f3dd73b0dbc3b274bbcfe70552)) +* Update UI with fixes and new portfolio process ([e74c02b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e74c02b64375b82d660feecdafc16429af8ecf87)) +* Update ui with fixes for publishing comments ([9238bc5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9238bc5cb99b73732b6c34cfcbd6afeab126b0f8)) +* Update UI with fixes for the Task list ([ab03ede](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ab03ede4e7ce9e46cde4343c2fe08040bb5b6643)) +* update ui with fixes from usability tests ([cb0a19e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cb0a19e2f039c6b89d349a4eb9262a554ab5bc81)) +* update ui with fixes from usability tests ([7c8dab7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7c8dab76e5b951fd8de89b35988765e17b822a8e)) +* Update UI with grading improvements ([8932381](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8932381024f558162e81b6a1e675c6afea1311e2)) +* Update UI with gview or object for PDF ([91b7347](https://github.com/doubtfire-lms/doubtfire-deploy/commit/91b7347fbd8224d26f24bc29934f2b6fa6c76c19)) +* Update UI with new portfolio assessment pages ([2253a9a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2253a9aba85c7530fc0cb4e47882a622210ee1ad)) +* Update UI with new task assessment details ([95864b1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/95864b1408e4f74907bbb3dc3fba1ee4c2839ca7)) +* Update UI with new task assessment details ([5c9a53d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5c9a53df73f84055ebeea51abc3a30d69b3e395e)) +* Update UI with safari pdf viewer ([5aa4842](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5aa48425e7cb58668e25dd4bef21a798b1196112)) +* Update UI with task pdf upload changes ([9900f1a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9900f1a17d6105fad92084f938c1bdcaec8ed808)) +* update UI with unit dates for home page ([ec4afc0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ec4afc04ba10bd8201f793821ad1ee92a0f31506)) +* Update urls to use https in mailer tasks ([c68e13c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c68e13cad521fdad64dedc4bc88eb8d4cd159c94)) +* Update web command to npm start in docker-compose ([554be09](https://github.com/doubtfire-lms/doubtfire-deploy/commit/554be09f9e1277b10493032e2724b2f01c6e1b32)) +* Update weekly student status email. Only if no portfolio ([94a7d43](https://github.com/doubtfire-lms/doubtfire-deploy/commit/94a7d430099d9870e8ddadbbeb807427aced52e5)) +* updating grade calculates project task stats ([b1b925d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b1b925d5c9190b2410866784a78211fbc2999b54)) +* Uploading new files replaces existing files in student_work/new/task_id folder ([a8a4563](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a8a4563fa372479e4b61c4a9646d19753e711702)) +* Use auth_helper methods in api endpoint tests for auth ([9bed401](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9bed4018fed3a2caa00677ce62a2782931ff565c)) +* Use campus instead of campus_id ([17f464e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/17f464eed680ccdd5d184eff51a5a83d1580bcef)) +* Use correct host path for cover page ([bee7dab](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bee7dab964017c9d42e05a6983f5d55fb5ee5a05)) +* Use correct institutional host for mailers ([2497802](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2497802e7fc060af7599b72cec1e64aef2393463)) +* Use correct key for plagiarism file data ([b937d9d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b937d9d35b79de89b6570257c5c6f8408ebfe03d)) +* Use correct uniqueness validator in activity type model ([9292051](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9292051ec529bf9002c996e83c68701e63aa9a56)) +* Use correct values in methods ([248cf69](https://github.com/doubtfire-lms/doubtfire-deploy/commit/248cf692245f4079b4e7aa78761b8b419c1a125c)) +* Use correct values in methods ([4d92357](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4d92357aeb61e011b3b85b416077f4fe8786de00)) +* Use cross institutional domain for default user ([de06416](https://github.com/doubtfire-lms/doubtfire-deploy/commit/de06416d016d3db51e621850719bbb762db77717)) +* Use Doubtfire logger as rails logger ([b6e4a6e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b6e4a6ef47dee7007d372fc1062dbe3bd95140a2)) +* Use file helper directly from within user model ([c104110](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c1041103db20d720d07a53b5fd195ac7c7ac2953)) +* Use first administrator email if no convenors ([0f445f8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0f445f8de9f29012b56e2a853fc8b75b2f1fe243)) +* Use id lookup for another case of status lookup ([37c2b0b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/37c2b0bebd6b48af31757061e1b1b2efc40b78d8)) +* Use institution name for task and portfolio generation ([92269b4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/92269b4c61ea0032a6761551e6800566f76811ac)) +* Use just the filename for the image in tex ([2bce49e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2bce49e6371581a501828339b7e62c8357f0c2a1)) +* Use name instead of nickname for comments GET ([4d56c26](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4d56c26ce385ae4029ccf9a371ab1e6cd8257c43)) +* Use name instead of nickname for comments GET ([cb58d46](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cb58d46c85ffb5248e9ee9c2c815030756472b81)) +* Use new to create Breaks ([55a2280](https://github.com/doubtfire-lms/doubtfire-deploy/commit/55a2280ad746c4456ad635e7086fee11469bc173)) +* Use outer join in get all projects ([baad4bd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/baad4bdf0adb03052994e80ecee5d430e99808b3)) +* Use single query to retrieve webcal tasks, fix webcal MIME type ([8253e15](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8253e15a82dc076e7f53e3d9957b865ef0e284d4)) +* Use time_created of receipt for read time ([86ba70d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/86ba70d6c4bfa81c8e7bbfb92b85ebb99f810129)) +* Use tutorial enrolments for checking group tutorial ([d5cb9ad](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d5cb9adcb9cbae046946f5940eb9c4c0c60338db)) +* Use unit for enroling project in a tutorial ([7e33418](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7e334189dbf207e5e7b4f76f420877fb3324bdae)) +* Use updated enrol student for populator ([71e9d59](https://github.com/doubtfire-lms/doubtfire-deploy/commit/71e9d591e7e3e37e150de2a512a203510230321b)) +* use validations in tutorial creation to ensure tutorial abbreviation is unique. ([3855591](https://github.com/doubtfire-lms/doubtfire-deploy/commit/38555919cb00cedd113a8dd93c8ea5d6926e5395)) +* Validate exactly one of teaching_period or dates must be present ([0edaaef](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0edaaefd5fed3be95c1229237e0b2f3f9782cd7a)) +* Validate text length in rails to avoid sql errors ([7f78668](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7f7866883d1156cf2d4e7c76e1c809cb51a85fca)) +* Validate that active is true or false ([d71c944](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d71c9442121a0aedc562ce7d7fa0e4158425a43d)) +* Validation failed msg in break not colliding ([f7a9cce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f7a9cce2d1f0d3c0fddc0cb6a3c4fd48aa420bc3)) +* Validation for docker_image_name_tag now respects nil values ([23408f7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/23408f76b6d173ff5e36c8a8072096f234e8af1b)) +* Validators for docker_image_name_tag ([372fe97](https://github.com/doubtfire-lms/doubtfire-deploy/commit/372fe97235584a291b4a8ddb97d109d4cfe50595)) +* Variable name ([5303ceb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5303ceb48be6d9e768a67560b84bd5a8e20c2f7c)) +* Weekly Email Summary ([145a48d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/145a48d6e2ad81eff1fb8e15df0bdb17aa5127d9)) +* Whitelist Tutorial.find_by_user ([e74760f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e74760f4627720738e0b0c71b4ef891d1b073077)) +* YAML Docker image name, disable image ([3fe985b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3fe985bc6fc8b223eb9e6f8c5927d0def4612f64)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d65743b5..f2f5c5018 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,400 +1,25 @@ -# Doubtfire Git Workflow +![Doubtfire Logo](http://puu.sh/lyClF/fde5bfbbe7.png) -We follow a [Forking workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow) when developing Doubtfire. +# Contributing to Doubtfire Api -## Table of Contents - -1. [About the Doubtfire Branch Structure](#about-the-doubtfire-branch-structure) -2. [Getting started with the Forking Workflow](#getting-started-with-the-forking-workflow) - 1. [Forking and Cloning the repository](#1-forking-and-cloning-the-repository) - 2. [Writing your new changes](#2-writing-your-new-changes) - 3. [Prepare for a Pull Request](#3-prepare-for-a-pull-request) - 4. [Submitting a Pull Request (PR) to the upstream repository](#4-submitting-a-pull-request-pr-to-the-upstream-repository) - 5. [Cleaning Up](#5-cleaning-up) - 6. [Workflow Summary](#workflow-summary) -3. [Branch Prefixes](#branch-prefixes) -4. [Writing Commit Messages](#writing-commit-messages) - 1. [Prefix your commit subject line with a tag](#prefix-your-commit-subject-line-with-a-tag) - 2. [Formatting your message](#formatting-your-message) - 3. [Use the imperative mood in your commit subject line](#use-the-imperative-mood-in-your-commit-subject-line) - 4. [Subject and body lines](#subject-and-body-lines) - -## About the Doubtfire Branch Structure - -![Feature Branches](http://puu.sh/lP4eT/43f3131730.png) - -We try to keep two main branches at all times: - -- `master` for production -- `develop` for current development, a branch off `master` - -That way, we follow the workflow: - -1. branch off `develop`, giving your branch one of the prefixes defined below, -2. make your changes in that branch, -3. merge your branch back into `develop`, -4. delete your branch to clean up - -In some cases, your branches may only consist of one or two commits. This is still okay as you can submit a pull request for code review back into `develop`. - -You may want to branch again, e.g.: - -``` -* master -|\ -| \ -| | -| (b1) develop -| |\ -| | (b2) feature/my-new-feature -| | |\ -| | | (b3) test/unit-tests-for-new-feature -| | |/ -| | (m1) -| |/ -| (m2) -| |\ -| | (b4) fix/broken-thing -| |/ -| (m3) -| /| -|/ | -(m4) -| | -| | -* * -``` - -Here, we: - - 1. branched off `master` to create our `develop` branch, at **`b1`** - 2. branched off `develop` to create a new feature under the new branch `feature/my-new-feature`, at **`b2`** - 3. branched off `feature/my-new-feature` to create some unit tests for that feature under `test/unit-tests-for-new-feature`, at **`b3`** - 4. merged those unit tests back into `feature/my-new-feature`, at **`m1`** - 5. merged the new feature back into `develop`, at **`m2`** - 6. found a new bug in the feature later on, so branched off `develop` into `fix/broken-thing`, at **`b4`** - 7. after we fixed our bug, we merged `fix/broken-thing` back into `develop`, at **`m3`** - 8. decide we're ready to release, so merge `develop` into `master`, at **`m4`** - -Note that along the way **we're deleting branches after we don't need them**. This helps us keep *short-lived* branches that don't go *stale* after months of inactivity, and prevents us from forgetting about open branches. The only branch we kept open was `develop`, which we can always branch off for new, un-released changes again. - -Ideally, any changes that are merged into `master` have been **code-reviewed** before they were merged into `develop`. **You should always code review before merging back into `develop`**. You can do this by performing a Pull Request, where the reviewer can see the changes you want to merge in to `develop`. - -## Getting started with the Forking Workflow - -### 1. Forking and Cloning the repository - -#### Fork the Repo - -To get a copy of a Doubtfire repositories on your user account, you will need to fork it *for each repository*: - -![Fork the repo](http://puu.sh/nxPqN/68e50046d2.png) - -#### Clone the Fork - -You can then clone the repositories you have forked to your machine. To do so, navigate to your forked repositories and copy the clone URL: - -![Copy the clone URL](http://puu.sh/nxPy9/a360d7c755.png) - -Navigate to your `projects` or `repo` folder, and make a `doubtfire` folder. Then clone using the URLs you copied above: - -``` -$ cd ~/repos -$ mkdir doubtfire -$ cd doubtfire -$ git clone https://github.com/{username}/doubtfire-api.git -$ git clone https://github.com/{username}/doubtfire-web.git -``` - -#### Set up your upstream to `doubtfire-lms` - -By default, git tracks your remote forked repository (the repository you cloned). This remote is called `origin`. - -You will then need to set up a new remote to track to the `doubfire-lms` owned repository. This will be useful when you need to get the latest changes other developers have contributed to the `doubtfire-lms` repo, but you do not yet have those changes in your forked repo. Call this remote `upstream`: - -``` -$ cd ~/repos/doubtfire/doubtfire-api -$ git remote add upstream https://github.com/doubtfire-lms/doubtfire-api.git -$ cd ~/repos/doubtfire/doubtfire-web -$ git remote add upstream https://github.com/doubtfire-lms/doubtfire-web.git -``` - -#### Ensure you have your author credentials set up - -You should ensure your git user config are set and set to the email address you use with GitHub: - -``` -$ git config --global user.email "my-github-email@gmail.com" -$ git config --global user.name "Freddy Smith" -``` - -#### Use a rebase pull - -We also want to avoid having merge commits whenever you pull from `upstream`. It is useful to pull from upstream using the `--rebase` switch, as this avoids an unnecessary merge commit when pulling if there are conflicts. - -To fix this, always pull with `--rebase` (unless otherwise specified—see the `--ff` switch needed in [Step 3](#3-prepare-for-a-pull-request)): - -``` -$ git pull upstream develop --rebase -``` - -or alternatively, make a rebase pull as your default setting: - -``` -$ git config --global pull.rebase true -``` - -### 2. Writing your new changes - -As per the [branching structure](#about-the-doubtfire-branch-structure), you need to branch off of `develop` to a new branch that will have your code changes in it. When branching, **be sure you are using a [branch prefix](#branch-prefixes)**: - -``` -$ cd ~/repos/doubtfire/doubtfire-api -$ git checkout -b feature/my-awesome-new-feature -``` - -You can now begin making your changes. Commit along the way, **being sure to conform to the [commit message guidelines](#writing-commit-messages)**, on this branch and push to your fork: - -``` -$ git status - -On branch feature/my-awesome-new-feature -Your branch is up-to-date with 'origin/feature/my-awesome-new-feature'. -Changes not staged for commit: - (use "git add ..." to update what will be committed) - (use "git checkout -- ..." to discard changes in working directory) - - modified: src/file-that-changed.js - modified: src/another-file-that-changed.js - -$ git add src/file-that-changed.js src/another-file-that-changed.js -$ git commit - -[feature/my-awesome-new-feature 7f35016] DOCS: Add new documentation about git - 2 files changed, 10 insertions(+), 15 deletions(-) - -$ git push -u origin feature/my-awesome-new-feature -``` - -Note you only need to add the `-u` flag on an initial commit for a new branch. - -### 3. Prepare for a Pull Request - -**Note, while it is advised you perform this step, it you can skip it and move straight to the [Pull Request step](#4-submitting-a-pull-request-pr-to-the-upstream-repository). If the branch cannot be automatically merged, then you should run through these steps.** - -When you are done with your changes, you need to pull any changes from `develop` from the `upstream` repository. This essentially means "get me anything that has changed on the `doubtfire-lms` repository that I don't yet have". - -To do this, pull any changes (if any) from the `upstream` repository's `develop` branch into your local `develop` branch: - -``` -$ git checkout feature/my-awesome-new-feature -$ git pull --ff upstream develop -``` - -If there are merge conflicts, you can resolve them now. Follow GitHub's [guide](https://help.github.com/articles/resolving-a-merge-conflict-from-the-command-line) for resolving merge conflicts. - -We can now update your `origin` repository's `my-awesome-new-feature` on GitHub such that it will include the changes from `upstream`: - -``` -$ git push origin feature/my-awesome-new-feature -``` - -### 4. Submitting a Pull Request (PR) to the upstream repository +We welcome additions and extensions to Doubtfire that help progress our goal of supporting student learning through frequent formative feedback and delayed summative assessment. -Once you have pushed your changes to your fork, and have ensured nothing has broken, you can then submit a pull request for code review to Doubtfire. +This guide provides high-level details on how to contribute to the Doubtfire API repository. -To submit a pull request, go to the relevant Doubtfire LMS Repo and click "New Pull Request": +Before continuing, **please read the [contributing document](https://github.com/doubtfire-lms/doubtfire-deploy/blob/development/CONTRIBUTING.md)**, as this outlines the Git workflow you should be following. -![New PR](http://puu.sh/nxQu0/77cd489a7f.png) - -Ensure that the **Head Fork** is set to your forked repository and on your feature branch. If you cannot see your repository, try clicking the "Compare across forks" link. - -![Compare forks](http://puu.sh/nyYF5/22d554103e.png) - -You can then begin writing the pull request. Be sure you are **Able to Merge**, otherwise **try repeating an upstream pull of develop into your feature branch, as per the [previous step](#3-prepare-for-a-pull-request)**. - -![Writing a Pull Request](http://puu.sh/nyYEd/8d3c8789a6.png) - -With your PR body, be descriptive. GitHub may automatically add a commit summary in the body. If fixing a problem, include a description of the problem you're trying to fix and why this PR fixes it. When you are done, assign a code reviewer and add a tag (if applicable) and create the pull request! - -If your code is ok, it will be merged into `develop`, (and eventually `master`, meaning your code will go live - woohoo :tada:) - -If not, the reviewer will give you suggestions and feedback for you to fix your code. - -**STOP! Continue to the next step once your Pull Request is approved and merged into the `doubtfire-lms`'s `develop` branch.** - -### 5. Cleaning Up - -Once your pull request is approved, your code changes are finalised, and merged you will want to delete your old feature branch so you don't get lots of old branches on your repository. - -Following from the example above, we would delete `feature/my-awesome-new-feature` as it has been merged into `develop`. We first delete the branch locally: - -``` -$ git branch -D feature/my-awesome-new-feature -``` - -Then remove it from your fork on GitHub: - -``` -$ git push origin --delete feature/my-awesome-new-feature -``` - -Then ensure you are git is no longer tracking the deleted branch from `origin` by running a fetch prune: - -``` -$ git fetch --prune -``` - -As your changes have been merged into `upstream`'s `develop` branch, pull from `upstream` and you can grab those changes into your local repository: - -``` -$ git checkout develop -$ git pull upstream develop -``` - -Then push those changes up into your `origin`'s `develop` so that it is synced with `upstream`'s `develop`: - -``` -$ git push origin upstream -``` - -### Workflow Summary - -**Step 1.** Set up for new feature branch: - -```bash -$ git checkout develop # make sure you are on develop -$ git pull --rebase upstream develop # sync your local develop with upstream's develop -$ git checkout -b my-new-branch # create your new feature branch -``` - -**Step 2.** Make changes, and repeat until you are done: - -```bash -$ git add ... ; git commit ; git push # make changes, commit, and push to origin -``` - -**Step 3.** Submit a [pull request](#4-submitting-a-pull-request-pr-to-the-upstream-repository), and **if unable to merge**: - -```bash -$ git pull --ff upstream develop # merge upstream's develop in your feature branch -$ git add ... ; git commit # resolve merge conflicts and commit -$ git push origin # push your merge conflict resolution to origin -``` - -**Step 4.** Only when the pull request has been **approved and merged**, clean up: - -```bash -$ git checkout develop # make sure you are back on develop -$ git branch -D my-new-branch # delete the feature branch locally -$ git push --delete my-new-branch # delete the feature branch on origin -$ git fetch origin --prune # make sure you no longer track the deleted branch -$ git pull --rebase upstream develop # pull the merged changes from develop -$ git push origin develop # push to origin to sync origin with develop -``` - -## Branch Prefixes - -When branching, try to prefix your branch with one of the following: - -Prefix | Description | Example ------------|---------------------------------------------------------------------------|-------------------------------------------------------------------- -`feature/` | New feature was added | `feature/add-learning-outcome-alignment` -`fix/` | A bug was fixed | `fix/crash-when-code-submission-finished` -`enhance/` | Improvement to existing feature, but not visual enhancement (See `LOOKS`) | `enhance/allow-code-files-to-be-submitted` -`looks/` | UI Refinement, but not functional change (See `ENHANCE`) | `looks/rebrand-ui-for-version-2-marketing` -`quality/` | Refactoring of existing code | `quality/make-code-convention-consistent` -`doc/` | Documentation-related changes | `doc/add-new-api-documentation` -`config/` | Project configuration changes | `config/add-framework-x-to-project` -`speed/` | Performance-related improvements | `speed/new-algorithm-to-process-foo` -`test/` | Test addition or enhancement | `test/unit-tests-for-new-feature-x` - -## Writing Commit Messages - -Parts of this section have been adapted from Chris Beam's post, [How to Write Good Commit Messages](http://chris.beams.io/posts/git-commit/). - -When writing commits, try to follow this guide: - -### Prefix your commit subject line with a tag - -Each one of your commit messages should be prefixed with one of the following: - -Tag | Description | Example ------------|---------------------------------------------------------------------------|-------------------------------------------------------------------- -`NEW` | New feature was added | **NEW:** Add unit outcome alignment tab -`​FIX` | A bug was fixed | **FIX:** Amend typo throwing error -`​​ENHANCE` | Improvement to existing feature, but not visual enhancement (See `LOOKS`) | **ENHANCE:** Calculate time between classes to show on timetable -`​LOOKS` | UI Refinement, but not functional change (See `ENHANCE`) | **LOOKS:** Make plagiarism tab consistent with other tabs -`​QUALITY` | Refactoring of existing code | **QUALITY:** Make directives in consistent format with eachother -`​DOC` | Documentation-related changes | **DOC:** Write guide on writing commit messages -`CONFIG` | Project configuration changes | **CONFIG:** Add new scheme for UI automation testing -`​SPEED` | Performance-related improvements | **SPEED:** Reduce time needed to batch process PDF submissions -`TEST` | Test addition or enhancement | **TEST:** Add unit tests for tutorial administration - -### Formatting your message - -Capitalise your commit messages and do not end the subject line with a period - -``` -FIX: Change the behaviour of the logging system -``` - -and not - -``` -fix: change the behaviour of the logging system. -``` - -### Use the imperative mood in your commit subject line - -Write your commits in the imperative mood and not the indicative mood - -- "Fix a bug" and **not** "Fix*ed* a bug" -- "Change the behaviour of Y" and **not** "*Changed* the behaviour of Y" -- "Add new API methods" and **not** "Sweet new API methods" - -A properly formed git commit subject line should always be able to complete the following sentence: - -> If applied, this commit will **your subject line here** -> -> If applied, this commit will **fix a bug** -> -> If applied, this commit will **change the behaviour of Y** - -and not - -> If applied, this commit will **sweet new API methods** - - -### Subject and body lines - -Write a commit subject, and explain that commit on a new line (if need be): - -``` -FIX: Derezz the master control program - -MCP turned out to be evil and had become intent on world domination. -This commit throws Tron's disc into MCP (causing its deresolution) -and turns it back into a chess game. -``` - -Keep the subject line (top line) concise; keep it **within 50 characters**. - -Use the body (lines after the top line) to explain why and what and *not* how; keep it **within 72 characters**. +## Table of Contents -#### But how can I write new lines if I'm using `git commit -m "Message"`? +- [Contributing to Doubtfire Api](#contributing-to-doubtfire-api) + - [Table of Contents](#table-of-contents) + - [Project structure](#project-structure) + - [Unit Testing](#unit-testing) -Don't use the `-m` switch. Use a text editor to write your commit message instead. +## Project structure -If you are using the command line to write your commits, it is useful to set your git editor to make writing a commit body easier. You can use the following command to set your editor to `nano`, `emacs`, `vim`, `atom`. +Coming... basically Grape API, with Active Record backend. -``` -$ git config --global core.editor nano -$ git config --global core.editor emacs -$ git config --global core.editor vim -$ git config --global core.editor "atom --wait" -``` +## Unit Testing -If you want to use Sublime Text as your editor, follow [this guide](https://help.github.com/articles/associating-text-editors-with-git/#using-sublime-text-as-your-editor). +Coming... See minitest and the test folder. -If you are not using the command line for git, you probably [should be](http://try.github.io). diff --git a/Dockerfile b/Dockerfile index d2f9fb0d9..4b4fd3fc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,35 @@ -FROM ruby:2.3.1 +FROM ruby:3.1-buster -RUN apt-get update -RUN apt-get install -y \ - build-essential \ - libpq-dev imagemagick \ - libmagickwand-dev \ +# DEBIAN_FRONTEND=noninteractive is required to install tzdata in non interactive way +ENV DEBIAN_FRONTEND noninteractive +RUN apt-get update && apt-get install -y \ + ffmpeg \ + ghostscript \ + imagemagick \ libmagic-dev \ - libpq-dev \ - python-pygments \ - ghostscript + libmagickwand-dev \ + libmariadb-dev \ + python3-pygments \ + tzdata \ + wget + +# Setup the folder where we will deploy the code +WORKDIR /doubtfire + +COPY ./.ci-setup/ /doubtfire/.ci-setup/ +RUN ./.ci-setup/texlive-install.sh +ENV PATH /tmp/texlive/bin/x86_64-linux:$PATH + +RUN gem install bundler -v '~> 2.2.0' + +# Install the Gems +COPY ./Gemfile ./Gemfile.lock /doubtfire/ +RUN bundle install -ADD . /doubtfire-api -WORKDIR /doubtfire-api +# Copy code locally to allow container to be used without the code volume +COPY . . EXPOSE 3000 -RUN bundle install --without production replica +ENV RAILS_ENV development +CMD rm -f tmp/pids/server.pid && bundle exec rake db:migrate && bundle exec rails s -b 0.0.0.0 diff --git a/FETCH_HEAD b/FETCH_HEAD new file mode 100644 index 000000000..e69de29bb diff --git a/Gemfile b/Gemfile index c31fd43d7..375b3271c 100644 --- a/Gemfile +++ b/Gemfile @@ -2,74 +2,89 @@ source 'https://rubygems.org' # Ruby versions for various enviornments ruby_versions = { - development: '2.3.1', - test: '2.3.1', - staging: '2.3.1', - production: '2.3.1' + development: '~>3.1.0', + test: '~>3.1.0', + staging: '~>3.1.0', + production: '~>3.1.0' } # Get the ruby version for the current enviornment ruby ruby_versions[(ENV['RAILS_ENV'] || 'development').to_sym] # The venerable, almighty Rails -gem 'rails', '4.2.6' +gem 'rails', '~>7.0.0' -group :development do - gem 'pg' - gem 'hirb' +group :development, :test do + gem "sprockets-rails" gem 'better_errors' + gem 'byebug' + gem 'database_cleaner' gem 'rails_best_practices' - gem 'thin' - gem 'rubocop', '0.46.0' + gem 'rubocop' + gem 'rubocop-faker' + gem 'rubocop-rails' + gem 'simplecov', require: false + gem 'listen' end -group :development, :test do - gem 'database_cleaner' - gem 'factory_girl_rails' +group :development, :test, :staging do + # Generators for population + gem 'factory_bot' + gem 'factory_bot_rails' + gem 'faker' + gem 'minitest' gem 'minitest-around' - gem 'minitest-hyper' - gem 'minitest-osx' - gem 'minitest-rails' + gem 'webmock' end -group :production do - gem 'passenger', '= 4.0.42' -end +# Database +gem 'mysql2' -group :production, :staging do - gem 'mysql2' -end +# Webserver - included in development and test and optionally in production +gem 'puma', '~> 5.5' + +gem 'bootsnap', '>= 1.4.4', require: false + +# Extend irb for better output +gem 'hirb' # Authentication -gem 'devise', '~> 4.1.1' +gem 'devise' gem 'devise_ldap_authenticatable' -gem 'json-jwt', '1.7.0' - -# Generators for population -gem 'populator' -gem 'faker' +gem 'json-jwt' +gem 'ruby-saml', '~> 1.13.0' # Student submission gem 'coderay' +gem 'rmagick', '~> 4.1' # require: false #already included in other gems - remove to avoid duplicate errors gem 'ruby-filemagic' -gem 'rmagick', '~> 2.15' # require: false #already included in other gems - remove to avoid duplicate errors gem 'rubyzip' # Plagarism detection -gem 'moss_ruby', '= 1.1.2' +gem 'moss_ruby', '>= 1.1.2' # Latex -gem 'rails-latex', '=2.0.1' +gem 'rails-latex', '>2.3' # API -gem 'grape', '0.16.2' -gem 'active_model_serializers', '~> 0.9.0' -gem 'grape-active_model_serializers', '~> 1.3.2' +gem 'grape' +gem 'grape-entity' gem 'grape-swagger' +gem 'grape-swagger-rails' # Miscellaneous -gem 'attr_encrypted', '~> 1.3.2' -gem 'rack-cors', require: 'rack/cors' gem 'ci_reporter' -gem 'terminator' -gem 'require_all', '1.3.3' gem 'dotenv-rails' +gem 'rack-cors', require: 'rack/cors' +gem 'require_all', '>=1.3.3' +gem 'bunny-pub-sub', '0.5.2' + +# Excel support +gem 'roo', '~> 2.7.0' +gem 'roo-xls' + +# webcal generation +gem 'icalendar', '~> 2.5', '>= 2.5.3' + +gem 'rest-client', '~> 2.0' + +gem 'net-smtp', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 13bb256a4..ff4824ee6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,292 +1,422 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (4.2.6) - actionpack (= 4.2.6) - actionview (= 4.2.6) - activejob (= 4.2.6) + actioncable (7.0.1) + actionpack (= 7.0.1) + activesupport (= 7.0.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (7.0.1) + actionpack (= 7.0.1) + activejob (= 7.0.1) + activerecord (= 7.0.1) + activestorage (= 7.0.1) + activesupport (= 7.0.1) + mail (>= 2.7.1) + net-imap + net-pop + net-smtp + actionmailer (7.0.1) + actionpack (= 7.0.1) + actionview (= 7.0.1) + activejob (= 7.0.1) + activesupport (= 7.0.1) mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.6) - actionview (= 4.2.6) - activesupport (= 4.2.6) - rack (~> 1.6) - rack-test (~> 0.6.2) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.6) - activesupport (= 4.2.6) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.0) + actionpack (7.0.1) + actionview (= 7.0.1) + activesupport (= 7.0.1) + rack (~> 2.0, >= 2.2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (7.0.1) + actionpack (= 7.0.1) + activerecord (= 7.0.1) + activestorage (= 7.0.1) + activesupport (= 7.0.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.0.1) + activesupport (= 7.0.1) builder (~> 3.1) - erubis (~> 2.7.0) - rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - active_model_serializers (0.9.5) - activemodel (>= 3.2) - activejob (4.2.6) - activesupport (= 4.2.6) - globalid (>= 0.3.0) - activemodel (4.2.6) - activesupport (= 4.2.6) - builder (~> 3.1) - activerecord (4.2.6) - activemodel (= 4.2.6) - activesupport (= 4.2.6) - arel (~> 6.0) - activesupport (4.2.6) - i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) - arel (6.0.3) - ast (2.3.0) - attr_encrypted (1.3.5) - encryptor (~> 1.3.0) - axiom-types (0.1.1) - descendants_tracker (~> 0.0.4) - ice_nine (~> 0.11.0) - thread_safe (~> 0.3, >= 0.3.1) - bcrypt (3.1.11) - better_errors (2.1.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (7.0.1) + activesupport (= 7.0.1) + globalid (>= 0.3.6) + activemodel (7.0.1) + activesupport (= 7.0.1) + activerecord (7.0.1) + activemodel (= 7.0.1) + activesupport (= 7.0.1) + activestorage (7.0.1) + actionpack (= 7.0.1) + activejob (= 7.0.1) + activerecord (= 7.0.1) + activesupport (= 7.0.1) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (7.0.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + addressable (2.8.0) + public_suffix (>= 2.0.2, < 5.0) + aes_key_wrap (1.1.0) + amq-protocol (2.3.2) + ast (2.4.2) + bcrypt (3.1.16) + better_errors (2.9.1) coderay (>= 1.0.0) - erubis (>= 2.6.6) + erubi (>= 1.0.0) rack (>= 0.9.0) - bindata (2.3.4) - builder (3.2.2) + bindata (2.4.10) + bootsnap (1.10.1) + msgpack (~> 1.2) + builder (3.2.4) + bunny (2.19.0) + amq-protocol (~> 2.3, >= 2.3.1) + sorted_set (~> 1, >= 1.0.2) + bunny-pub-sub (0.5.2) + bunny (~> 2.14) + byebug (11.1.3) ci_reporter (2.0.0) builder (>= 2.1.2) - code_analyzer (0.4.5) + code_analyzer (0.5.2) sexp_processor - coderay (1.1.1) - coercible (1.0.0) - descendants_tracker (~> 0.0.1) - concurrent-ruby (1.0.2) - daemon_controller (1.2.0) - daemons (1.2.3) - database_cleaner (1.5.3) - descendants_tracker (0.0.4) - thread_safe (~> 0.3, >= 0.3.1) - devise (4.1.1) + coderay (1.1.3) + concurrent-ruby (1.1.9) + crack (0.4.5) + rexml + crass (1.0.6) + database_cleaner (2.0.1) + database_cleaner-active_record (~> 2.0.0) + database_cleaner-active_record (2.0.1) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + devise (4.8.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0, < 5.1) + railties (>= 4.1.0) responders warden (~> 1.2.3) - devise_ldap_authenticatable (0.8.5) + devise_ldap_authenticatable (0.8.7) devise (>= 3.4.1) - net-ldap (>= 0.6.0, <= 0.11) - dotenv (2.1.1) - dotenv-rails (2.1.1) - dotenv (= 2.1.1) - railties (>= 4.0, < 5.1) - encryptor (1.3.0) - enumerable-lazy (0.0.1) - equalizer (0.0.11) + net-ldap (>= 0.16.0) + digest (3.1.0) + docile (1.4.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.7.6) + dotenv-rails (2.7.6) + dotenv (= 2.7.6) + railties (>= 3.2) + dry-configurable (0.14.0) + concurrent-ruby (~> 1.0) + dry-core (~> 0.6) + dry-container (0.9.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 0.13, >= 0.13.0) + dry-core (0.7.1) + concurrent-ruby (~> 1.0) + dry-inflector (0.2.1) + dry-logic (1.2.0) + concurrent-ruby (~> 1.0) + dry-core (~> 0.5, >= 0.5) + dry-types (1.5.1) + concurrent-ruby (~> 1.0) + dry-container (~> 0.3) + dry-core (~> 0.5, >= 0.5) + dry-inflector (~> 0.1, >= 0.1.2) + dry-logic (~> 1.0, >= 1.0.2) + erubi (1.10.0) erubis (2.7.0) - eventmachine (1.2.0.1) - factory_girl (4.7.0) - activesupport (>= 3.0.0) - factory_girl_rails (4.7.0) - factory_girl (~> 4.7.0) - railties (>= 3.0.0) - faker (1.6.3) - i18n (~> 0.5) - fattr (2.3.0) - globalid (0.3.6) - activesupport (>= 4.1.0) - grape (0.16.2) + factory_bot (6.2.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.2.0) + factory_bot (~> 6.2.0) + railties (>= 5.0.0) + faker (2.19.0) + i18n (>= 1.6, < 2) + ffi (1.15.5) + globalid (1.0.0) + activesupport (>= 5.0) + grape (1.6.2) activesupport builder - hashie (>= 2.1.0) - multi_json (>= 1.3.2) - multi_xml (>= 0.5.2) - mustermann19 (~> 0.4.3) + dry-types (>= 1.1) + mustermann-grape (~> 1.0.0) rack (>= 1.3.0) rack-accept - virtus (>= 1.0.0) - grape-active_model_serializers (1.3.2) - active_model_serializers (>= 0.9.0) - grape - grape-swagger (0.21.0) - grape (>= 0.12.0) - hashie (3.4.4) + grape-entity (0.10.1) + activesupport (>= 3.0.0) + multi_json (>= 1.3.2) + grape-swagger (1.4.2) + grape (~> 1.3) + grape-swagger-rails (0.3.1) + railties (>= 3.2.12) + hashdiff (1.0.1) hirb (0.7.3) - i18n (0.7.0) - ice_nine (0.11.2) - json (1.8.3) - json-jwt (1.7.0) - activesupport + http-accept (1.7.0) + http-cookie (1.0.4) + domain_name (~> 0.5) + i18n (1.8.11) + concurrent-ruby (~> 1.0) + icalendar (2.7.1) + ice_cube (~> 0.16) + ice_cube (0.16.4) + io-wait (0.2.1) + json (2.6.1) + json-jwt (1.13.0) + activesupport (>= 4.2) + aes_key_wrap bindata - multi_json (>= 1.3) - securecompare - url_safe_base64 - loofah (2.0.3) + listen (3.7.1) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + loofah (2.13.0) + crass (~> 1.0.2) nokogiri (>= 1.5.9) - mail (2.6.4) - mime-types (>= 1.16, < 4) - mime-types (3.1) + mail (2.7.1) + mini_mime (>= 0.1.1) + marcel (1.0.2) + method_source (1.0.0) + mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) - minitest (5.9.0) - minitest-around (0.4.0) + mime-types-data (3.2022.0105) + mini_mime (1.1.2) + minitest (5.15.0) + minitest-around (0.5.0) minitest (~> 5.0) - minitest-hyper (0.2.0) - minitest-osx (0.1.0) - minitest (~> 5.4) - terminal-notifier (~> 1.6) - minitest-rails (2.2.1) - minitest (~> 5.7) - railties (~> 4.1) - moss_ruby (1.1.2) - multi_json (1.12.1) - multi_xml (0.5.5) - mustermann19 (0.4.4) - enumerable-lazy - mysql2 (0.4.4) - net-ldap (0.11) - nokogiri (1.6.8) - mini_portile2 (~> 2.1.0) - pkg-config (~> 1.1.7) + moss_ruby (1.1.3) + msgpack (1.4.2) + multi_json (1.15.0) + mustermann (1.1.1) + ruby2_keywords (~> 0.0.1) + mustermann-grape (1.0.1) + mustermann (>= 1.0.0) + mysql2 (0.5.3) + net-imap (0.2.3) + digest + net-protocol + strscan + net-ldap (0.17.0) + net-pop (0.1.1) + digest + net-protocol + timeout + net-protocol (0.1.2) + io-wait + timeout + net-smtp (0.3.1) + digest + net-protocol + timeout + netrc (0.11.0) + nio4r (2.5.8) + nokogiri (1.13.1-x86_64-linux) + racc (~> 1.4) orm_adapter (0.5.0) - parser (2.3.3.1) - ast (~> 2.2) - passenger (4.0.42) - daemon_controller (>= 1.2.0) - rack - rake (>= 0.8.1) - pg (0.18.4) - pkg-config (1.1.7) - populator (1.0.0) - powerpack (0.1.1) - rack (1.6.4) + parallel (1.21.0) + parser (3.1.0.0) + ast (~> 2.4.1) + public_suffix (4.0.6) + puma (5.5.2) + nio4r (~> 2.0) + racc (1.6.0) + rack (2.2.3) rack-accept (0.4.5) rack (>= 0.4) - rack-cors (0.4.0) - rack-test (0.6.3) - rack (>= 1.0) - rails (4.2.6) - actionmailer (= 4.2.6) - actionpack (= 4.2.6) - actionview (= 4.2.6) - activejob (= 4.2.6) - activemodel (= 4.2.6) - activerecord (= 4.2.6) - activesupport (= 4.2.6) - bundler (>= 1.3.0, < 2.0) - railties (= 4.2.6) - sprockets-rails - rails-deprecated_sanitizer (1.0.3) - activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.7) - activesupport (>= 4.2.0.beta, < 5.0) - nokogiri (~> 1.6.0) - rails-deprecated_sanitizer (>= 1.0.1) - rails-html-sanitizer (1.0.3) - loofah (~> 2.0) - rails-latex (2.0.1) - rails (>= 3.0.0, < 5) - rails_best_practices (1.16.0) + rack-cors (1.1.1) + rack (>= 2.0.0) + rack-test (1.1.0) + rack (>= 1.0, < 3) + rails (7.0.1) + actioncable (= 7.0.1) + actionmailbox (= 7.0.1) + actionmailer (= 7.0.1) + actionpack (= 7.0.1) + actiontext (= 7.0.1) + actionview (= 7.0.1) + activejob (= 7.0.1) + activemodel (= 7.0.1) + activerecord (= 7.0.1) + activestorage (= 7.0.1) + activesupport (= 7.0.1) + bundler (>= 1.15.0) + railties (= 7.0.1) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.4.2) + loofah (~> 2.3) + rails-latex (2.3.4) + rails (>= 3.0.0, < 8) + rails_best_practices (1.22.1) activesupport - code_analyzer (>= 0.4.3) + code_analyzer (>= 0.5.2) erubis i18n json - require_all + require_all (~> 3.0) ruby-progressbar - railties (4.2.6) - actionpack (= 4.2.6) - activesupport (= 4.2.6) - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) - rainbow (2.1.0) - rake (11.2.2) - require_all (1.3.3) - responders (2.2.0) - railties (>= 4.2.0, < 5.1) - rmagick (2.15.4) - rubocop (0.46.0) - parser (>= 2.3.1.1, < 3.0) - powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + railties (7.0.1) + actionpack (= 7.0.1) + activesupport (= 7.0.1) + method_source + rake (>= 12.2) + thor (~> 1.0) + zeitwerk (~> 2.5) + rainbow (3.1.1) + rake (13.0.6) + rb-fsevent (0.11.0) + rb-inotify (0.10.1) + ffi (~> 1.0) + rbtree (0.4.4) + regexp_parser (2.2.0) + require_all (3.0.0) + responders (3.0.1) + actionpack (>= 5.0) + railties (>= 5.0) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + rexml (3.2.5) + rmagick (4.2.4) + roo (2.7.1) + nokogiri (~> 1) + rubyzip (~> 1.1, < 2.0.0) + roo-xls (1.2.0) + nokogiri + roo (>= 2.0.0, < 3) + spreadsheet (> 0.9.0) + rubocop (1.24.1) + parallel (~> 1.10) + parser (>= 3.0.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml + rubocop-ast (>= 1.15.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-filemagic (0.7.1) - ruby-progressbar (1.8.1) - rubyzip (1.2.0) - securecompare (1.0.0) - sexp_processor (4.7.0) - sprockets (3.6.3) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.15.1) + parser (>= 3.0.1.1) + rubocop-faker (1.1.0) + faker (>= 2.12.0) + rubocop (>= 0.82.0) + rubocop-rails (2.13.2) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.7.0, < 2.0) + ruby-filemagic (0.7.3) + ruby-ole (1.2.12.2) + ruby-progressbar (1.11.0) + ruby-saml (1.13.0) + nokogiri (>= 1.10.5) + rexml + ruby2_keywords (0.0.5) + rubyzip (1.3.0) + set (1.0.2) + sexp_processor (4.16.0) + simplecov (0.21.2) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.3) + sorted_set (1.0.3) + rbtree + set (~> 1.0) + spreadsheet (1.3.0) + ruby-ole + sprockets (4.0.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.1.1) - actionpack (>= 4.0) - activesupport (>= 4.0) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) sprockets (>= 3.0.0) - terminal-notifier (1.6.3) - terminator (1.0.0) - fattr (>= 2.2) - thin (1.7.0) - daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0, >= 1.0.4) - rack (>= 1, < 3) - thor (0.19.1) - thread_safe (0.3.5) - tzinfo (1.2.2) - thread_safe (~> 0.1) - unicode-display_width (1.1.2) - url_safe_base64 (0.2.2) - virtus (1.0.5) - axiom-types (~> 0.1) - coercible (~> 1.0) - descendants_tracker (~> 0.0, >= 0.0.3) - equalizer (~> 0.0, >= 0.0.9) - warden (1.2.6) - rack (>= 1.0) + strscan (3.0.1) + thor (1.2.1) + timeout (0.2.0) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8) + unicode-display_width (2.1.0) + warden (1.2.9) + rack (>= 2.0.9) + webmock (3.14.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + websocket-driver (0.7.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.5.3) PLATFORMS - ruby + x86_64-linux DEPENDENCIES - active_model_serializers (~> 0.9.0) - attr_encrypted (~> 1.3.2) better_errors + bootsnap (>= 1.4.4) + bunny-pub-sub (= 0.5.2) + byebug ci_reporter coderay database_cleaner - devise (~> 4.1.1) + devise devise_ldap_authenticatable dotenv-rails - factory_girl_rails + factory_bot + factory_bot_rails faker - grape (= 0.16.2) - grape-active_model_serializers (~> 1.3.2) + grape + grape-entity grape-swagger + grape-swagger-rails hirb - json-jwt (= 1.7.0) + icalendar (~> 2.5, >= 2.5.3) + json-jwt + listen + minitest minitest-around - minitest-hyper - minitest-osx - minitest-rails - moss_ruby (= 1.1.2) + moss_ruby (>= 1.1.2) mysql2 - passenger (= 4.0.42) - pg - populator + net-smtp + puma (~> 5.5) rack-cors - rails (= 4.2.6) - rails-latex (= 2.0.1) + rails (~> 7.0.0) + rails-latex (> 2.3) rails_best_practices - require_all (= 1.3.3) - rmagick (~> 2.15) - rubocop (= 0.46.0) + require_all (>= 1.3.3) + rest-client (~> 2.0) + rmagick (~> 4.1) + roo (~> 2.7.0) + roo-xls + rubocop + rubocop-faker + rubocop-rails ruby-filemagic + ruby-saml (~> 1.13.0) rubyzip - terminator - thin + simplecov + sprockets-rails + webmock RUBY VERSION - ruby 2.3.1p112 + ruby 3.1.0p0 BUNDLED WITH - 1.15.1 + 2.3.3 diff --git a/README.md b/README.md index 5517db57c..c3e4e3dcb 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,27 @@ ![Doubtfire Logo](http://puu.sh/lyClF/fde5bfbbe7.png) -# Doubtfire API +# Doubtfire API [![test-doubtfire-api](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml) -A modern, lightweight learning management system. +Doubtfire is a feedback-driven learning support system. ## Table of Contents -1. [Getting Started](#getting-started) - 1. [Install Script](#install-script) - 2. [Manual Install](#manual-install) -2. [Environment Variables](#environment-variables) -3. [Getting up and Running](#getting-up-and-running) -4. [Running Rake Tasks](#running-rake-tasks) -5. [PDF Generation Prerequisites](#pdf-generation-prerequisites) -6. [Testing](#testing) -7. [Contributing](#contributing) -8. [License](#license) +- [Doubtfire API ![test-doubtfire-api](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml)](#doubtfire-api--) + - [Table of Contents](#table-of-contents) + - [Getting started](#getting-started) + - [Clone Repository](#clone-repository) + - [Install script](#install-script) + - [Manual install](#manual-install) + - [Environment variables](#environment-variables) + - [Get it up and running!](#get-it-up-and-running) +- [Running Rake Tasks](#running-rake-tasks) +- [Testing](#testing) +- [Contributing](#contributing) +- [License](#license) ## Getting started -### Install script - -The install script will try to setup the development environment for either macOS or Linux, and can be found in the root of the project as `setup.sh`. - -### Manual install - -The manual installation guide can be found on the wiki for: [Linux](https://github.com/doubtfire-lms/doubtfire-api/wiki/Manual-install-on-Linux), [macOS](https://github.com/doubtfire-lms/doubtfire-api/wiki/Manual-install-on-macOS), or [Docker](https://github.com/doubtfire-lms/doubtfire-api/wiki/Getting-Started-Using-Docker). +See [Doubtfire Deploy](https://github.com/doubtfire-lms/doubtfire-deploy) for instructions on deploying, and contributing, to the Doubtfire project. ## Environment variables @@ -34,7 +30,7 @@ Doubtfire requires multiple environment variables that help define settings abou | Key | Description | Default | |-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------| -| `DF_AUTH_METHOD` | The authentication method you would like Doubtfire to use. Possible values are `database` for standard authentication with the database, `ldap` for [LDAP](https://www.freebsd.org/doc/en/articles/ldap-auth/), and `aaf` for [AAF Rapid Connect](https://rapid.aaf.edu.au/). | `database` | +| `DF_AUTH_METHOD` | The authentication method you would like Doubtfire to use. Possible values are `database` for standard authentication with the database, `ldap` for [LDAP](https://www.freebsd.org/doc/en/articles/ldap-auth/), `aaf` for [AAF Rapid Connect](https://rapid.aaf.edu.au/), or `SAML2` for [SAML2.0 auth](https://en.wikipedia.org/wiki/SAML_2.0). | `database` | | `DF_STUDENT_WORK_DIR` | The directory to store uploaded student work for processing. | `student_work` | | `DF_INSTITUTION_NAME` | The name of your institution running Doubtfire. | _University of Foo_ | | `DF_INSTITUTION_EMAIL_DOMAIN` | The email domain from which emails are sent to and from in your institution. | `doubtfire.com` | @@ -44,6 +40,10 @@ Doubtfire requires multiple environment variables that help define settings abou | `DF_SECRET_KEY_ATTR` | The secret key to encrypt certain database fields. | Default key provided. | | `DF_SECRET_KEY_DEVISE` | The secret key provided to Devise. | Default key provided. | | `DF_SECRET_KEY_MOSS` | The secret key provided to [Moss](http://theory.stanford.edu/~aiken/moss/) for plagiarism detection. This value will need to be set to run `rake submission:check_plagiarism` (otherwise you **won't** need it). You will need to register for a Moss account to use this. | No default. | +| `DF_INSTITUTION_PRIVACY` | A statement related to the need for students to submit their own work, and that this work may be uploaded to 3rd parties for the purpose of plagiarism detection. | Default statement provided | +| `DF_INSTITUTION_PLAGIARISM` | A statement clarifying the terms plagiarism and collusion. | Default statement provided | +| `DF_INSTITUTION_SETTINGS_RB` | The path of the institution specific settings rb code - used to map student imports from institutional exports to a format understood by Doubtfire. | No default | +| `DF_FFMPEG_PATH` | The path of to the ffmpeg binary for audio processing. | ffmpeg | If you have chosen to use AAF Rapid Connect authentication, then you will also need to provide the following: @@ -54,6 +54,7 @@ If you have chosen to use AAF Rapid Connect authentication, then you will also n | `DF_AAF_CALLBACK_URL` | The secure endpoint within your application that AAF Rapid Connect should POST responses to. It **must end with `/api/auth/jwt`** to access the Doubtfire JWT authentication endpoint. | No default - required | | `DF_AAF_UNIQUE_URL` | The unique URL provided by AAF Rapid Connect used for redirection out of Doubtfire. | No default - required | | `DF_AAF_IDENTITY_PROVIDER_URL` | The URL of the AAF-registered identity provider. | No default - required | +| `DF_AAF_AUTH_SIGNOUT_URL` | The URL to redirect to on sign out in order to log out of AAF Rapid Connect. | No default - required | | `DF_SECRET_KEY_AAF` | The secret used to register your application with AAF. | `secretsecret12345` | You may choose to keep your environment variables inside a `.env` file using key-value pairs: @@ -71,7 +72,7 @@ You can also keep multiple `.env` files for different environments, e.g.: `.env. Once you've installed using either in install script or the manual install steps. ``` -$ rails s +$ bundle exec rails s ``` You should see all the Doubtfire endpoints at **[http://localhost:3000/api/docs/](http://localhost:3000/api/docs/)**, which means the API is running. @@ -98,8 +99,6 @@ To run unit tests, execute: $ rake test ``` -A report will be generated under `spec/reports/hyper/index.html`. - Unit tests are located in the `test` directory, where **model** tests are under the `model` subdirectory and **API** tests are under the `api` subdirectory. diff --git a/README.rdoc b/README.rdoc deleted file mode 100644 index 7c36f2356..000000000 --- a/README.rdoc +++ /dev/null @@ -1,261 +0,0 @@ -== Welcome to Rails - -Rails is a web-application framework that includes everything needed to create -database-backed web applications according to the Model-View-Control pattern. - -This pattern splits the view (also called the presentation) into "dumb" -templates that are primarily responsible for inserting pre-built data in between -HTML tags. The model contains the "smart" domain objects (such as Account, -Product, Person, Post) that holds all the business logic and knows how to -persist themselves to a database. The controller handles the incoming requests -(such as Save New Account, Update Product, Show Post) by manipulating the model -and directing data to the view. - -In Rails, the model is handled by what's called an object-relational mapping -layer entitled Active Record. This layer allows you to present the data from -database rows as objects and embellish these data objects with business logic -methods. You can read more about Active Record in -link:files/vendor/rails/activerecord/README.html. - -The controller and view are handled by the Action Pack, which handles both -layers by its two parts: Action View and Action Controller. These two layers -are bundled in a single package due to their heavy interdependence. This is -unlike the relationship between the Active Record and Action Pack that is much -more separate. Each of these packages can be used independently outside of -Rails. You can read more about Action Pack in -link:files/vendor/rails/actionpack/README.html. - - -== Getting Started - -1. At the command prompt, create a new Rails application: - rails new myapp (where myapp is the application name) - -2. Change directory to myapp and start the web server: - cd myapp; rails server (run with --help for options) - -3. Go to http://localhost:3000/ and you'll see: - "Welcome aboard: You're riding Ruby on Rails!" - -4. Follow the guidelines to start developing your application. You can find -the following resources handy: - -* The Getting Started Guide: http://guides.rubyonrails.org/getting_started.html -* Ruby on Rails Tutorial Book: http://www.railstutorial.org/ - - -== Debugging Rails - -Sometimes your application goes wrong. Fortunately there are a lot of tools that -will help you debug it and get it back on the rails. - -First area to check is the application log files. Have "tail -f" commands -running on the server.log and development.log. Rails will automatically display -debugging and runtime information to these files. Debugging info will also be -shown in the browser on requests from 127.0.0.1. - -You can also log your own messages directly into the log file from your code -using the Ruby logger class from inside your controllers. Example: - - class WeblogController < ActionController::Base - def destroy - @weblog = Weblog.find(params[:id]) - @weblog.destroy - logger.info("#{Time.now} Destroyed Weblog ID ##{@weblog.id}!") - end - end - -The result will be a message in your log file along the lines of: - - Mon Oct 08 14:22:29 +1000 2007 Destroyed Weblog ID #1! - -More information on how to use the logger is at http://www.ruby-doc.org/core/ - -Also, Ruby documentation can be found at http://www.ruby-lang.org/. There are -several books available online as well: - -* Programming Ruby: http://www.ruby-doc.org/docs/ProgrammingRuby/ (Pickaxe) -* Learn to Program: http://pine.fm/LearnToProgram/ (a beginners guide) - -These two books will bring you up to speed on the Ruby language and also on -programming in general. - - -== Debugger - -Debugger support is available through the debugger command when you start your -Mongrel or WEBrick server with --debugger. This means that you can break out of -execution at any point in the code, investigate and change the model, and then, -resume execution! You need to install ruby-debug to run the server in debugging -mode. With gems, use sudo gem install ruby-debug. Example: - - class WeblogController < ActionController::Base - def index - @posts = Post.all - debugger - end - end - -So the controller will accept the action, run the first line, then present you -with a IRB prompt in the server window. Here you can do things like: - - >> @posts.inspect - => "[#nil, "body"=>nil, "id"=>"1"}>, - #"Rails", "body"=>"Only ten..", "id"=>"2"}>]" - >> @posts.first.title = "hello from a debugger" - => "hello from a debugger" - -...and even better, you can examine how your runtime objects actually work: - - >> f = @posts.first - => #nil, "body"=>nil, "id"=>"1"}> - >> f. - Display all 152 possibilities? (y or n) - -Finally, when you're ready to resume execution, you can enter "cont". - - -== Console - -The console is a Ruby shell, which allows you to interact with your -application's domain model. Here you'll have all parts of the application -configured, just like it is when the application is running. You can inspect -domain models, change values, and save to the database. Starting the script -without arguments will launch it in the development environment. - -To start the console, run rails console from the application -directory. - -Options: - -* Passing the -s, --sandbox argument will rollback any modifications - made to the database. -* Passing an environment name as an argument will load the corresponding - environment. Example: rails console production. - -To reload your controllers and models after launching the console run -reload! - -More information about irb can be found at: -link:http://www.rubycentral.org/pickaxe/irb.html - - -== dbconsole - -You can go to the command line of your database directly through rails -dbconsole. You would be connected to the database with the credentials -defined in database.yml. Starting the script without arguments will connect you -to the development database. Passing an argument will connect you to a different -database, like rails dbconsole production. Currently works for MySQL, -PostgreSQL and SQLite 3. - -== Description of Contents - -The default directory structure of a generated Ruby on Rails application: - - |-- app - | |-- assets - | |-- images - | |-- javascripts - | `-- stylesheets - | |-- controllers - | |-- helpers - | |-- mailers - | |-- models - | `-- views - | `-- layouts - |-- config - | |-- environments - | |-- initializers - | `-- locales - |-- db - |-- doc - |-- lib - | `-- tasks - |-- log - |-- public - |-- script - |-- test - | |-- fixtures - | |-- functional - | |-- integration - | |-- performance - | `-- unit - |-- tmp - | |-- cache - | |-- pids - | |-- sessions - | `-- sockets - `-- vendor - |-- assets - `-- stylesheets - `-- plugins - -app - Holds all the code that's specific to this particular application. - -app/assets - Contains subdirectories for images, stylesheets, and JavaScript files. - -app/controllers - Holds controllers that should be named like weblogs_controller.rb for - automated URL mapping. All controllers should descend from - ApplicationController which itself descends from ActionController::Base. - -app/models - Holds models that should be named like post.rb. Models descend from - ActiveRecord::Base by default. - -app/views - Holds the template files for the view that should be named like - weblogs/index.html.erb for the WeblogsController#index action. All views use - eRuby syntax by default. - -app/views/layouts - Holds the template files for layouts to be used with views. This models the - common header/footer method of wrapping views. In your views, define a layout - using the layout :default and create a file named default.html.erb. - Inside default.html.erb, call <% yield %> to render the view using this - layout. - -app/helpers - Holds view helpers that should be named like weblogs_helper.rb. These are - generated for you automatically when using generators for controllers. - Helpers can be used to wrap functionality for your views into methods. - -config - Configuration files for the Rails environment, the routing map, the database, - and other dependencies. - -db - Contains the database schema in schema.rb. db/migrate contains all the - sequence of Migrations for your schema. - -doc - This directory is where your application documentation will be stored when - generated using rake doc:app - -lib - Application specific libraries. Basically, any kind of custom code that - doesn't belong under controllers, models, or helpers. This directory is in - the load path. - -public - The directory available for the web server. Also contains the dispatchers and the - default HTML files. This should be set as the DOCUMENT_ROOT of your web - server. - -script - Helper scripts for automation and generation. - -test - Unit and functional tests along with fixtures. When using the rails generate - command, template test files will be generated for you and placed in this - directory. - -vendor - External libraries that the application depends on. Also includes the plugins - subdirectory. If the app has frozen rails, those gems also go here, under - vendor/rails/. This directory is in the load path. diff --git a/app/api/activity_types_authenticated_api.rb b/app/api/activity_types_authenticated_api.rb new file mode 100644 index 000000000..bb8b47f0c --- /dev/null +++ b/app/api/activity_types_authenticated_api.rb @@ -0,0 +1,68 @@ +require 'grape' + +class ActivityTypesAuthenticatedApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add an activity type' + params do + requires :activity_type, type: Hash do + requires :name, type: String, desc: 'The name of the activity type' + requires :abbreviation, type: String, desc: 'The abbreviation for the activity type' + end + end + post '/activity_types' do + unless authorise? current_user, User, :handle_activity_types + error!({ error: 'Not authorised to create an activity type' }, 403) + end + activity_type_parameters = ActionController::Parameters.new(params) + .require(:activity_type) + .permit(:name, + :abbreviation) + + result = ActivityType.create!(activity_type_parameters) + + if result.nil? + error!({ error: 'No activity type added' }, 403) + else + present result, with: Entities::ActivityTypeEntity + end + end + + desc 'Update an activity type' + params do + requires :activity_type, type: Hash do + optional :name, type: String, desc: 'The name of the activity type' + optional :abbreviation, type: String, desc: 'The abbreviation for the activity type' + end + end + put '/activity_types/:id' do + activity_type = ActivityType.find(params[:id]) + unless authorise? current_user, User, :handle_activity_types + error!({ error: 'Not authorised to update an activity type' }, 403) + end + activity_type_parameters = ActionController::Parameters.new(params) + .require(:activity_type) + .permit(:name, + :abbreviation) + + activity_type.update!(activity_type_parameters) + present activity_type, with: Entities::ActivityTypeEntity + end + + desc 'Delete an activity type' + delete '/activity_types/:id' do + unless authorise? current_user, User, :handle_activity_types + error!({ error: 'Not authorised to delete an activity type' }, 403) + end + + activity_type = ActivityType.find(params[:id]) + activity_type.destroy + error!({ error: activity_type.errors.full_messages.last }, 403) unless activity_type.destroyed? + present activity_type.destroyed?, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/activity_types_public_api.rb b/app/api/activity_types_public_api.rb new file mode 100644 index 000000000..439497ac0 --- /dev/null +++ b/app/api/activity_types_public_api.rb @@ -0,0 +1,14 @@ +require 'grape' + +class ActivityTypesPublicApi < Grape::API + + desc "Get an activity type details" + get '/activity_types/:id' do + present ActivityType.find(params[:id]), with: Entities::ActivityTypeEntity + end + + desc 'Get all the activity types' + get '/activity_types' do + present ActivityType.all, with: Entities::ActivityTypeEntity + end +end diff --git a/app/api/admin/overseer_admin_api.rb b/app/api/admin/overseer_admin_api.rb new file mode 100644 index 000000000..d84408f0c --- /dev/null +++ b/app/api/admin/overseer_admin_api.rb @@ -0,0 +1,93 @@ +require 'grape' + +module Admin + + class OverseerAdminApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add an overseer image' + params do + requires :overseer_image, type: Hash do + requires :name, type: String, desc: 'The name to display for this image' + requires :tag, type: String, desc: 'The tag used to receive from container repo' + end + end + post '/admin/overseer_images' do + unless authorise? current_user, User, :admin_overseer + error!({ error: 'Not authorised to create overseer images' }, 403) + end + unless Doubtfire::Application.config.overseer_enabled + error!({ error: 'Overseer is not enabled. Enable Overseer before updating settings.' }, 403) + end + overseer_image_params = ActionController::Parameters.new(params) + .require(:overseer_image) + .permit(:name, + :tag) + + result = OverseerImage.create!(overseer_image_params) + + if result.nil? + error!({ error: 'No overseer image added' }, 403) + else + present result, with: Entities::OverseerImageEntity + end + end + + desc 'Update an overseer image' + params do + requires :overseer_image, type: Hash do + optional :name, type: String, desc: 'The name of the overseer image' + optional :tag, type: String, desc: 'The tag used to receive from container repo' + end + end + put '/admin/overseer_images/:id' do + unless authorise? current_user, User, :admin_overseer + error!({ error: 'Not authorised to update an overseer image' }, 403) + end + unless Doubtfire::Application.config.overseer_enabled + error!({ error: 'Overseer is not enabled. Enable Overseer before updating settings.' }, 403) + end + + overseer_image = OverseerImage.find(params[:id]) + + overseer_image_params = ActionController::Parameters.new(params) + .require(:overseer_image) + .permit(:name, + :tag) + + overseer_image.update!(overseer_image_params) + present overseer_image, with: Entities::OverseerImageEntity + end + + desc 'Delete an overseer image' + delete '/admin/overseer_images/:id' do + unless authorise? current_user, User, :admin_overseer + error!({ error: 'Not authorised to delete an overseer image' }, 403) + end + + overseer_image = OverseerImage.find(params[:id]) + overseer_image.destroy + error!({ error: overseer_image.errors.full_messages.last }, 403) unless overseer_image.destroyed? + + present overseer_image.destroyed?, with: Grape::Presenters::Presenter + end + + desc 'Get all overseer images' + get '/admin/overseer_images' do + unless authorise? current_user, User, :use_overseer + error!({ error: 'Not authorised to get overseer images' }, 403) + end + + if Doubtfire::Application.config.overseer_enabled + present OverseerImage.all, with: Entities::OverseerImageEntity + else + present [], with: Grape::Presenters::Presenter + end + end + end +end diff --git a/app/api/api.rb b/app/api/api.rb deleted file mode 100644 index a2bfd3b65..000000000 --- a/app/api/api.rb +++ /dev/null @@ -1,66 +0,0 @@ -require 'grape' -require 'grape-swagger' - -module Api - class Root < Grape::API - helpers AuthorisationHelpers - helpers LogHelper - helpers AuthenticationHelpers - - prefix 'api' - format :json - formatter :json, Grape::Formatter::ActiveModelSerializers - rescue_from :all - - # - # Mount the api modules - # - mount Api::Authentication - mount Api::GroupSets - mount Api::Projects - mount Api::Students - mount Api::Tasks - mount Api::TaskComments - mount Api::TaskDefinitions - mount Api::Tutorials - mount Api::UnitRoles - mount Api::Units - mount Api::Users - mount Api::LearningOutcomes - mount Api::LearningAlignment - mount Api::Submission::Generate - mount Api::Submission::PortfolioApi - mount Api::Submission::PortfolioEvidenceApi - mount Api::Submission::BatchTask - - # - # Add auth details to all end points - # - AuthenticationHelpers.add_auth_to Api::GroupSets - AuthenticationHelpers.add_auth_to Api::Units - AuthenticationHelpers.add_auth_to Api::Projects - AuthenticationHelpers.add_auth_to Api::Students - AuthenticationHelpers.add_auth_to Api::Tasks - AuthenticationHelpers.add_auth_to Api::TaskComments - AuthenticationHelpers.add_auth_to Api::TaskDefinitions - AuthenticationHelpers.add_auth_to Api::Tutorials - AuthenticationHelpers.add_auth_to Api::Users - AuthenticationHelpers.add_auth_to Api::UnitRoles - AuthenticationHelpers.add_auth_to Api::LearningOutcomes - AuthenticationHelpers.add_auth_to Api::LearningAlignment - AuthenticationHelpers.add_auth_to Api::Submission::PortfolioApi - AuthenticationHelpers.add_auth_to Api::Submission::PortfolioEvidenceApi - AuthenticationHelpers.add_auth_to Api::Submission::BatchTask - - add_swagger_documentation \ - base_path: nil, - add_version: false, - hide_documentation_path: true, - info: { - title: 'Doubtfire API Documentaion', - description: 'Doubtfire is a modern, lightweight learning management system.', - license: 'AGPL v3.0', - license_url: 'https://github.com/doubtfire-lms/doubtfire-api/blob/master/LICENSE' - } - end -end diff --git a/app/api/api_root.rb b/app/api/api_root.rb new file mode 100644 index 000000000..c66531b8b --- /dev/null +++ b/app/api/api_root.rb @@ -0,0 +1,122 @@ +require 'grape' +require 'grape-swagger' + +class ApiRoot < Grape::API + helpers AuthorisationHelpers + helpers LogHelper + helpers AuthenticationHelpers + + prefix 'api' + format :json + + before do + header['Access-Control-Allow-Origin'] = '*' + header['Access-Control-Request-Method'] = '*' + + Thread.current.thread_variable_set(:ip, request.ip) + end + + rescue_from :all do |e| + case e + when ActiveRecord::RecordInvalid, Grape::Exceptions::ValidationErrors + message = e.message + status = 400 + when ActiveRecord::InvalidForeignKey + message = "This operation has been rejected as it would break data integrity. Ensure that related values are deleted or updated before trying again." + status = 400 + when Grape::Exceptions::MethodNotAllowed + message = e.message + status = 405 + when ActiveRecord::RecordNotDestroyed + message = e.message + status = 400 + when ActiveRecord::RecordNotFound + message = "Unable to find requested #{e.message[/(Couldn't find )(.*)( with)/,2]}" + status = 404 + else + logger.error "Unhandled exception: #{e.class}" + logger.error e.inspect + logger.error e.backtrace.join("\n") + message = "Sorry... something went wrong with your request." + status = 500 + end + Rack::Response.new( {error: message}.to_json, status, { 'Content-type' => 'text/error' } ) + end + + # + # Mount the api modules + # + mount Admin::OverseerAdminApi + mount ActivityTypesAuthenticatedApi + mount ActivityTypesPublicApi + mount AuthenticationApi + mount BreaksApi + mount DiscussionCommentApi + mount ExtensionCommentsApi + mount GroupSetsApi + mount LearningOutcomesApi + mount LearningAlignmentApi + mount ProjectsApi + mount SettingsApi + mount StudentsApi + mount Submission::PortfolioApi + mount Submission::PortfolioEvidenceApi + mount Submission::BatchTaskApi + mount TaskCommentsApi + mount TaskDefinitionsApi + mount TasksApi + mount TeachingPeriodsPublicApi + mount TeachingPeriodsAuthenticatedApi + mount CampusesPublicApi + mount CampusesAuthenticatedApi + mount TutorialsApi + mount TutorialStreamsApi + mount TutorialEnrolmentsApi + mount UnitRolesApi + mount UnitsApi + mount UsersApi + mount WebcalApi + mount WebcalPublicApi + + # + # Add auth details to all end points + # + AuthenticationHelpers.add_auth_to Admin::OverseerAdminApi + + AuthenticationHelpers.add_auth_to ActivityTypesAuthenticatedApi + AuthenticationHelpers.add_auth_to BreaksApi + AuthenticationHelpers.add_auth_to DiscussionCommentApi + AuthenticationHelpers.add_auth_to ExtensionCommentsApi + AuthenticationHelpers.add_auth_to GroupSetsApi + AuthenticationHelpers.add_auth_to LearningOutcomesApi + AuthenticationHelpers.add_auth_to LearningAlignmentApi + AuthenticationHelpers.add_auth_to ProjectsApi + AuthenticationHelpers.add_auth_to StudentsApi + AuthenticationHelpers.add_auth_to Submission::PortfolioApi + AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi + AuthenticationHelpers.add_auth_to Submission::BatchTaskApi + AuthenticationHelpers.add_auth_to TasksApi + AuthenticationHelpers.add_auth_to TaskCommentsApi + AuthenticationHelpers.add_auth_to TaskDefinitionsApi + AuthenticationHelpers.add_auth_to TeachingPeriodsAuthenticatedApi + AuthenticationHelpers.add_auth_to CampusesAuthenticatedApi + AuthenticationHelpers.add_auth_to TutorialsApi + AuthenticationHelpers.add_auth_to TutorialStreamsApi + AuthenticationHelpers.add_auth_to TutorialEnrolmentsApi + AuthenticationHelpers.add_auth_to UsersApi + AuthenticationHelpers.add_auth_to UnitRolesApi + AuthenticationHelpers.add_auth_to UnitsApi + AuthenticationHelpers.add_auth_to WebcalApi + + add_swagger_documentation \ + base_path: nil, + api_version: 'v1', + hide_documentation_path: true, + info: { + title: 'Doubtfire API Documentaion', + description: 'Doubtfire is a modern, lightweight learning management system.', + license: 'AGPL v3.0', + license_url: 'https://github.com/doubtfire-lms/doubtfire-api/blob/master/LICENSE' + }, + mount_path: 'swagger_doc' +end diff --git a/app/api/authentication.rb b/app/api/authentication.rb deleted file mode 100644 index 3a6ef0156..000000000 --- a/app/api/authentication.rb +++ /dev/null @@ -1,252 +0,0 @@ -require 'grape' -require 'user_serializer' -require 'json/jwt' - -module Api - # - # Provides the authentication API for Doubtfire. - # Users can sign in via email and password and receive an auth token - # that can be used with other API calls. - # - class Authentication < Grape::API - helpers LogHelper - helpers AuthenticationHelpers - - # - # Sign in - only mounted if AAF auth is NOT used - # - unless AuthenticationHelpers.aaf_auth? - desc 'Sign in' - params do - requires :username, type: String, desc: 'User username' - requires :password, type: String, desc: 'User\'s password' - optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false - end - post '/auth' do - username = params[:username] - password = params[:password] - remember = params[:remember] - logger.info "Authenticate #{username} from #{request.ip}" - - # Truncate the 's' from sXXX for Swinburne auth - truncate_s_match = (username =~ /^[Ss]\d{6,10}([Xx]|\d)$/) - username[0] = '' if !truncate_s_match.nil? && truncate_s_match.zero? - - # No provided credentials - if username.nil? || password.nil? - error!({ error: 'The request must contain the user username and password.' }, 400) - return - end - - # User lookup - username = username.downcase - institution_email_domain = Doubtfire::Application.config.institution[:email_domain] - user = User.find_or_create_by(username: username) do |new_user| - new_user.first_name = 'First Name' - new_user.last_name = 'Surname' - new_user.email = "#{username}@#{institution_email_domain}" - new_user.nickname = 'Nickname' - new_user.role_id = Role.student.id - new_user.login_id = username - end - - # Redirect acain_student or acain_tutor - acain_match = username =~ /^acain_.*$/ - user.username = 'acain' if !acain_match.nil? && acain_match.zero? - - # Try to authenticate - unless user.authenticate?(password) - error!({ error: 'Invalid email or password.' }, 401) - return - end - - # Restore username if acain_... - user.username = username if !acain_match.nil? && acain_match.zero? - - # Create user if they are a new record - if user.new_record? - user.password = 'password' - user.encrypted_password = BCrypt::Password.create('password') - unless user.valid? - error!(error: 'There was an error creating your account in Doubtfire. ' \ - 'Please get in contact with your unit convenor or the ' \ - 'Doubtfire administrators.') - end - user.save - end - - # Revise an auth_token for future requests - if user.authentication_token_expired? - # Create a new token - user.generate_authentication_token! remember - else - # Extend the existing token's time - user.extend_authentication_token remember - end - - # Return user details - { user: UserSerializer.new(user), auth_token: user.auth_token } - end - end - - # - # AAF JWT callback - only mounted if AAF auth is used - # - if AuthenticationHelpers.aaf_auth? - desc 'AAF Rapid Connect JWT callback' - params do - requires :assertion, type: String, desc: 'Data provided for further processing.' - end - post '/auth/jwt' do - jws = params[:assertion] - error!({ error: 'JWS was not found in request.' }, 500) unless jws - - # Identity provider must match the one set in the configuration -- this - # prevents an identity provider from a different institution from trying - # to log into this instance of Doubtfire - referer = request.referer - error!({ error: 'This URL must be referered by AAF.' }, 500) unless referer - request_idp = referer.split('entityID=').last - unless request_idp == Doubtfire::Application.config.aaf[:identity_provider_url] - error!({ error: 'This request was not verified by the correct identity provider.' }, 500) - end - - # Decode JWS - jwt = User.decode_jws(jws) - error!({ error: 'Invalid JWS.' }, 500) unless jwt - - # User lookup via unique login id - attrs = jwt['https://aaf.edu.au/attributes'] - login_id = jwt[:sub] - email = attrs[:mail] - - # Lookup using login_id if it exists - # Lookup using email otherwise and set login_id - # Otherwise create new - user = User.find_by(login_id: login_id) || - User.find_by(email: email) || - User.find_or_create_by(login_id: login_id) do |new_user| - role = Role.aaf_affiliation_to_role_id(attrs[:edupersonscopedaffiliation]) - first_name = (attrs[:givenname] || attrs[:cn]).capitalize - last_name = attrs[:surname].capitalize - username = email.split('@').first - # Some institutions may provide givenname and surname, others - # may only provide common name which we will use as first name - new_user.first_name = first_name - new_user.last_name = last_name - new_user.email = email - new_user.username = username - new_user.nickname = first_name - new_user.role_id = role - end - - # Set login id + username if not yet specified - user.login_id = login_id if user.login_id.nil? - user.username = username if user.username.nil? - - # Try to authenticate - return error!({ error: 'Invalid JSON web token.' }, 401) unless user.authenticate?(jws) - - # Try and save the user once authenticated if new - if user.new_record? - user.password = 'password' - user.encrypted_password = BCrypt::Password.create('password') - unless user.valid? - error!(error: 'There was an error creating your account in Doubtfire. ' \ - 'Please get in contact with your unit convenor or the ' \ - 'Doubtfire administrators.') - end - user.save - end - - # Generate a temporary auth_token for future requests - user.generate_temporary_authentication_token! - - # Must redirect to the front-end after sign in - protocol = Rails.env.development? ? 'http' : 'https' - host = Rails.env.development? ? 'localhost:8000' : Doubtfire::Application.config.institution[:host] - redirect "#{protocol}://#{host}/#sign_in?authToken=#{user.auth_token}" - end - - # - # Respond user details provided a temporary login token - # - desc 'Get user details from an authentication token' - params do - requires :auth_token, type: String, desc: 'The user\'s temporary auth token' - end - post '/auth' do - error!({ error: 'Invalid token.' }, 404) if params[:auth_token].nil? - logger.info "Get user via auth_token from #{request.ip}" - - # Authenticate that the token is okay - if authenticated? - user = User.find_by_auth_token(params[:auth_token]) - - # Invalidate the token and regenrate a new one - user.reset_authentication_token! - user.generate_authentication_token! true - - # Respond user details with new auth token - { user: UserSerializer.new(user), auth_token: user.auth_token } - end - end - end - - # - # Returns the current auth method - # - desc 'Authentication method configuration' - get '/auth/method' do - response = { - method: Doubtfire::Application.config.auth_method - } - response[:redirect_to] = Doubtfire::Application.config.aaf[:redirect_url] if aaf_auth? - response - end - - # - # Update token - # - desc 'Allow tokens to be updated' - params do - requires :username, type: String, desc: 'User username' - optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false - end - put '/auth/:auth_token' do - error!({ error: 'Invalid token.' }, 404) if params[:auth_token].nil? - logger.info "Update token #{params[:username]} from #{request.ip}" - - # Find user - user = User.find_by_auth_token(params[:auth_token]) - remember = params[:remember] || false - - # Token does not match user - if user.nil? || user.username != params[:username] - error!({ error: 'Invalid token.' }, 404) - else - if user.auth_token_expiry > Time.zone.now && user.auth_token_expiry < Time.zone.now + 1.hour - user.reset_authentication_token! - user.generate_authentication_token! remember - end - # Return extended auth token - { auth_token: user.auth_token } - end - end - - # - # Sign out - # - desc 'Sign out' - delete '/auth/:auth_token' do - user = User.find_by_auth_token(params[:auth_token]) - - if user - logger.info "Sign out #{user.username} from #{request.ip}" - user.reset_authentication_token! - end - - nil - end - end -end diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb new file mode 100644 index 000000000..ce783cfee --- /dev/null +++ b/app/api/authentication_api.rb @@ -0,0 +1,312 @@ +require 'grape' +require 'json/jwt' +require 'onelogin/ruby-saml' +require 'entities/user_entity' +# +# Provides the authentication API for Doubtfire. +# Users can sign in via email and password and receive an auth token +# that can be used with other API calls. +# +class AuthenticationApi < Grape::API + helpers LogHelper + helpers AuthenticationHelpers + helpers Auth::AuthSamlHelper + + # + # Sign in - only mounted if AAF auth is NOT used + # + if !AuthenticationHelpers.aaf_auth? && !AuthenticationHelpers.saml_auth? + desc 'Sign in' + params do + requires :username, type: String, desc: 'User username' + requires :password, type: String, desc: 'User\'s password' + optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false + end + post '/auth' do + username = params[:username] + password = params[:password] + remember = params[:remember] + logger.info "Authenticate #{username} from #{request.ip}" + + # Truncate the 's' from sXXX for Swinburne auth + truncate_s_match = (username =~ /^[Ss]\d{6,10}([Xx]|\d)$/) + username[0] = '' if !truncate_s_match.nil? && truncate_s_match.zero? + + # No provided credentials + if username.nil? || password.nil? + error!({ error: 'The request must contain the user username and password.' }, 400) + end + + # User lookup + username = username.downcase + institution_email_domain = Doubtfire::Application.config.institution[:email_domain] + user = User.find_or_create_by(username: username) do |new_user| + new_user.first_name = 'First Name' + new_user.last_name = 'Surname' + new_user.email = "#{username}@#{institution_email_domain}" + new_user.nickname = 'Nickname' + new_user.role_id = Role.student.id + new_user.login_id = username + end + + # Try to authenticate + unless user.authenticate?(password) + error!({ error: 'Invalid email or password.' }, 401) + return + end + + # Create user if they are a new record + if user.new_record? + user.encrypted_password = BCrypt::Password.create('password') + + unless user.valid? + error!(error: 'There was an error creating your account in Doubtfire. ' \ + 'Please get in contact with your unit convenor or the ' \ + 'Doubtfire administrators.') + end + user.save + end + + logger.info "Login #{username} from #{request.ip}" + + # Return user details + present :user, user, with: Entities::UserEntity + present :auth_token, user.generate_authentication_token!(remember).authentication_token + end + end + + # + # AAF JWT callback - only mounted if AAF SAML is used + # This isn't really a JWT, we will treat it as if it's a SAML response + # + if AuthenticationHelpers.saml_auth? + desc 'SAML2.0 auth' + params do + requires :SAMLResponse, type: String, desc: 'Data provided for further processing.' + end + post '/auth/jwt' do + # this endpoint is being deprecated and will be removed + AuthSamlHelper.auth_saml2 params[:SAMLResponse] + end + post '/auth/saml2' do + AuthSamlHelper.auth_saml2 params[:SAMLResponse] + end + end + + # + # AAF JWT callback - only mounted if AAF auth is used + # + if AuthenticationHelpers.aaf_auth? + desc 'AAF Rapid Connect JWT callback' + params do + requires :assertion, type: String, desc: 'Data provided for further processing.' + end + post '/auth/jwt' do + jws = params[:assertion] + error!({ error: 'JWS was not found in request.' }, 500) unless jws + + # Decode JWS + jwt = User.decode_jws(jws) + error!({ error: 'Invalid JWS.' }, 500) unless jwt + + # User lookup via unique login id + attrs = jwt['https://aaf.edu.au/attributes'] + login_id = jwt[:sub] + email = attrs[:mail] + + logger.info "Authenticate #{email} from #{request.ip}" + + # Lookup using login_id if it exists + # Lookup using email otherwise and set login_id + # Otherwise create new + user = User.find_by(login_id: login_id) || + User.find_by_username(email[/(.*)@/, 1]) || + User.find_by(email: email) || + User.find_or_create_by(login_id: login_id) do |new_user| + role = Role.aaf_affiliation_to_role_id(attrs[:edupersonscopedaffiliation]) + first_name = (attrs[:givenname] || attrs[:cn]).capitalize + last_name = attrs[:surname].capitalize + username = email.split('@').first + # Some institutions may provide givenname and surname, others + # may only provide common name which we will use as first name + new_user.first_name = first_name + new_user.last_name = last_name + new_user.email = email + new_user.username = username + new_user.nickname = first_name + new_user.role_id = role + end + + # Set login id + username if not yet specified + user.login_id = login_id if user.login_id.nil? + user.username = username if user.username.nil? + + # Try to authenticate + return error!({ error: 'Invalid JSON web token.' }, 401) unless user.authenticate?(jws) + + # Try and save the user once authenticated if new + if user.new_record? + user.encrypted_password = BCrypt::Password.create(SecureRandom.hex(32)) + unless user.valid? + error!(error: 'There was an error creating your account in Doubtfire. ' \ + 'Please get in contact with your unit convenor or the ' \ + 'Doubtfire administrators.') + end + user.save + end + + # Generate a temporary auth_token for future requests + onetime_token = user.generate_temporary_authentication_token! + + logger.info "Redirecting #{user.username} from #{request.ip}" + + # Must redirect to the front-end after sign in + protocol = Rails.env.development? ? 'http' : 'https' + host = Rails.env.development? ? "#{protocol}://localhost:3000" : Doubtfire::Application.config.institution[:host] + host = "#{protocol}://#{host}" unless host.starts_with?('http') + redirect "#{host}/#sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + end + end + + if AuthenticationHelpers.saml_auth? || AuthenticationHelpers.aaf_auth? + # + # Respond user details provided a temporary login token + # + desc 'Get user details from an authentication token' + params do + requires :username, type: String, desc: 'The user\'s username' + requires :auth_token, type: String, desc: 'The user\'s temporary auth token' + end + post '/auth' do + error!({ error: 'Invalid token.' }, 404) if params[:auth_token].nil? + logger.info "Get user via auth_token from #{request.ip}" + + # Authenticate that the token is okay + if authenticated? + user = User.find_by_username(params[:username]) + token = user.token_for_text?(params[:auth_token]) unless user.nil? + error!({ error: 'Invalid token.' }, 404) if token.nil? + + # Invalidate the token and regenrate a new one + token.destroy! + token = user.generate_authentication_token! true + + logger.info "Login #{params[:username]} from #{request.ip}" + + # Respond user details with new auth token + present :user, user, with: Entities::UserEntity + present :auth_token, token.authentication_token + end + end + end + + # + # Returns the current auth method + # + desc 'Authentication method configuration' + get '/auth/method' do + response = { + method: Doubtfire::Application.config.auth_method + } + response[:redirect_to] = + if aaf_auth? + Doubtfire::Application.config.aaf[:redirect_url] + elsif saml_auth? + request = OneLogin::RubySaml::Authrequest.new + request.create(AuthenticationHelpers.saml_settings) + end + present response, with: Grape::Presenters::Presenter + end + + # + # Returns the current auth signout URL + # + desc 'Authentication signout URL' + get '/auth/signout_url' do + response = {} + response[:auth_signout_url] = + if aaf_auth? && Doubtfire::Application.config.aaf[:auth_signout_url].present? + Doubtfire::Application.config.aaf[:auth_signout_url] + elsif saml_auth? && Doubtfire::Application.config.saml[:idp_sso_signout_url].present? + Doubtfire::Application.config.saml[:idp_sso_signout_url] + end + present response, with: Grape::Presenters::Presenter + end + + # + # Update the expiry of an existing authentication token + # + desc 'Allow tokens to be updated', + { + headers: + { + "username" => + { + description: "User username", + required: true + }, + "auth_token" => + { + description: "The user\'s temporary auth token", + required: true + } + } + } + params do + optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false + end + put '/auth' do + token_param = headers['Auth-Token'] || params['Auth-Token'] + user_param = headers['Username'] || params['Username'] + + error!({ error: 'Invalid token/username.' }, 404) if token_param.nil? || user_param.nil? + + logger.info "Update token #{token_param} from #{request.ip} for #{user_param}" + + # Find user + user = User.find_by_username(user_param) + token = user.token_for_text?(token_param) unless user.nil? + remember = params[:remember] || false + + # Token does not match user + if token.nil? || user.nil? || user.username != user_param + error!({ error: 'Invalid token.' }, 404) + else + token.extend_token remember if token.auth_token_expiry > Time.zone.now + + # Return extended auth token + present :auth_token, token.authentication_token + end + end + + # + # Sign out + # + desc 'Sign out', + { + headers: + { + "username" => + { + description: "User username", + required: true + }, + "auth_token" => + { + description: "The user\'s temporary auth token", + required: true + } + } + } + delete '/auth' do + user = User.find_by_username(headers['Username']) + token = user.token_for_text?(headers['Auth-Token']) unless user.nil? + + if token.present? + logger.info "Sign out #{user.username} from #{request.ip}" + token.destroy! + end + + present nil + end +end diff --git a/app/api/breaks_api.rb b/app/api/breaks_api.rb new file mode 100644 index 000000000..2bcb24601 --- /dev/null +++ b/app/api/breaks_api.rb @@ -0,0 +1,77 @@ +require 'grape' + +class BreaksApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add a new break to the teaching period' + params do + requires :start_date, type: Date, desc: 'The start date of the break' + requires :number_of_weeks, type: Integer, desc: 'Break duration' + end + post '/teaching_periods/:teaching_period_id/breaks' do + unless authorise? current_user, User, :handle_teaching_period + error!({ error: 'Not authorised to add a break' }, 403) + end + + # Find the Teaching Period to add break + teaching_period = TeachingPeriod.find(params[:teaching_period_id]) + + start_date = params[:start_date] + number_of_weeks = params[:number_of_weeks] + + result = teaching_period.add_break(start_date, number_of_weeks) + present result, with: Entities::BreakEntity + end + + desc 'Update a break in the teaching period' + params do + optional :start_date, type: Date, desc: 'The start date of the break' + optional :number_of_weeks, type: Integer, desc: 'Break duration' + end + put '/teaching_periods/:teaching_period_id/breaks/:id' do + unless authorise? current_user, User, :handle_teaching_period + error!({ error: 'Not authorised to update a break' }, 403) + end + + # Find the Teaching Period to update break + teaching_period = TeachingPeriod.find(params[:teaching_period_id]) + + id = params[:id] + start_date = params[:start_date] + number_of_weeks = params[:number_of_weeks] + + result = teaching_period.update_break(id, start_date, number_of_weeks) + present result, with: Entities::BreakEntity + end + + desc 'Get all the breaks in the Teaching Period' + get '/teaching_periods/:teaching_period_id/breaks' do + unless authorise? current_user, User, :get_teaching_periods + error!({ error: 'Not authorised to get breaks' }, 403) + end + + teaching_period = TeachingPeriod.find(params[:teaching_period_id]) + present teaching_period.breaks, with: Entities::BreakEntity + end + + desc 'Remove a break from a teaching period' + delete '/teaching_periods/:teaching_period_id/breaks/:id' do + unless authorise? current_user, User, :handle_teaching_period + error!({ error: 'Not authorised to delete a break' }, 403) + end + + # Find the Teaching Period to update break + teaching_period = TeachingPeriod.find(params[:teaching_period_id]) + + id = params[:id] + the_break = teaching_period.breaks.find(id) + + the_break.destroy + present the_break.destroyed?, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/campuses_authenticated_api.rb b/app/api/campuses_authenticated_api.rb new file mode 100644 index 000000000..28fd53c08 --- /dev/null +++ b/app/api/campuses_authenticated_api.rb @@ -0,0 +1,76 @@ +require 'grape' + +class CampusesAuthenticatedApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add a Campus' + params do + requires :campus, type: Hash do + requires :name, type: String, desc: 'The name of the campus' + requires :mode, type: String, desc: 'This will determine the campus mode', values: ['timetable', 'automatic', 'manual'] + requires :abbreviation, type: String, desc: 'The abbreviation for the campus' + requires :active, type: Boolean, desc: 'Determines whether campus is active' + end + end + post '/campuses' do + unless authorise? current_user, User, :handle_campuses + error!({ error: 'Not authorised to create a campus' }, 403) + end + campus_parameters = ActionController::Parameters.new(params) + .require(:campus) + .permit(:name, + :mode, + :abbreviation, + :active) + + result = Campus.create!(campus_parameters) + + if result.nil? + error!({ error: 'No campus added.' }, 403) + else + present result, with: Entities::CampusEntity + end + end + + desc 'Update Campus' + params do + requires :campus, type: Hash do + optional :name, type: String, desc: 'The name of the campus' + optional :mode, type: String, desc: 'This will determine the campus mode', values: ['timetable', 'automatic', 'manual'] + optional :abbreviation, type: String, desc: 'The abbreviation for the campus' + optional :active, type: Boolean, desc: 'Determines whether campus is active' + end + end + put '/campuses/:id' do + campus = Campus.find(params[:id]) + unless authorise? current_user, User, :handle_campuses + error!({ error: 'Not authorised to update a campus' }, 403) + end + campus_parameters = ActionController::Parameters.new(params) + .require(:campus) + .permit(:name, + :mode, + :abbreviation, + :active) + + campus.update!(campus_parameters) + present campus, with: Entities::CampusEntity + end + + desc 'Delete a campus' + delete '/campuses/:id' do + unless authorise? current_user, User, :handle_campuses + error!({ error: 'Not authorised to delete a campus' }, 403) + end + + campus = Campus.find(params[:id]) + campus.destroy + error!({ error: campus.errors.full_messages.last }, 403) unless campus.destroyed? + present campus.destroyed?, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/campuses_public_api.rb b/app/api/campuses_public_api.rb new file mode 100644 index 000000000..9d4eec219 --- /dev/null +++ b/app/api/campuses_public_api.rb @@ -0,0 +1,15 @@ +require 'grape' + +class CampusesPublicApi < Grape::API + + desc "Get a campus details" + get '/campuses/:id' do + campus = Campus.find(params[:id]) + present campus, with: Entities::CampusEntity + end + + desc 'Get all the Campuses' + get '/campuses' do + present Campus.all, with: Entities::CampusEntity + end +end diff --git a/app/api/discussion_comment_api.rb b/app/api/discussion_comment_api.rb new file mode 100644 index 000000000..d7eac131e --- /dev/null +++ b/app/api/discussion_comment_api.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true +require 'grape' + +class DiscussionCommentApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add a new discussion comment to a task' + params do + requires :attachments, type: Array do + requires type: File, desc: 'audio prompts.' + end + end + post '/projects/:project_id/task_def_id/:task_definition_id/discussion_comments' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :create_discussion + error!({ error: 'Not authorised to create a discussion comment for this task' }, 403) + end + + attached_files = params[:attachments] + + for attached_file in attached_files do + if attached_file.present? + error!(error: 'Attachment is empty.') unless File.size?(attached_file["tempfile"].path).present? + error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 + end + end + + type_string = content_type.to_s + + logger.info("#{current_user.username} - added discussion comment for task #{task.id} (#{task_definition.abbreviation})") + + if attached_files.nil? || attached_files.empty? + error!({ error: 'Audio prompts are empty, unable to add new discussion comment' }, 403) + end + + result = task.add_discussion_comment(current_user, attached_files) + + present result.serialize(current_user), Grape::Presenters::Presenter + end + + desc 'Get a discussion comment prompt' + params do + optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + end + get '/projects/:project_id/task_def_id/:task_definition_id/comments/:task_comment_id/discussion_comment/prompt_number/:prompt_number' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + prompt_number = params[:prompt_number] + + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :get_discussion + error!({ error: 'You cannot get this discussion prompt' }, 403) + end + + if project.has_task_for_task_definition? task_definition + task = project.task_for_task_definition(task_definition) + discussion_comment = task.all_comments.find(params[:task_comment_id]).becomes(DiscussionComment) + discussion_comment.mark_discussion_started + + prompt_path = discussion_comment.attachment_path(prompt_number) + + error!({ error: 'File missing' }, 404) unless File.exist? prompt_path + logger.info("#{current_user.username} - get discussion comment for task #{task.id} (#{task_definition.abbreviation})") + + content_type('audio/wav; charset:binary') + env['api.format'] = :binary + + # mark as attachment + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{prompt_path}" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + end + + # Work out what part to return + file_size = File.size(prompt_path) + begin_point = 0 + end_point = file_size - 1 + + # Was it asked for just a part of the file? + if request.headers['Range'] + # indicate partial content + status 206 + + # extract part desired from the content + if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ + begin_point = Regexp.last_match(1).to_i + end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? + end + + end_point = file_size - 1 unless end_point < file_size - 1 + end + + # Return the requested content + content_length = end_point - begin_point + 1 + header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" + header['Content-Length'] = content_length.to_s + header['Accept-Ranges'] = 'bytes' + + # Read the binary data and return + result = IO.binread(prompt_path, content_length, begin_point) + result + end + end + + desc 'Get a discussion comment student response' + params do + optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + end + get '/projects/:project_id/task_def_id/:task_definition_id/comments/:task_comment_id/discussion_comment/response' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :get_discussion + error!({ error: 'You cannot get this discussion prompt' }, 403) + end + + if project.has_task_for_task_definition? task_definition + task = project.task_for_task_definition(task_definition) + discussion_comment = task.all_comments.find(params[:task_comment_id]).becomes(DiscussionComment) + + response_path = discussion_comment.reply_attachment_path + + error!({ error: 'File missing' }, 404) unless File.exist? response_path + logger.info("#{current_user.username} - get discussion comment for task #{task.id} (#{task_definition.abbreviation})") + + content_type('audio/wav; charset:binary') + env['api.format'] = :binary + + # mark as attachment + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{response_path}" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + end + + # Work out what part to return + file_size = File.size(response_path) + begin_point = 0 + end_point = file_size - 1 + + # Was it asked for just a part of the file? + if request.headers['Range'] + # indicate partial content + status 206 + + # extract part desired from the content + if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ + begin_point = Regexp.last_match(1).to_i + end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? + end + + end_point = file_size - 1 unless end_point < file_size - 1 + end + + # Return the requested content + content_length = end_point - begin_point + 1 + header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" + header['Content-Length'] = content_length.to_s + header['Accept-Ranges'] = 'bytes' + + # Read the binary data and return + result = IO.binread(response_path, content_length, begin_point) + result + end + end + + desc 'Reply to a discussion comment of a task' + params do + requires :attachment, type: File, desc: 'discussion reply.' + end + post '/projects/:project_id/task_def_id/:task_definition_id/comments/:task_comment_id/discussion_comment/reply' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :make_discussion_reply + error!({ error: 'Not authorised to reply to this discussion comment' }, 403) + end + + attached_file = params[:attachment] + + if attached_file.present? + error!(error: 'Attachment is empty.') unless File.size?(attached_file["tempfile"].path).present? + error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 + end + + logger.info("#{current_user.username} - added a reply to the discussion comment #{params[:task_comment_id]} for task #{task.id} (#{task_definition.abbreviation})") + + if attached_file.nil? || attached_file.empty? + error!({ error: 'Discussion reply is empty, unable to add new reply to discussion comment' }, 403) + end + + discussion_comment = task.all_comments.find(params[:task_comment_id]) + # discussion_comment.mark_discussion_completed + # mark comment read for student + discussion_comment.mark_as_read(current_user, project.unit) + + error!({ error: 'No discussion comment found for the given task' }, 403) if discussion_comment.nil? + + result = discussion_comment.add_reply(attached_file) + nil + end +end diff --git a/app/api/entities/activity_type_entity.rb b/app/api/entities/activity_type_entity.rb new file mode 100644 index 000000000..d878097c9 --- /dev/null +++ b/app/api/entities/activity_type_entity.rb @@ -0,0 +1,7 @@ +module Entities + class ActivityTypeEntity < Grape::Entity + expose :id + expose :name + expose :abbreviation + end +end diff --git a/app/api/entities/break_entity.rb b/app/api/entities/break_entity.rb new file mode 100644 index 000000000..f21c06e57 --- /dev/null +++ b/app/api/entities/break_entity.rb @@ -0,0 +1,15 @@ +module Entities + class BreakEntity < Grape::Entity + format_with(:date_only) do |date| + date.strftime('%Y-%m-%d') + end + + expose :id + + with_options(format_with: :date_only) do + expose :start_date + end + + expose :number_of_weeks + end +end diff --git a/app/api/entities/campus_entity.rb b/app/api/entities/campus_entity.rb new file mode 100644 index 000000000..ccb7d1e35 --- /dev/null +++ b/app/api/entities/campus_entity.rb @@ -0,0 +1,9 @@ +module Entities + class CampusEntity < Grape::Entity + expose :id + expose :name + expose :mode + expose :abbreviation + expose :active + end +end diff --git a/app/api/entities/comment_entity.rb b/app/api/entities/comment_entity.rb new file mode 100644 index 000000000..014ddd8be --- /dev/null +++ b/app/api/entities/comment_entity.rb @@ -0,0 +1,52 @@ +module Entities + class CommentEntity < Grape::Entity + expose :id + expose :comment + expose :has_attachment do |data, options| + ["audio", "image", "pdf"].include?(data.content_type) + end + expose :type do |data, options| + data.content_type || "text" + end + expose :is_new do |data, options| + if data.has_attribute?(:is_new) && data.is_new.present? + data.is_new != 0 + else + data.new_for?(options[:current_user]) + end + end + expose :reply_to_id + expose :created_at + expose :recipient_read_time, safe: true + expose :author do |data, options| + if data.has_attribute? :author_id + { + id: data.author_id, + name: "#{data.author_first_name} #{data.author_last_name}", + email: data.author_email + } + else + { + id: data.user_id, + name: data.user.name, + email: data.user.email + } + end + end + expose :recipient do |data, options| + if data.has_attribute? :recipient_first_name + { + id: data.recipient_id, + name: "#{data.recipient_first_name} #{data.recipient_last_name}", + email: data.recipient_email + } + else + { + id: data.recipient_id, + name: data.recipient.name, + email: data.recipient.email + } + end + end + end +end diff --git a/app/api/entities/group_entity.rb b/app/api/entities/group_entity.rb new file mode 100644 index 000000000..1fc44a73a --- /dev/null +++ b/app/api/entities/group_entity.rb @@ -0,0 +1,11 @@ +module Entities + class GroupEntity < Grape::Entity + expose :id + expose :name + expose :tutorial_id + expose :group_set_id + expose :student_count #TODO: remove this and request it dynamically when needed + expose :capacity_adjustment + expose :locked + end +end diff --git a/app/api/entities/group_membership_entity.rb b/app/api/entities/group_membership_entity.rb new file mode 100644 index 000000000..8af3eb4bb --- /dev/null +++ b/app/api/entities/group_membership_entity.rb @@ -0,0 +1,6 @@ +module Entities + class GroupMembershipEntity < Grape::Entity + expose :group_id + expose :project_id + end +end diff --git a/app/api/entities/group_set_entity.rb b/app/api/entities/group_set_entity.rb new file mode 100644 index 000000000..4c4397348 --- /dev/null +++ b/app/api/entities/group_set_entity.rb @@ -0,0 +1,11 @@ +module Entities + class GroupSetEntity < Grape::Entity + expose :id + expose :name + expose :allow_students_to_create_groups + expose :allow_students_to_manage_groups + expose :keep_groups_in_same_class + expose :capacity + expose :locked + end +end diff --git a/app/api/entities/learning_outcome_entity.rb b/app/api/entities/learning_outcome_entity.rb new file mode 100644 index 000000000..2467bd742 --- /dev/null +++ b/app/api/entities/learning_outcome_entity.rb @@ -0,0 +1,9 @@ +module Entities + class LearningOutcomeEntity < Grape::Entity + expose :id + expose :ilo_number + expose :abbreviation + expose :name + expose :description + end +end diff --git a/app/api/entities/overseer_assessment_entity.rb b/app/api/entities/overseer_assessment_entity.rb new file mode 100644 index 000000000..b8402dcf5 --- /dev/null +++ b/app/api/entities/overseer_assessment_entity.rb @@ -0,0 +1,11 @@ +module Entities + class OverseerAssessmentEntity < Grape::Entity + expose :id + expose :task_id + expose :submission_timestamp + expose :result_task_status + expose :status + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/overseer_image_entity.rb b/app/api/entities/overseer_image_entity.rb new file mode 100644 index 000000000..56057f082 --- /dev/null +++ b/app/api/entities/overseer_image_entity.rb @@ -0,0 +1,7 @@ +module Entities + class OverseerImageEntity < Grape::Entity + expose :id + expose :name + expose :tag + end +end diff --git a/app/api/entities/project_entity.rb b/app/api/entities/project_entity.rb new file mode 100644 index 000000000..d1f961f11 --- /dev/null +++ b/app/api/entities/project_entity.rb @@ -0,0 +1,33 @@ +module Entities + class ProjectEntity < Grape::Entity + expose :unit_id + expose :id, as: :project_id + expose :student_id do |project, options| + project.student.username + end + expose :campus_id + expose :student_name do |project, options| + "#{project.student.name}#{project.student.nickname.blank? ? '' : ' (' << project.student.nickname << ')'}" + end + expose :enrolled + expose :target_grade + expose :submitted_grade + expose :portfolio_files + expose :compile_portfolio + expose :portfolio_available + expose :uses_draft_learning_summary + + expose :task_stats, as: :stats + expose :burndown_chart_data + + expose :tasks do | project, options | + project.task_details_for_shallow_serializer(options[:user]) + end + expose :tutorial_enrolments, using: TutorialEnrolmentEntity + expose :groups, using: GroupEntity + expose :task_outcome_alignments, using: TaskOutcomeAlignmentEntity + + expose :grade, if: :for_staff + expose :grade_rationale, if: :for_staff + end +end diff --git a/app/api/entities/task_definition_entity.rb b/app/api/entities/task_definition_entity.rb new file mode 100644 index 000000000..4bfc5f8ed --- /dev/null +++ b/app/api/entities/task_definition_entity.rb @@ -0,0 +1,37 @@ +module Entities + class TaskDefinitionEntity < Grape::Entity + format_with(:date_only) do |date| + date.strftime('%Y-%m-%d') + end + + expose :id + expose :abbreviation + expose :name + expose :description + expose :weighting, as: :weight + expose :target_grade + + with_options(format_with: :date_only) do + expose :target_date + expose :due_date + expose :start_date + end + + expose :upload_requirements + expose :tutorial_stream do |tutorial, options| + tutorial.tutorial_stream.abbreviation unless tutorial.tutorial_stream.nil? + end + expose :plagiarism_checks + expose :plagiarism_report_url + expose :plagiarism_warn_pct + expose :restrict_status_updates + expose :group_set_id + expose :has_task_sheet?, as: :has_task_sheet + expose :has_task_resources?, as: :has_task_resources + expose :has_task_assessment_resources?, as: :has_task_assessment_resources + expose :is_graded + expose :max_quality_pts + expose :overseer_image_id + expose :assessment_enabled + end +end diff --git a/app/api/entities/task_entity.rb b/app/api/entities/task_entity.rb new file mode 100644 index 000000000..3b0271cd5 --- /dev/null +++ b/app/api/entities/task_entity.rb @@ -0,0 +1,44 @@ +module Entities + class TaskEntity < Grape::Entity + expose :id + expose :project_id + expose :task_definition_id + + expose :status + + expose :due_date + expose :extensions + + expose :submission_date + expose :completion_date + + expose :times_assessed + expose :grade + expose :quality_pts + + expose :include_in_portfolio + + # Attributes excluded from update only + + expose :pct_similar, unless: :update_only + expose :similar_to_count, unless: :update_only + expose :similar_to_dismissed_count, unless: :update_only + + expose :num_new_comments, unless: :update_only + + # Attributes only included in "update only" + + expose :new_stats, if: :update_only do |task, options| + task.project.task_stats + end + + # Attributes only included if include other projects + + expose :other_projects, if: :include_other_projects do |task, options| + if task.group_task? && !task.group.nil? + grp = task.group + grp.projects.select { |p| p.id != task.project_id }.map { |p| { id: p.id, new_stats: p.task_stats } } + end + end + end +end diff --git a/app/api/entities/task_outcome_alignment_entity.rb b/app/api/entities/task_outcome_alignment_entity.rb new file mode 100644 index 000000000..3e7cd9a84 --- /dev/null +++ b/app/api/entities/task_outcome_alignment_entity.rb @@ -0,0 +1,10 @@ +module Entities + class TaskOutcomeAlignmentEntity < Grape::Entity + expose :id + expose :description + expose :rating + expose :learning_outcome_id + expose :task_definition_id + expose :task_id + end +end diff --git a/app/api/entities/teaching_period_entity.rb b/app/api/entities/teaching_period_entity.rb new file mode 100644 index 000000000..8d6067b06 --- /dev/null +++ b/app/api/entities/teaching_period_entity.rb @@ -0,0 +1,17 @@ +module Entities + class TeachingPeriodEntity < Grape::Entity + expose :id + expose :period + expose :year + expose :start_date + expose :end_date + expose :active_until + expose :active do |teaching_period, options| + object.active_until > DateTime.now + end + expose :breaks, if: :full_details, using: Entities::BreakEntity + expose :units, if: :full_details do |teaching_period, options| + Entities::UnitEntity.represent teaching_period.units, only: [:id, :name, :code, :active] + end + end +end diff --git a/app/api/entities/tutorial_enrolment_entity.rb b/app/api/entities/tutorial_enrolment_entity.rb new file mode 100644 index 000000000..6c1f1dc39 --- /dev/null +++ b/app/api/entities/tutorial_enrolment_entity.rb @@ -0,0 +1,7 @@ +module Entities + class TutorialEnrolmentEntity < Grape::Entity + expose :id + expose :project_id + expose :tutorial_id + end +end diff --git a/app/api/entities/tutorial_entity.rb b/app/api/entities/tutorial_entity.rb new file mode 100644 index 000000000..624f57ff0 --- /dev/null +++ b/app/api/entities/tutorial_entity.rb @@ -0,0 +1,18 @@ +module Entities + class TutorialEntity < Grape::Entity + expose :id + expose :meeting_day + expose :meeting_time # ?? should we use: tutorial.meeting_time.to_time + expose :meeting_location + expose :abbreviation + expose :campus_id + expose :capacity + expose :tutorial_stream do |tutorial, options| + tutorial.tutorial_stream.abbreviation unless tutorial.tutorial_stream.nil? + end + expose :num_students #TODO: remove this and request it dynamically when needed + expose :tutor do |tutorial, options| + Entities::UserEntity.represent tutorial.tutor, only: [:id, :name] + end + end +end diff --git a/app/api/entities/tutorial_stream_entity.rb b/app/api/entities/tutorial_stream_entity.rb new file mode 100644 index 000000000..586b00d2b --- /dev/null +++ b/app/api/entities/tutorial_stream_entity.rb @@ -0,0 +1,10 @@ +module Entities + class TutorialStreamEntity < Grape::Entity + expose :id + expose :name + expose :abbreviation + expose :activity_type do |stream, options| + stream.activity_type.abbreviation #TODO: cache all activities in the client and just send the code + end + end +end diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb new file mode 100644 index 000000000..434e26be3 --- /dev/null +++ b/app/api/entities/unit_entity.rb @@ -0,0 +1,55 @@ +module Entities + class UnitEntity < Grape::Entity + format_with(:date_only) do |date| + date.strftime('%Y-%m-%d') + end + + def is_staff?(user, unit) + [ Role.convenor_id, Role.tutor_id, Role.admin_id ].include? unit.role_for(user).id + end + + expose :code + expose :id + expose :name + expose :my_role do |unit, options| + role = unit.role_for(options[:user]) + role.name unless role.nil? + end + expose :main_convenor_id + expose :description + expose :teaching_period_id, expose_nil: false + + with_options(format_with: :date_only) do + expose :start_date + expose :end_date + end + + expose :active + + expose :overseer_image_id, unless: :summary_only + expose :assessment_enabled, unless: :summary_only + + expose :auto_apply_extension_before_deadline, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + expose :send_notifications, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + expose :enable_sync_enrolments, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + expose :enable_sync_timetable, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + expose :draft_task_definition_id, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + expose :allow_student_extension_requests, unless: :summary_only + expose :extension_weeks_on_resubmit_request, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + expose :allow_student_change_tutorial, unless: :summary_only + + expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only + expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only + expose :tutorials, using: TutorialEntity, unless: :summary_only + expose :tutorial_enrolments, using: TutorialEnrolmentEntity, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:user], unit) } + + expose :task_definitions, using: TaskDefinitionEntity, unless: :summary_only + expose :task_outcome_alignments, using: TaskOutcomeAlignmentEntity, unless: :summary_only + expose :staff, using: UnitRoleEntity, unless: :summary_only + expose :group_sets, using: GroupSetEntity, unless: :summary_only + expose :groups, using: GroupEntity, unless: :summary_only + expose :group_memberships, using: GroupMembershipEntity, unless: :summary_only do |unit, options| + unit.group_memberships.where(active: true) + end + end +end diff --git a/app/api/entities/unit_role_entity.rb b/app/api/entities/unit_role_entity.rb new file mode 100644 index 000000000..52a857c24 --- /dev/null +++ b/app/api/entities/unit_role_entity.rb @@ -0,0 +1,19 @@ +module Entities + class UnitRoleEntity < Grape::Entity + expose :id + expose :role do |unit_role, options| unit_role.role.name end + expose :user_id + expose :name do |unit_role, options| unit_role.user.name end + expose :email do |unit_role, options| unit_role.user.email end + expose :unit_id, unless: :in_unit + end + + class UnitRoleWithUnitEntity < UnitRoleEntity + expose :unit_code do |unit_role, options| unit_role.unit.code end + expose :unit_name do |unit_role, options| unit_role.unit.name end + expose :start_date do |unit_role, options| unit_role.unit.start_date end + expose :end_date do |unit_role, options| unit_role.unit.end_date end + expose :teaching_period_id do |unit_role, options| unit_role.unit.teaching_period_id end + expose :active do |unit_role, options| unit_role.unit.active end + end +end diff --git a/app/api/entities/user_entity.rb b/app/api/entities/user_entity.rb new file mode 100644 index 000000000..a8791038a --- /dev/null +++ b/app/api/entities/user_entity.rb @@ -0,0 +1,22 @@ + +module Entities + class UserEntity < Grape::Entity + expose :id + expose :student_id + expose :email + expose :name + expose :first_name + expose :last_name + expose :username + expose :nickname + expose :receive_task_notifications + expose :receive_portfolio_notifications + expose :receive_feedback_notifications + expose :opt_in_to_research + expose :has_run_first_time_setup + + expose :system_role do |user, options| + user.role.name if user.role.present? + end + end +end diff --git a/app/api/entities/webcal_entity.rb b/app/api/entities/webcal_entity.rb new file mode 100644 index 000000000..d316324d2 --- /dev/null +++ b/app/api/entities/webcal_entity.rb @@ -0,0 +1,27 @@ + +module Entities + class WebcalEntity < Grape::Entity + expose :id, expose_nil: false + expose :guid, expose_nil: false + expose :include_start_dates, expose_nil: false + + expose :enabled do |webcal, options| + webcal.present? + end + + expose :reminder, expose_nil: false do |webcal, options| + if webcal.nil? || webcal.reminder_time.nil? || webcal.reminder_unit.nil? + nil + else + { + time: webcal.reminder_time, + unit: webcal.reminder_unit + } + end + end + + expose :unit_exclusions do |webcal, options| + webcal.webcal_unit_exclusions.map(&:unit_id) + end + end +end diff --git a/app/api/extension_comments_api.rb b/app/api/extension_comments_api.rb new file mode 100644 index 000000000..7617b3d3d --- /dev/null +++ b/app/api/extension_comments_api.rb @@ -0,0 +1,58 @@ +require 'grape' + +class ExtensionCommentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + desc 'Request an extension for a task' + params do + requires :comment, type: String, desc: 'The details of the request' + requires :weeks_requested, type: Integer, desc: 'The details of the request' + end + post '/projects/:project_id/task_def_id/:task_definition_id/request_extension' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + # check permissions using specific permission has with addition of request extension if allowed in unit + unless authorise? current_user, task, :request_extension, ->(role, perm_hash, other) { task.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to request an extension for this task' }, 403) + end + + error!({error:'Extension weeks can not be 0.'}, 403) if params[:weeks_requested] == 0 + + max_duration = task.weeks_can_extend + duration = params[:weeks_requested] + duration = max_duration unless params[:weeks_requested] <= max_duration + + error!({error:'Extensions cannot be granted beyond task deadline.'}, 403) if duration <= 0 + + result = task.apply_for_extension(current_user, params[:comment], duration) + present result.serialize(current_user), Grape::Presenters::Presenter + end + + desc 'Assess an extension for a task' + params do + requires :granted, type: Boolean, desc: 'Assess an extension' + end + put '/projects/:project_id/task_def_id/:task_definition_id/assess_extension/:task_comment_id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :assess_extension + error!({ error: 'Not authorised to assess an extension for this task' }, 403) + end + + task_comment = task.all_comments.find(params[:task_comment_id]).becomes(ExtensionComment) + + unless task_comment.assess_extension(current_user, params[:granted]) + if task_comment.errors.count >= 1 + error!({error: task_comment.errors.full_messages.first}, 403) + else + error!({error: 'Error saving extension'}, 403) + end + end + present task_comment.serialize(current_user), Grape::Presenters::Presenter + end +end diff --git a/app/api/group_sets.rb b/app/api/group_sets.rb deleted file mode 100644 index 072554f30..000000000 --- a/app/api/group_sets.rb +++ /dev/null @@ -1,339 +0,0 @@ -require 'grape' -require 'mime-check-helpers' - -module Api - # - # Allow GroupSets to be managed via the API - # - class GroupSets < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers MimeCheckHelpers - helpers LogHelper - - before do - authenticated? - end - - # ------------------------------------------------------------------------ - # Group Sets - # ------------------------------------------------------------------------ - - desc 'Add a new group set to the given unit' - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group set' - requires :group_set, type: Hash do - requires :name, type: String, desc: 'The name of this group set' - optional :allow_students_to_create_groups, type: Boolean, desc: 'Are students allowed to create groups' - optional :allow_students_to_manage_groups, type: Boolean, desc: 'Are students allowed to manage their group memberships' - optional :keep_groups_in_same_class, type: Boolean, desc: 'Must groups be kept in the one class' - end - end - post '/units/:unit_id/group_sets' do - unit = Unit.find(params[:unit_id]) - unless authorise? current_user, unit, :update - error!({ error: 'Not authorised to create a group set for this unit' }, 403) - end - - logger.info "Create group set: #{current_user.username} in #{unit.code} from #{request.ip}" - - group_params = ActionController::Parameters.new(params) - .require(:group_set) - .permit( - :name, - :allow_students_to_create_groups, - :allow_students_to_manage_groups, - :keep_groups_in_same_class - ) - - group_set = GroupSet.create!(group_params) - group_set.unit = unit - group_set.save! - group_set - end - - desc 'Edits the given group set' - params do - requires :id, type: Integer, desc: 'The group set id to edit' - requires :group_set, type: Hash do - optional :name, type: String, desc: 'The name of this group set' - optional :allow_students_to_create_groups, type: Boolean, desc: 'Are students allowed to create groups' - optional :allow_students_to_manage_groups, type: Boolean, desc: 'Are students allowed to manage their group memberships' - optional :keep_groups_in_same_class, type: Boolean, desc: 'Must groups be kept in the one class' - end - end - put '/units/:unit_id/group_sets/:id' do - group_set = GroupSet.find(params[:id]) - unit = Unit.find(params[:unit_id]) - - logger.info "Edit group set: #{current_user.username} in #{unit.code} from #{request.ip}" - - if group_set.unit != unit - error!({ error: 'Unable to locate group set for unit' }, 404) - end - - unless authorise? current_user, unit, :update - error!({ error: 'Not authorised to update group set for this unit' }, 403) - end - - group_params = ActionController::Parameters.new(params) - .require(:group_set) - .permit( - :name, - :allow_students_to_create_groups, - :allow_students_to_manage_groups, - :keep_groups_in_same_class - ) - - group_set.update!(group_params) - group_set - end - - desc 'Delete a group set' - delete '/units/:unit_id/group_sets/:id' do - group_set = GroupSet.find(params[:id]) - unit = Unit.find(params[:unit_id]) - - logger.info "Delete group set: #{current_user.username} in #{unit.code} from #{request.ip}" - - if group_set.unit != unit - error!({ error: 'Unable to locate group set for unit' }, 404) - end - - unless authorise? current_user, unit, :update - error!({ error: 'Not authorised to delete group set for this unit' }, 403) - end - - error!(error: group_set.errors[:base].last) unless group_set.destroy - nil - end - - # ------------------------------------------------------------------------ - # Groups - # ------------------------------------------------------------------------ - - desc 'Get the groups in a group set' - get '/units/:unit_id/group_sets/:id/groups' do - unit = Unit.find(params[:unit_id]) - group_set = unit.group_sets.find(params[:id]) - - unless authorise? current_user, group_set, :get_groups, ->(role, perm_hash, other) { group_set.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to get groups for this unit' }, 403) - end - - group_set.groups - end - - desc 'Download a CSV of groups in a group set' - get '/units/:unit_id/group_sets/:group_set_id/groups/csv' do - unit = Unit.find(params[:unit_id]) - group_set = unit.group_sets.find(params[:group_set_id]) - - unless authorise? current_user, unit, :update - error!({ error: 'Not authorised to download csv of groups for this unit' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-groups.csv " - env['api.format'] = :binary - unit.export_groups_to_csv(group_set) - end - - desc "Add a new group to the given unit's group_set" - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group' - requires :group_set_id, type: Integer, desc: 'The id of the group set' - requires :group, type: Hash do - optional :name, type: String, desc: 'The name of this group' - requires :tutorial_id, type: Integer, desc: 'The id of the tutorial for the group' - end - end - post '/units/:unit_id/group_sets/:group_set_id/groups' do - unit = Unit.find(params[:unit_id]) - group_set = unit.group_sets.find(params[:group_set_id]) - tutorial = unit.tutorials.find(params[:group][:tutorial_id]) - - unless authorise? current_user, group_set, :create_group, ->(role, perm_hash, other) { group_set.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to create a group set for this unit' }, 403) - end - - group_params = ActionController::Parameters.new(params) - .require(:group) - .permit( - :name - ) - - # Group with the same name - unless group_set.groups.where(name: group_params[:name]).empty? - error!({ error: "This group name is not unique to the #{group_set.name} group set." }, 403) - end - - last = group_set.groups.last - num = last.nil? ? 1 : last.number + 1 - if group_params[:name].nil? || group_params[:name].empty? - group_params[:name] = "Group #{num}" - end - grp = Group.create(name: group_params[:name], group_set: group_set, tutorial: tutorial, number: num) - grp.save! - grp - end - - desc 'Upload a CSV for groups in a group set' - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group' - requires :group_set_id, type: Integer, desc: 'The id of the group set' - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - end - post '/units/:unit_id/group_sets/:group_set_id/groups/csv' do - # check mime is correct before uploading - ensure_csv!(params[:file][:tempfile]) - - unit = Unit.find(params[:unit_id]) - group_set = unit.group_sets.find(params[:group_set_id]) - - unless authorise? current_user, unit, :update - error!({ error: 'Not authorised to upload csv of groups for this unit' }, 403) - end - - unit.import_groups_from_csv(group_set, params[:file][:tempfile]) - end - - desc 'Edits the given group' - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group' - requires :group_set_id, type: Integer, desc: 'The id of the group set' - requires :group_id, type: Integer, desc: 'The id of the group' - requires :group, type: Hash do - optional :name, type: String, desc: 'The name of this group set' - optional :tutorial_id, type: Integer, desc: 'Tutorial of the group' - end - end - put '/units/:unit_id/group_sets/:group_set_id/groups/:group_id' do - unit = Unit.find(params[:unit_id]) - gs = unit.group_sets.find(params[:group_set_id]) - grp = gs.groups.find(params[:group_id]) - - unless authorise? current_user, grp, :manage_group, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to update this group' }, 403) - end - - # Switching tutorials will violate any existing group members - if !grp.group_memberships.empty? && params[:tutorial_id] != grp.tutorial.id && gs.keep_groups_in_same_class - error!({ error: 'Cannot modify group tutorial as members already exist and they must be in the same tutorial. Clear all members first.' }, 403) - end - - group_params = ActionController::Parameters.new(params) - .require(:group) - .permit( - :name, - :tutorial_id - ) - - grp.update!(group_params) - grp - end - - desc 'Delete a group' - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group' - requires :group_set_id, type: Integer, desc: 'The id of the group set' - requires :group_id, type: Integer, desc: 'The id of the group' - end - delete '/units/:unit_id/group_sets/:group_set_id/groups/:group_id' do - unit = Unit.find(params[:unit_id]) - gs = unit.group_sets.find(params[:group_set_id]) - grp = gs.groups.find(params[:group_id]) - - unless authorise? current_user, grp, :manage_group, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to delete group set for this unit' }, 403) - end - - unless unit.tutors.include? current_user - # check that they are the only member of the group, or the group is empty - error!({ error: 'You cannot delete a group with members' }, 403) unless grp.projects.count <= 1 - error!({ error: 'You cannot delete this group' }, 403) unless grp.projects.count.zero? || grp.projects.first.student == current_user - end - - error!(error: grp.errors[:base].last) unless grp.destroy - nil - end - - desc 'Get the members of a group' - get '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members' do - unit = Unit.find(params[:unit_id]) - group_set = unit.group_sets.find(params[:group_set_id]) - grp = group_set.groups.find(params[:group_id]) - - unless authorise? current_user, grp, :get_members, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to get groups for this unit' }, 403) - end - - Thread.current[:user] = current_user - ActiveModel::ArraySerializer.new(grp.projects, each_serializer: GroupMemberProjectSerializer) - end - - desc 'Add a group member' - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group' - requires :group_set_id, type: Integer, desc: 'The id of the group set' - requires :group_id, type: Integer, desc: 'The id of the group' - requires :project_id, type: Integer, desc: 'The project id of the member' - end - post '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members' do - unit = Unit.find(params[:unit_id]) - gs = unit.group_sets.find(params[:group_set_id]) - grp = gs.groups.find(params[:group_id]) - - prj = unit.projects.find(params[:project_id]) - - unless authorise? current_user, gs, :join_group, ->(role, perm_hash, other) { gs.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to manage this group' }, 403) - end - - unless authorise? current_user, prj, :get - error!({ error: 'Not authorised to manage this student' }, 403) - end - - if gs.keep_groups_in_same_class && prj.tutorial != grp.tutorial - error!({ error: "Students from the tutorial '#{grp.tutorial.abbreviation}' can only be added to this group." }, 403) - end - - if grp.group_memberships.find_by(project: prj, active: true) - error!({ error: "#{prj.student.name} is already a member of this group" }, 403) - end - - gm = grp.add_member(prj) - Thread.current[:user] = current_user - GroupMemberProjectSerializer.new(prj) - end - - desc 'Remove a group member' - params do - requires :unit_id, type: Integer, desc: 'The unit for the new group' - requires :group_set_id, type: Integer, desc: 'The id of the group set' - requires :group_id, type: Integer, desc: 'The id of the group' - requires :id, type: Integer, desc: 'The project id of the member' - end - delete '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members/:id' do - unit = Unit.find(params[:unit_id]) - gs = unit.group_sets.find(params[:group_set_id]) - grp = gs.groups.find(params[:group_id]) - prj = grp.projects.find(params[:id]) - - unless authorise? current_user, grp, :manage_group, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } - error!({ error: 'Not authorised to manage this group' }, 403) - end - - unless authorise? current_user, prj, :get - error!({ error: 'Not authorised to manage this student' }, 403) - end - - if grp.group_memberships.find_by(project: prj).nil? - error!({ error: "#{prj.student.name} is not a member of this group" }, 403) - end - - grp.remove_member(prj) - nil - end - end -end diff --git a/app/api/group_sets_api.rb b/app/api/group_sets_api.rb new file mode 100644 index 000000000..656225c51 --- /dev/null +++ b/app/api/group_sets_api.rb @@ -0,0 +1,458 @@ +require 'grape' + +# +# Allow GroupSets to be managed via the API +# +class GroupSetsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers MimeCheckHelpers + helpers LogHelper + + before do + authenticated? + end + + # ------------------------------------------------------------------------ + # Group Sets + # ------------------------------------------------------------------------ + + desc 'Add a new group set to the given unit' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group set' + requires :group_set, type: Hash do + requires :name, type: String, desc: 'The name of this group set' + optional :allow_students_to_create_groups, type: Boolean, desc: 'Are students allowed to create groups' + optional :allow_students_to_manage_groups, type: Boolean, desc: 'Are students allowed to manage their group memberships' + optional :keep_groups_in_same_class, type: Boolean, desc: 'Must groups be kept in the one class' + optional :capacity, type: Integer, desc: 'Capacity for each group' + end + end + post '/units/:unit_id/group_sets' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to create a group set for this unit' }, 403) + end + + logger.info "Create group set: #{current_user.username} in #{unit.code} from #{request.ip}" + + group_params = ActionController::Parameters.new(params) + .require(:group_set) + .permit( + :name, + :allow_students_to_create_groups, + :allow_students_to_manage_groups, + :keep_groups_in_same_class, + :capacity + ) + + group_set = GroupSet.create(group_params) + group_set.unit = unit + group_set.save! + present group_set, with: Entities::GroupSetEntity + end + + desc 'Edits the given group set' + params do + requires :id, type: Integer, desc: 'The group set id to edit' + requires :group_set, type: Hash do + optional :name, type: String, desc: 'The name of this group set' + optional :allow_students_to_create_groups, type: Boolean, desc: 'Are students allowed to create groups' + optional :allow_students_to_manage_groups, type: Boolean, desc: 'Are students allowed to manage their group memberships' + optional :keep_groups_in_same_class, type: Boolean, desc: 'Must groups be kept in the one class' + optional :capacity, type: Integer, desc: 'Capacity for each group' + optional :locked, type: Boolean, desc: 'Is this group set locked' + end + end + put '/units/:unit_id/group_sets/:id' do + group_set = GroupSet.find(params[:id]) + unit = Unit.find(params[:unit_id]) + + logger.info "Edit group set: #{current_user.username} in #{unit.code} from #{request.ip}" + + if group_set.unit != unit + error!({ error: 'Unable to locate group set for unit' }, 404) + end + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update group set for this unit' }, 403) + end + + group_params = ActionController::Parameters.new(params) + .require(:group_set) + .permit( + :name, + :allow_students_to_create_groups, + :allow_students_to_manage_groups, + :keep_groups_in_same_class, + :capacity, + :locked, + ) + + group_set.update!(group_params) + present group_set, with: Entities::GroupSetEntity + end + + desc 'Delete a group set' + delete '/units/:unit_id/group_sets/:id' do + group_set = GroupSet.find(params[:id]) + unit = Unit.find(params[:unit_id]) + + logger.info "Delete group set: #{current_user.username} in #{unit.code} from #{request.ip}" + + if group_set.unit != unit + error!({ error: 'Unable to locate group set for unit' }, 404) + end + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to delete group set for this unit' }, 403) + end + + error!(error: group_set.errors[:base].last) unless group_set.destroy + present true, with: Grape::Presenters::Presenter + end + + # ------------------------------------------------------------------------ + # Groups + # ------------------------------------------------------------------------ + + desc 'Get the groups in a group set' + get '/units/:unit_id/group_sets/:id/groups' do + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:id]) + + unless authorise? current_user, group_set, :get_groups, ->(role, perm_hash, other) { group_set.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to get groups for this unit' }, 403) + end + + result = group_set. + groups. + joins('LEFT OUTER JOIN group_memberships ON group_memberships.group_id = groups.id AND group_memberships.active = TRUE'). + group( + 'groups.id', + 'groups.name', + 'groups.tutorial_id', + 'groups.group_set_id', + 'groups.capacity_adjustment', + 'groups.locked', + ). + select( + 'groups.id as id', + 'groups.name as name', + 'groups.tutorial_id as tutorial_id', + 'groups.group_set_id as group_set_id', + 'groups.capacity_adjustment as capacity_adjustment', + 'groups.locked as locked', + 'COUNT(group_memberships.id) as student_count' + ) + present result, with: Grape::Presenters::Presenter + end + + desc 'Download a CSV of groups and their students in a group set' + get '/units/:unit_id/group_sets/:group_set_id/groups/student_csv' do + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:group_set_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to download csv of groups for this unit' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{group_set.name}-student-groups.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.export_student_groups_to_csv(group_set) + end + + desc 'Download a CSV of groups in a group set' + get '/units/:unit_id/group_sets/:group_set_id/groups/csv' do + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:group_set_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to download csv of groups for this unit' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{group_set.name}-groups.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.export_groups_to_csv(group_set) + end + + desc "Add a new group to the given unit's group_set" + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :group, type: Hash do + optional :name, type: String, desc: 'The name of this group' + requires :tutorial_id, type: Integer, desc: 'The id of the tutorial for the group' + optional :capacity_adjustment, type: Integer, desc: 'How capacity for group is adjusted', default: 0 + end + end + post '/units/:unit_id/group_sets/:group_set_id/groups' do + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:group_set_id]) + tutorial = unit.tutorials.find(params[:group][:tutorial_id]) + + unless authorise? current_user, group_set, :create_group, ->(role, perm_hash, other) { group_set.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to create a group set for this unit' }, 403) + end + + group_params = ActionController::Parameters.new(params) + .require(:group) + .permit( + :name, + :capacity_adjustment + ) + + # Group with the same name + unless group_set.groups.where(name: group_params[:name]).empty? + error!({ error: "This group name is not unique to the #{group_set.name} group set." }, 403) + end + + # Now check if they are a student... + project = nil + if unit.role_for(current_user) == Role.student + project = unit.active_projects.find_by(user_id: current_user.id) + # They cannot already be in a group for this group set + error!({error: "You are already in a group for #{group_set.name}"}, 403) unless project.group_for_groupset(group_set).nil? + end + + num = group_set.groups.count + 1 + while group_params[:name].nil? || group_params[:name].empty? || group_set.groups.where(name: group_params[:name]).count > 0 + group_params[:name] = "Group #{num}" + num += 1 + end + grp = Group.create(name: group_params[:name], group_set: group_set, tutorial: tutorial) + grp.save! + + # If they are a student, then add them to the group they created + if project.present? + grp.add_member(project) + end + + present grp, with: Entities::GroupEntity + end + + desc 'Upload a CSV for groups in a group set' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :file, type: File, desc: 'CSV upload file.' + end + post '/units/:unit_id/group_sets/:group_set_id/groups/csv' do + # check mime is correct before uploading + ensure_csv!(params[:file][:tempfile]) + + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:group_set_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to upload csv of groups for this unit' }, 403) + end + + present unit.import_groups_from_csv(group_set, params[:file][:tempfile]), with: Grape.Presenters.Presenter + end + + desc 'Upload a CSV for students in groups in a group set' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :file, type: File, desc: 'CSV upload file.' + end + post '/units/:unit_id/group_sets/:group_set_id/groups/student_csv' do + # check mime is correct before uploading + ensure_csv!(params[:file][:tempfile]) + + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:group_set_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to upload csv of groups for this unit' }, 403) + end + + present unit.import_student_groups_from_csv(group_set, params[:file][:tempfile]), with: Grape.Presenters.Presenter + end + + desc 'Edits the given group' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :group_id, type: Integer, desc: 'The id of the group' + requires :group, type: Hash do + optional :name, type: String, desc: 'The name of this group set' + optional :tutorial_id, type: Integer, desc: 'Tutorial of the group' + optional :capacity_adjustment, type: Integer, desc: 'How capacity for group is adjusted' + optional :locked, type: Boolean, desc: 'Is the group locked' + end + end + put '/units/:unit_id/group_sets/:group_set_id/groups/:group_id' do + unit = Unit.find(params[:unit_id]) + gs = unit.group_sets.find(params[:group_set_id]) + grp = gs.groups.find(params[:group_id]) + + unless authorise? current_user, grp, :manage_group, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to update this group' }, 403) + end + + group_params = ActionController::Parameters.new(params) + .require(:group) + .permit( + :name, + :tutorial_id, + :capacity_adjustment, + :locked, + ) + + # Allow locking only if the current user has permission to do so + if params[:group][:locked].present? && params[:group][:locked] != grp.locked + unless authorise? current_user, grp, :lock_group + error!({ error: "Not authorised to #{grp.locked ? 'unlock' : 'lock'} this group" }, 403) + end + end + + # Switching tutorials will violate any existing group members + if params[:group][:tutorial_id].present? && params[:group][:tutorial_id] != grp.tutorial_id + if authorise? current_user, grp, :move_tutorial + tutorial = unit.tutorials.find_by(id: params[:group][:tutorial_id]) + begin + grp.switch_to_tutorial tutorial + rescue StandardError => e + error!({ error: e.message }, 403) + end + else + error!({ error: 'You are not authorised to change the tutorial of this group' }, 403) + end + end + + if params[:group][:capacity_adjustment].present? && params[:group][:capacity_adjustment] != grp.capacity_adjustment + if authorise? current_user, grp, :move_tutorial + group_params[:capacity_adjustment] = params[:group][:capacity_adjustment] + else + error!({ error: 'You are not authorised to change the capacity of this group' }, 403) + end + end + + grp.update!(group_params) + present grp, with: Entities::GroupEntity + end + + desc 'Delete a group' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :group_id, type: Integer, desc: 'The id of the group' + end + delete '/units/:unit_id/group_sets/:group_set_id/groups/:group_id' do + unit = Unit.find(params[:unit_id]) + gs = unit.group_sets.find(params[:group_set_id]) + grp = gs.groups.find(params[:group_id]) + + unless authorise? current_user, grp, :manage_group, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to delete group set for this unit' }, 403) + end + + unless unit.tutors.include? current_user + # check that they are the only member of the group, or the group is empty + error!({ error: 'You cannot delete a group with members' }, 403) unless grp.projects.count <= 1 + error!({ error: 'You cannot delete this group' }, 403) unless grp.projects.count.zero? || grp.projects.first.student == current_user + end + + error!(error: grp.errors[:base].last) unless grp.destroy + true + end + + desc 'Get the members of a group' + get '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members' do + unit = Unit.find(params[:unit_id]) + group_set = unit.group_sets.find(params[:group_set_id]) + grp = group_set.groups.find(params[:group_id]) + + unless authorise? current_user, grp, :get_members, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to get groups for this unit' }, 403) + end + + present grp.projects, with: Entities::ProjectEntity, only: [:student_id, :project_id, :student_name, :target_grade], user: current_user + end + + desc 'Add a group member' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :group_id, type: Integer, desc: 'The id of the group' + requires :project_id, type: Integer, desc: 'The project id of the member' + end + post '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members' do + unit = Unit.find(params[:unit_id]) + gs = unit.group_sets.find(params[:group_set_id]) + grp = gs.groups.find(params[:group_id]) + + prj = unit.projects.find(params[:project_id]) + + unless authorise? current_user, gs, :join_group, ->(role, perm_hash, other) { gs.specific_permission_hash(role, perm_hash, other) } + if gs.locked + error!({ error: 'All of these groups are now locked' }, 403) + else + error!({ error: 'Not authorised to manage this group' }, 403) + end + end + + unless authorise? current_user, prj, :get + error!({ error: 'Not authorised to manage this student' }, 403) + end + + if gs.keep_groups_in_same_class && !prj.enrolled_in?(grp.tutorial) + error!({ error: "Students from the tutorial '#{grp.tutorial.abbreviation}' can only be added to this group." }, 403) + end + + if grp.active_group_members.find_by(project: prj, active: true) + error!({ error: "#{prj.student.name} is already a member of this group" }, 403) + end + + if grp.locked + error!({ error: 'Group is locked, no additional members can be added'}, 403) + end + + if grp.at_capacity? && ! authorise?(current_user, grp, :can_exceed_capacity) + error!({ error: 'Group is at capacity, no additional members can be added'}, 403) + end + + gm = grp.add_member(prj) + + present prj, with: Entities::ProjectEntity, only: [:student_id, :project_id, :student_name, :target_grade], user: current_user + end + + desc 'Remove a group member' + params do + requires :unit_id, type: Integer, desc: 'The unit for the new group' + requires :group_set_id, type: Integer, desc: 'The id of the group set' + requires :group_id, type: Integer, desc: 'The id of the group' + requires :id, type: Integer, desc: 'The project id of the member' + end + delete '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members/:id' do + unit = Unit.find(params[:unit_id]) + gs = unit.group_sets.find(params[:group_set_id]) + grp = gs.groups.find(params[:group_id]) + prj = grp.projects.find(params[:id]) + + unless authorise? current_user, grp, :manage_group, ->(role, perm_hash, other) { grp.specific_permission_hash(role, perm_hash, other) } + if grp.locked || gs.locked + error!({ error: 'This group is locked' }, 403) + else + error!({ error: 'Not authorised to manage this group' }, 403) + end + end + + unless authorise? current_user, prj, :get + error!({ error: 'Not authorised to manage this student' }, 403) + end + + if grp.active_group_members.find_by(project: prj).nil? + error!({ error: "#{prj.student.name} is not a member of this group" }, 403) + end + + grp.remove_member(prj) + true + end +end diff --git a/app/api/learning_alignment.rb b/app/api/learning_alignment.rb deleted file mode 100644 index 3b31bc7ed..000000000 --- a/app/api/learning_alignment.rb +++ /dev/null @@ -1,236 +0,0 @@ -require 'grape' - -module Api - class LearningAlignment < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers MimeCheckHelpers - - before do - authenticated? - end - - desc 'Get the task/outcome alignment details for a unit or a project' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - optional :project_id, type: Integer, desc: 'The id of the student project to get the alignment from' - end - get '/units/:unit_id/learning_alignments' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access this unit.' }, 403) - end - - if params[:project_id].nil? - return unit.task_outcome_alignments - else - proj = unit.projects.find(params[:project_id]) - unless authorise?(current_user, proj, :get) - error!({ error: 'You are not authorised to access this project.' }, 403) - end - return proj.task_outcome_alignments - end - end - - desc 'Download CSV of task alignments in this unit' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - optional :project_id, type: Integer, desc: 'The id of the student project to get the alignment from' - end - get '/units/:unit_id/learning_alignments/csv' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access this unit.' }, 403) - end - - if params[:project_id].nil? - unless authorise? current_user, unit, :download_unit_csv - error!({ error: "Not authorised to download CSV of task alignment in #{unit.code}" }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-Alignment.csv " - env['api.format'] = :binary - unit.export_task_alignment_to_csv - else - proj = unit.projects.find(params[:project_id]) - unless authorise?(current_user, proj, :get) - error!({ error: 'You are not authorised to access this project.' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{proj.student.name}-Task-Alignment.csv " - env['api.format'] = :binary - - proj.export_task_alignment_to_csv - end - end - - desc 'Upload CSV of task to outcome alignments' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - optional :project_id, type: Integer, desc: 'The id of the student project to upload the alignment to' - end - post '/units/:unit_id/learning_alignments/csv' do - ensure_csv!(params[:file][:tempfile]) - - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access this unit.' }, 403) - end - - if params[:project_id].nil? - unless authorise? current_user, unit, :upload_csv - error!({ error: "Not authorised to upload CSV of task alignment to #{unit.code}" }, 403) - end - - # Actually import... - unit.import_task_alignment_from_csv(params[:file][:tempfile], nil) - else - proj = unit.projects.find(params[:project_id]) - unless authorise?(current_user, proj, :make_submission) - error!({ error: 'You are not authorised to access this project.' }, 403) - end - - unit.import_task_alignment_from_csv(params[:file][:tempfile], proj) - end - end - - desc "Add an outcome to a unit's task definition" - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - requires :learning_outcome_id, type: Integer, desc: 'The id of the learning outcome' - requires :task_definition_id, type: Integer, desc: 'The id of the task definition' - optional :project_id, type: Integer, desc: 'The id of the project if this is a self reflection' - requires :description, type: String, desc: 'The ILO''s description' - requires :rating, type: Integer, desc: 'The rating for this link, indicating the strength of this alignment' - end - post '/units/:unit_id/learning_alignments' do - unit = Unit.find(params[:unit_id]) - - # if there is no project -- then this is a unit LO link - # so need to check the user is authorised to update the unit... - if params[:project_id].nil? && !authorise?(current_user, unit, :update) - error!({ error: 'You are not authorised to create task alignments in this unit.' }, 403) - end - - unit.learning_outcomes.find(params[:learning_outcome_id]) - - task_def = unit.task_definitions.find(params[:task_definition_id]) - - link_parameters = ActionController::Parameters.new(params) - .permit( - :task_definition_id, - :learning_outcome_id, - :description, - :rating - ) - - unless params[:project_id].nil? - project = unit.projects.find(params[:project_id]) - task = project.task_for_task_definition(task_def) - - unless authorise?(current_user, task, :make_submission) - error!({ error: 'You are not authorised to create outcome alignments for this task.' }, 403) - end - - link_parameters[:task_id] = task.id - end - - LearningOutcomeTaskLink.create! link_parameters - end - - desc 'Update the alignment between a task and unit outcome' - params do - requires :id, type: Integer, desc: 'The id of the task alignment' - requires :unit_id, type: Integer, desc: 'The id of the unit' - optional :description, type: String, desc: 'The description of the alignment' - optional :rating, type: Integer, desc: 'The rating for this link, indicating the strength of this alignment' - end - put '/units/:unit_id/learning_alignments/:id' do - unit = Unit.find(params[:unit_id]) - - align = unit.learning_outcome_task_links.find(params[:id]) - - link_parameters = ActionController::Parameters.new(params) - .permit( - :description, - :rating - ) - - # is this a project task alignment update? - if !align.task_id.nil? - task = align.task - - unless authorise?(current_user, task, :make_submission) - error!({ error: 'You are not authorised to update outcome alignments for this task.' }, 403) - end - - # else, this is a unit alignment update! - elsif !authorise?(current_user, unit, :update) - error!({ error: 'You are not authorised to update the task alignments in this unit.' }, 403) - end - - align.update(link_parameters) - align.save! - end - - desc 'Delete the alignment between a task and unit outcome' - params do - requires :id, type: Integer, desc: 'The id of the task alignment' - requires :unit_id, type: Integer, desc: 'The id of the unit' - end - delete '/units/:unit_id/learning_alignments/:id' do - unit = Unit.find(params[:unit_id]) - - align = unit.learning_outcome_task_links.find(params[:id]) - - # is this a project task alignment update? - if !align.task_id.nil? - task = align.task - - unless authorise?(current_user, task, :make_submission) - error!({ error: 'You are not authorised to update outcome alignments for this task.' }, 403) - end - - # else, this is a unit alignment update! - elsif !authorise?(current_user, unit, :update) - error!({ error: 'You are not authorised to update the task alignments in this unit.' }, 403) - end - - align.destroy! - nil - end - - desc 'Return unit learning alignment median values' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - end - get '/units/:unit_id/learning_alignments/class_stats' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access these task alignments.' }, 403) - end - - unit.ilo_progress_class_stats - end - - desc 'Return unit learning alignment values with median stats for each tutorial' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - end - get '/units/:unit_id/learning_alignments/class_details' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :provide_feedback) - error!({ error: 'You are not authorised to access these task alignments.' }, 403) - end - - unit.ilo_progress_class_details - end - end -end diff --git a/app/api/learning_alignment_api.rb b/app/api/learning_alignment_api.rb new file mode 100644 index 000000000..2ea49366c --- /dev/null +++ b/app/api/learning_alignment_api.rb @@ -0,0 +1,238 @@ +require 'grape' + +class LearningAlignmentApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers MimeCheckHelpers + + before do + authenticated? + end + + desc 'Get the task/outcome alignment details for a unit or a project' + params do + requires :unit_id, type: Integer, desc: 'The id of the unit' + optional :project_id, type: Integer, desc: 'The id of the student project to get the alignment from' + end + get '/units/:unit_id/learning_alignments' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :get_unit) + error!({ error: 'You are not authorised to access this unit.' }, 403) + end + + if params[:project_id].nil? + present unit.task_outcome_alignments, with: Entities::TaskOutcomeAlignmentEntity + else + proj = unit.projects.find(params[:project_id]) + unless authorise?(current_user, proj, :get) + error!({ error: 'You are not authorised to access this project.' }, 403) + end + present proj.task_outcome_alignments, with: Entities::TaskOutcomeAlignmentEntity + end + end + + desc 'Download CSV of task alignments in this unit' + params do + requires :unit_id, type: Integer, desc: 'The id of the unit' + optional :project_id, type: Integer, desc: 'The id of the student project to get the alignment from' + end + get '/units/:unit_id/learning_alignments/csv' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :get_unit) + error!({ error: 'You are not authorised to access this unit.' }, 403) + end + + if params[:project_id].nil? + unless authorise? current_user, unit, :download_unit_csv + error!({ error: "Not authorised to download CSV of task alignment in #{unit.code}" }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-Alignment.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.export_task_alignment_to_csv + else + proj = unit.projects.find(params[:project_id]) + unless authorise?(current_user, proj, :get) + error!({ error: 'You are not authorised to access this project.' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{proj.student.name}-Task-Alignment.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + + proj.export_task_alignment_to_csv + end + end + + desc 'Upload CSV of task to outcome alignments' + params do + requires :file, type: File, desc: 'CSV upload file.' + optional :project_id, type: Integer, desc: 'The id of the student project to upload the alignment to' + end + post '/units/:unit_id/learning_alignments/csv' do + ensure_csv!(params[:file][:tempfile]) + + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :get_unit) + error!({ error: 'You are not authorised to access this unit.' }, 403) + end + + if params[:project_id].nil? + unless authorise? current_user, unit, :upload_csv + error!({ error: "Not authorised to upload CSV of task alignment to #{unit.code}" }, 403) + end + + # Actually import... + unit.import_task_alignment_from_csv(params[:file][:tempfile], nil) + else + proj = unit.projects.find(params[:project_id]) + unless authorise?(current_user, proj, :make_submission) + error!({ error: 'You are not authorised to access this project.' }, 403) + end + + unit.import_task_alignment_from_csv(params[:file][:tempfile], proj) + end + end + + desc "Add an outcome to a unit's task definition" + params do + requires :unit_id, type: Integer, desc: 'The id of the unit' + requires :learning_outcome_id, type: Integer, desc: 'The id of the learning outcome' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition' + optional :project_id, type: Integer, desc: 'The id of the project if this is a self reflection' + requires :description, type: String, desc: 'The ILO''s description' + requires :rating, type: Integer, desc: 'The rating for this link, indicating the strength of this alignment' + end + post '/units/:unit_id/learning_alignments' do + unit = Unit.find(params[:unit_id]) + + # if there is no project -- then this is a unit LO link + # so need to check the user is authorised to update the unit... + if params[:project_id].nil? && !authorise?(current_user, unit, :update) + error!({ error: 'You are not authorised to create task alignments in this unit.' }, 403) + end + + unit.learning_outcomes.find(params[:learning_outcome_id]) + + task_def = unit.task_definitions.find(params[:task_definition_id]) + + link_parameters = ActionController::Parameters.new(params) + .permit( + :task_definition_id, + :learning_outcome_id, + :description, + :rating + ) + + unless params[:project_id].nil? + project = unit.projects.find(params[:project_id]) + task = project.task_for_task_definition(task_def) + + unless authorise?(current_user, task, :make_submission) + error!({ error: 'You are not authorised to create outcome alignments for this task.' }, 403) + end + + link_parameters[:task_id] = task.id + end + + result = LearningOutcomeTaskLink.create! link_parameters + present result, with: Entities::TaskOutcomeAlignmentEntity + end + + desc 'Update the alignment between a task and unit outcome' + params do + requires :id, type: Integer, desc: 'The id of the task alignment' + requires :unit_id, type: Integer, desc: 'The id of the unit' + optional :description, type: String, desc: 'The description of the alignment' + optional :rating, type: Integer, desc: 'The rating for this link, indicating the strength of this alignment' + end + put '/units/:unit_id/learning_alignments/:id' do + unit = Unit.find(params[:unit_id]) + + align = unit.learning_outcome_task_links.find(params[:id]) + + link_parameters = ActionController::Parameters.new(params) + .permit( + :description, + :rating + ) + + # is this a project task alignment update? + if !align.task_id.nil? + task = align.task + + unless authorise?(current_user, task, :make_submission) + error!({ error: 'You are not authorised to update outcome alignments for this task.' }, 403) + end + + # else, this is a unit alignment update! + elsif !authorise?(current_user, unit, :update) + error!({ error: 'You are not authorised to update the task alignments in this unit.' }, 403) + end + + align.update(link_parameters) + align.save! + present align, with: Entities::TaskOutcomeAlignmentEntity + end + + desc 'Delete the alignment between a task and unit outcome' + params do + requires :id, type: Integer, desc: 'The id of the task alignment' + requires :unit_id, type: Integer, desc: 'The id of the unit' + end + delete '/units/:unit_id/learning_alignments/:id' do + unit = Unit.find(params[:unit_id]) + + align = unit.learning_outcome_task_links.find(params[:id]) + + # is this a project task alignment update? + if !align.task_id.nil? + task = align.task + + unless authorise?(current_user, task, :make_submission) + error!({ error: 'You are not authorised to update outcome alignments for this task.' }, 403) + end + + # else, this is a unit alignment update! + elsif !authorise?(current_user, unit, :update) + error!({ error: 'You are not authorised to update the task alignments in this unit.' }, 403) + end + + align.destroy! + nil + end + + desc 'Return unit learning alignment median values' + params do + requires :unit_id, type: Integer, desc: 'The id of the unit' + end + get '/units/:unit_id/learning_alignments/class_stats' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :get_unit) + error!({ error: 'You are not authorised to access these task alignments.' }, 403) + end + + present unit.ilo_progress_class_stats + end + + desc 'Return unit learning alignment values with median stats for each tutorial' + params do + requires :unit_id, type: Integer, desc: 'The id of the unit' + end + get '/units/:unit_id/learning_alignments/class_details' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :provide_feedback) + error!({ error: 'You are not authorised to access these task alignments.' }, 403) + end + + present unit.ilo_progress_class_details + end +end diff --git a/app/api/learning_outcomes.rb b/app/api/learning_outcomes.rb deleted file mode 100644 index d41f829af..000000000 --- a/app/api/learning_outcomes.rb +++ /dev/null @@ -1,115 +0,0 @@ -require 'grape' - -module Api - class LearningOutcomes < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers MimeCheckHelpers - - before do - authenticated? - end - - desc 'Add an outcome to a unit' - params do - requires :unit_id, type: Integer, desc: 'The unit ID for which the ILO belongs to' - requires :name, type: String, desc: 'The ILO''s name' - requires :description, type: String, desc: 'The ILO''s description' - optional :abbreviation, type: String, desc: 'The ILO''s new abbreviation' - end - post '/units/:unit_id/outcomes' do - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to create outcomes in this unit.' }, 403) - end - - ilo = unit.add_ilo(params[:name], params[:description], params[:abbreviation]) - ilo - end - - desc 'Update ILO' - params do - requires :unit_id, type: Integer, desc: 'The unit ID for which the ILO belongs to' - optional :name, type: String, desc: 'The ILO''s new name' - optional :description, type: String, desc: 'The ILO''s new description' - optional :abbreviation, type: String, desc: 'The ILO''s new abbreviation' - optional :ilo_number, type: Integer, desc: 'The ILO''s new sequence number' - end - put '/units/:unit_id/outcomes/:id' do - unit = Unit.find(params[:unit_id]) - error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? - - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to update outcomes in this unit.' }, 403) - end - - ilo = unit.learning_outcomes.find(params[:id]) - error!({ error: 'Unable to locate outcome requested.' }, 405) if ilo.nil? - - ilo_parameters = ActionController::Parameters.new(params) - .permit( - :name, - :description, - :abbreviation - ) - unit.move_ilo(ilo, params[:ilo_number]) if params[:ilo_number] - ilo.update!(ilo_parameters) - ilo - end - - desc 'Delete an outcome from a unit' - params do - requires :unit_id, type: Integer, desc: 'The id for the unit' - requires :id, type: Integer, desc: 'The id for the outcome you wish to delete' - end - delete '/units/:unit_id/outcomes/:id' do - unit = Unit.find(params[:unit_id]) - error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? - - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to delete outcomes in this unit.' }, 403) - end - - ilo = unit.learning_outcomes.find(params[:id]) - error!({ error: 'Unable to locate outcome requested.' }, 405) if ilo.nil? - - ilo.destroy - nil - end - - desc 'Download the outcomes for a unit to a csv' - get '/units/:unit_id/outcomes/csv' do - unit = Unit.find(params[:unit_id]) - error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? - - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to download outcomes for this unit.' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-LearningOutcomes.csv " - env['api.format'] = :binary - unit.export_learning_outcome_to_csv - end - - desc 'Upload the outcomes for a unit from a csv' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - requires :unit_id, type: Integer, desc: 'The unit to upload tasks to' - end - post '/units/:unit_id/outcomes/csv' do - # check mime is correct before uploading - ensure_csv!(params[:file][:tempfile]) - - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :upload_csv - error!({ error: 'Not authorised to upload CSV of outcomes' }, 403) - end - - # Actually import... - unit.import_outcomes_from_csv(params[:file][:tempfile]) - end - end -end diff --git a/app/api/learning_outcomes_api.rb b/app/api/learning_outcomes_api.rb new file mode 100644 index 000000000..f8dd179bc --- /dev/null +++ b/app/api/learning_outcomes_api.rb @@ -0,0 +1,114 @@ +require 'grape' + +class LearningOutcomesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers MimeCheckHelpers + + before do + authenticated? + end + + desc 'Add an outcome to a unit' + params do + requires :unit_id, type: Integer, desc: 'The unit ID for which the ILO belongs to' + requires :name, type: String, desc: 'The ILO''s name' + requires :description, type: String, desc: 'The ILO''s description' + optional :abbreviation, type: String, desc: 'The ILO''s new abbreviation' + end + post '/units/:unit_id/outcomes' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'You are not authorised to create outcomes in this unit.' }, 403) + end + + ilo = unit.add_ilo(params[:name], params[:description], params[:abbreviation]) + present ilo, with: Entities::LearningOutcomeEntity + end + + desc 'Update ILO' + params do + requires :unit_id, type: Integer, desc: 'The unit ID for which the ILO belongs to' + optional :name, type: String, desc: 'The ILO''s new name' + optional :description, type: String, desc: 'The ILO''s new description' + optional :abbreviation, type: String, desc: 'The ILO''s new abbreviation' + optional :ilo_number, type: Integer, desc: 'The ILO''s new sequence number' + end + put '/units/:unit_id/outcomes/:id' do + unit = Unit.find(params[:unit_id]) + error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? + + unless authorise? current_user, unit, :update + error!({ error: 'You are not authorised to update outcomes in this unit.' }, 403) + end + + ilo = unit.learning_outcomes.find(params[:id]) + error!({ error: 'Unable to locate outcome requested.' }, 405) if ilo.nil? + + ilo_parameters = ActionController::Parameters.new(params) + .permit( + :name, + :description, + :abbreviation + ) + unit.move_ilo(ilo, params[:ilo_number]) if params[:ilo_number] + ilo.update!(ilo_parameters) + present ilo, with: Entities::LearningOutcomeEntity + end + + desc 'Delete an outcome from a unit' + params do + requires :unit_id, type: Integer, desc: 'The id for the unit' + requires :id, type: Integer, desc: 'The id for the outcome you wish to delete' + end + delete '/units/:unit_id/outcomes/:id' do + unit = Unit.find(params[:unit_id]) + error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? + + unless authorise? current_user, unit, :update + error!({ error: 'You are not authorised to delete outcomes in this unit.' }, 403) + end + + ilo = unit.learning_outcomes.find(params[:id]) + error!({ error: 'Unable to locate outcome requested.' }, 405) if ilo.nil? + + ilo.destroy + nil + end + + desc 'Download the outcomes for a unit to a csv' + get '/units/:unit_id/outcomes/csv' do + unit = Unit.find(params[:unit_id]) + error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? + + unless authorise? current_user, unit, :update + error!({ error: 'You are not authorised to download outcomes for this unit.' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-LearningOutcomes.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.export_learning_outcome_to_csv + end + + desc 'Upload the outcomes for a unit from a csv' + params do + requires :file, type: File, desc: 'CSV upload file.' + requires :unit_id, type: Integer, desc: 'The unit to upload tasks to' + end + post '/units/:unit_id/outcomes/csv' do + # check mime is correct before uploading + ensure_csv!(params[:file][:tempfile]) + + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :upload_csv + error!({ error: 'Not authorised to upload CSV of outcomes' }, 403) + end + + # Actually import... + unit.import_outcomes_from_csv(params[:file][:tempfile]) + end +end diff --git a/app/api/projects.rb b/app/api/projects.rb deleted file mode 100644 index 8fcd8a73b..000000000 --- a/app/api/projects.rb +++ /dev/null @@ -1,182 +0,0 @@ -require 'grape' -require 'project_serializer' - -module Api - class Projects < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers DbHelpers - - before do - authenticated? - end - - desc "Fetches all of the current user's projects" - params do - optional :include_inactive, type: Boolean, desc: 'Include projects for units that are no longer active?' - end - - get '/projects' do - include_inactive = params[:include_inactive] || false - - projects = Project.for_user current_user, include_inactive - - student_name = db_concat('users.first_name', "' '", 'users.last_name') - tutor_name = db_concat('tutor.first_name', "' '", 'tutor.last_name') - - # join in other tables to fetch data - projects = projects - .joins(:unit) - .joins(:user) - .joins('LEFT OUTER JOIN tutorials ON projects.tutorial_id = tutorials.id') - .joins('LEFT OUTER JOIN unit_roles AS tutor_role ON tutorials.unit_role_id = tutor_role.id') - .joins('LEFT OUTER JOIN users AS tutor ON tutor.id = tutor_role.user_id') - .select('projects.*', 'units.name AS unit_name', 'units.id AS unit_id', 'units.code AS unit_code', 'units.start_date AS start_date', "#{student_name} AS student_name", "#{tutor_name} AS tutor_name") - - ActiveModel::ArraySerializer.new(projects, each_serializer: ShallowProjectSerializer) - end - - desc 'Get project' - params do - requires :id, type: Integer, desc: 'The id of the project to get' - end - get '/projects/:id' do - project = Project.find(params[:id]) - - if authorise? current_user, project, :get - project - else - error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) - end - - Thread.current[:user] = current_user - project - end - - desc 'Update a project' - params do - optional :trigger, type: String, desc: 'The update trigger' - optional :tutorial_id, type: Integer, desc: 'Switch tutorial' - optional :enrolled, type: Boolean, desc: 'Enrol or withdraw this project' - optional :target_grade, type: Integer, desc: 'New target grade' - optional :compile_portfolio, type: Boolean, desc: 'Schedule a construction of the portfolio' - optional :grade, type: Integer, desc: 'New grade' - optional :old_grade, type: Integer, desc: 'Old grade to check it has not changed...' - optional :grade_rationale, type: String, desc: 'New grade rationale' - end - put '/projects/:id' do - project = Project.find(params[:id]) - - if params[:trigger].nil? == false - if params[:trigger] == 'trigger_week_end' - if authorise? current_user, project, :trigger_week_end - project.trigger_week_end(current_user) - else - error!({ error: "You are not authorised to perform this action for Project with id=#{params[:id]}" }, 403) - end - else - error!({ error: "Invalid trigger - #{params[:trigger]} unknown" }, 403) - end - elsif !params[:tutorial_id].nil? - unless authorise? current_user, project, :change_tutorial - error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) - end - - tutorial_id = params[:tutorial_id] - if project.unit.tutorials.where('tutorials.id = :tutorial_id', tutorial_id: tutorial_id).count == 1 - project.tutorial_id = tutorial_id - project.save! - elsif tutorial_id == -1 - project.tutorial = nil - project.save! - else - error!({ error: "Couldn't find Tutorial with id=#{params[:tutorial_id]}" }, 403) - end - elsif !params[:enrolled].nil? - unless authorise? current_user, project.unit, :change_project_enrolment - error!({ error: "You cannot change the enrolment for project #{params[:id]}" }, 403) - end - project.enrolled = params[:enrolled] - project.save - elsif !params[:target_grade].nil? - unless authorise? current_user, project, :change - error!({ error: "You do not have permissions to change Project with id=#{params[:id]}" }, 403) - end - - project.target_grade = params[:target_grade] - project.save - elsif !params[:grade].nil? - unless authorise? current_user, project, :assess - error!({ error: "You do not have permissions to assess Project with id=#{params[:id]}" }, 403) - end - - if params[:grade_rationale].nil? - error!({ error: 'Grade rationale required to perform assessment.' }, 403) - end - - if params[:old_grade].nil? - error!({ error: 'Existing project grade is required to perform assessment.' }, 403) - end - - if params[:old_grade] != project.grade - error!({ error: 'Existing project grade does not match current grade. Refresh project and try again.' }, 403) - end - - project.grade = params[:grade] - project.grade_rationale = params[:grade_rationale] - project.save! - elsif !params[:compile_portfolio].nil? - unless authorise? current_user, project, :change - error!({ error: "You do not have permissions to change Project with id=#{params[:id]}" }, 403) - end - - project.compile_portfolio = params[:compile_portfolio] - project.save - end - - Thread.current[:user] = current_user - project - end # put - - desc 'Enrol a student in a unit, creating them a project' - params do - requires :unit_id, type: Integer, desc: 'Unit Id' - requires :student_num, type: String, desc: 'Student Number 7 digit code' - optional :tutorial_id, type: Integer, desc: 'Tutorial Id' - end - post '/projects' do - unit = Unit.find(params[:unit_id]) - student = User.find_by(username: params[:student_num]) - - if student.nil? - error!({ error: "Couldn't find Student with username=#{params[:student_num]}" }, 403) - end - - if authorise? current_user, unit, :enrol_student - proj = unit.enrol_student(student, params[:tutorial_id]) - if proj.nil? - error!({ error: 'Error adding student to unit' }, 403) - else - { - project_id: proj.id, - enrolled: proj.enrolled, - first_name: proj.student.first_name, - last_name: proj.student.last_name, - student_id: proj.student.username, - student_email: proj.student.email, - target_grade: proj.target_grade, - tutorial_id: proj.tutorial_id, - compile_portfolio: false, - grade: proj.grade, - grade_rationale: proj.grade_rationale, - max_pct_copy: 0, - has_portfolio: false, - stats: '0|1|0|0|0|0|0|0|0|0|0' - } - end - else - error!({ error: "Couldn't find Unit with id=#{params[:unit_id]}" }, 403) - end - end - end -end diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb new file mode 100644 index 000000000..c24bee9fc --- /dev/null +++ b/app/api/projects_api.rb @@ -0,0 +1,203 @@ +require 'grape' + +class ProjectsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers DbHelpers + + before do + authenticated? + end + + desc "Fetches all of the current user's projects" + params do + optional :include_inactive, type: Boolean, desc: 'Include projects for units that are no longer active?' + end + get '/projects' do + include_inactive = params[:include_inactive] || false + + projects = Project.for_user current_user, include_inactive + + student_name = db_concat('users.first_name', "' '", 'users.last_name') + + # join in other tables to fetch data + data = projects. + joins(:unit). + joins(:user). + select( 'projects.*', + 'units.name AS unit_name', 'units.id AS unit_id', 'units.code AS unit_code', 'units.start_date AS start_date', 'units.end_date AS end_date', 'units.teaching_period_id AS teaching_period_id', 'units.active AS active', + "#{student_name} AS student_name" + ) + + # Now map the data to structure for json to return + result = data.map do |row| + { + unit_id: row['unit_id'], + unit_code: row['unit_code'], + unit_name: row['unit_name'], + project_id: row['id'], + campus_id: row['campus_id'], + target_grade: row['target_grade'], + has_portfolio: !row['portfolio_production_date'].nil?, + start_date: row['start_date'].strftime('%Y-%m-%d'), + end_date: row['end_date'].strftime('%Y-%m-%d'), + teaching_period_id: row['teaching_period_id'], + active: row['active'].is_a?(Numeric) ? row['active'] != 0 : row['active'] + } + end + + present result, with: Grape::Presenters::Presenter + end + + desc 'Get project' + params do + requires :id, type: Integer, desc: 'The id of the project to get' + end + get '/projects/:id' do + project = Project.find(params[:id]) + + if authorise? current_user, project, :get + project + else + error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) + end + + present project, with: Entities::ProjectEntity, user: current_user + end + + desc 'Update a project' + params do + optional :trigger, type: String, desc: 'The update trigger' + optional :campus_id, type: Integer, desc: 'Campus this project is part of, or -1 for no campus' + optional :enrolled, type: Boolean, desc: 'Enrol or withdraw this project' + optional :target_grade, type: Integer, desc: 'New target grade' + optional :submitted_grade, type: Integer, desc: 'New submitted grade' + optional :compile_portfolio, type: Boolean, desc: 'Schedule a construction of the portfolio' + optional :grade, type: Integer, desc: 'New grade' + optional :old_grade, type: Integer, desc: 'Old grade to check it has not changed...' + optional :grade_rationale, type: String, desc: 'New grade rationale' + end + put '/projects/:id' do + project = Project.find(params[:id]) + + if params[:trigger].nil? == false + if params[:trigger] == 'trigger_week_end' + if authorise? current_user, project, :trigger_week_end + project.trigger_week_end(current_user) + else + error!({ error: "You are not authorised to perform this action for Project with id=#{params[:id]}" }, 403) + end + else + error!({ error: "Invalid trigger - #{params[:trigger]} unknown" }, 403) + end + # If we are only updating the campus + elsif params[:campus_id].present? + unless authorise? current_user, project, :change_campus + error!({ error: "You cannot change the campus for project #{params[:id]}" }, 403) + end + project.campus_id = params[:campus_id] == -1 ? nil : params[:campus_id] + project.save! + elsif !params[:enrolled].nil? + unless authorise? current_user, project.unit, :change_project_enrolment + error!({ error: "You cannot change the enrolment for project #{params[:id]}" }, 403) + end + project.enrolled = params[:enrolled] + project.save + elsif !params[:target_grade].nil? + unless authorise? current_user, project, :change + error!({ error: "You do not have permissions to change Project with id=#{params[:id]}" }, 403) + end + + project.target_grade = params[:target_grade] + project.save + elsif !params[:submitted_grade].nil? + unless authorise? current_user, project, :change + error!({ error: "You do not have permissions to change Project with id=#{params[:id]}" }, 403) + end + if project.has_portfolio + error!({ error: "You cannot change your submitted grade after portfolio submission"}, 403) + end + + project.submitted_grade = params[:submitted_grade] + project.save + elsif !params[:grade].nil? + unless authorise? current_user, project, :assess + error!({ error: "You do not have permissions to assess Project with id=#{params[:id]}" }, 403) + end + + if params[:grade_rationale].nil? + error!({ error: 'Grade rationale required to perform assessment.' }, 403) + end + + if params[:old_grade].nil? + error!({ error: 'Existing project grade is required to perform assessment.' }, 403) + end + + if params[:old_grade] != project.grade + error!({ error: 'Existing project grade does not match current grade. Refresh project and try again.' }, 403) + end + + project.grade = params[:grade] + project.grade_rationale = params[:grade_rationale] + project.save! + + present project, Entities::ProjectEntity, for_staff: true + return + elsif !params[:compile_portfolio].nil? + unless authorise? current_user, project, :change + error!({ error: "You do not have permissions to change Project with id=#{params[:id]}" }, 403) + end + + project.compile_portfolio = params[:compile_portfolio] + project.save + end + + Entities::ProjectEntity.represent(project, only: [ :campus_id, :enrolled, :target_grade, :submitted_grade, :compile_portfolio, :portfolio_available, :uses_draft_learning_summary, :stats, :burndown_chart_data ]) + end # put + + desc 'Enrol a student in a unit, creating them a project' + params do + requires :unit_id, type: Integer, desc: 'Unit Id' + requires :student_num, type: String, desc: 'Student Number 7 digit code' + requires :campus_id, type: Integer, desc: 'Campus this project is part of' + end + post '/projects' do + unit = Unit.find(params[:unit_id]) + student = User.find_by(username: params[:student_num]) + student = User.find_by(student_id: params[:student_num]) if student.nil? + student = User.find_by(email: params[:student_num]) if student.nil? + + if student.nil? + error!({ error: "Couldn't find Student with username=#{params[:student_num]}" }, 403) + end + + campus = Campus.find(params[:campus_id]) + + if authorise? current_user, unit, :enrol_student + proj = unit.enrol_student(student, campus) + if proj.nil? + error!({ error: 'Error adding student to unit' }, 403) + else + result = { + project_id: proj.id, + enrolled: proj.enrolled, + first_name: proj.student.first_name, + last_name: proj.student.last_name, + student_id: proj.student.username, + student_email: proj.student.email, + target_grade: proj.target_grade, + campus_id: proj.campus_id, + compile_portfolio: false, + grade: proj.grade, + grade_rationale: proj.grade_rationale, + max_pct_copy: 0, + has_portfolio: false, + stats: Project::DEFAULT_TASK_STATS + } + present result, with: Grape::Presenters::Presenter + end + else + error!({ error: "Couldn't find Unit with id=#{params[:unit_id]}" }, 403) + end + end +end diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb new file mode 100644 index 000000000..2c5ff0a1b --- /dev/null +++ b/app/api/settings_api.rb @@ -0,0 +1,27 @@ +require 'grape' + +class SettingsApi < Grape::API + + # + # Returns the current auth method + # + desc 'Return configurable details for the Doubtfire front end' + get '/settings' do + response = { + externalName: Doubtfire::Application.config.institution[:product_name], + overseer_enabled: Doubtfire::Application.config.overseer_enabled + } + + present response, with: Grape::Presenters::Presenter + end + + desc 'Return privacy policy details' + get '/settings/privacy' do + response = { + privacy: Doubtfire::Application.config.institution[:privacy], + plagiarism: Doubtfire::Application.config.institution[:plagiarism] + } + + present response, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/students.rb b/app/api/students.rb deleted file mode 100644 index 4910de38d..000000000 --- a/app/api/students.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'grape' -require 'project_serializer' - -module Api - class Students < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - desc 'Get users' - params do - requires :unit_id, type: Integer, desc: 'The unit to get the students for' - optional :all, type: Boolean, desc: 'Show all students or just current students' - end - get '/students' do - unit = Unit.find(params[:unit_id]) - - if (authorise? current_user, unit, :get_students) || (authorise? current_user, User, :admin_units) - result = if params[:all].nil? || (!params[:all].nil? && !params[:all]) - unit.student_query(true) - else - unit.student_query(false) - end - else - error!({ error: "Couldn't find Unit with id=#{params[:unit_id]}" }, 403) - end - end - end -end diff --git a/app/api/students_api.rb b/app/api/students_api.rb new file mode 100644 index 000000000..1c85f84b3 --- /dev/null +++ b/app/api/students_api.rb @@ -0,0 +1,30 @@ +require 'grape' + +class StudentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get users' + params do + requires :unit_id, type: Integer, desc: 'The unit to get the students for' + optional :all, type: Boolean, desc: 'Show all students or just current students' + end + get '/students' do + unit = Unit.find(params[:unit_id]) + + if (authorise? current_user, unit, :get_students) || (authorise? current_user, User, :admin_units) + result = if params[:all].nil? || (!params[:all].nil? && !params[:all]) + unit.student_query(true) + else + unit.student_query(false) + end + present result, with: Grape::Presenters::Presenter + else + error!({ error: "Couldn't find Unit with id=#{params[:unit_id]}" }, 403) + end + end +end diff --git a/app/api/submission/batch_task.rb b/app/api/submission/batch_task.rb deleted file mode 100644 index 04f220fcc..000000000 --- a/app/api/submission/batch_task.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'grape' -require 'project_serializer' - -module Api - module Submission - class BatchTask < Grape::API - helpers GenerateHelpers - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - desc "Retrieve all submission documents ready to mark for the provided user's tutorials for the given unit id" - params do - requires :unit_id, type: Integer, desc: 'Unit ID to retrieve submissions for.' - optional :user_id, type: Integer, desc: 'User ID to retrieve submissions for (optional; will use current_user otherwise).' - end - get '/submission/assess/' do - user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) - unit = Unit.find(params[:unit_id]) - - unless authorise? user, unit, :provide_feedback - error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) - end - - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) - end - - # Array of tasks that need marking for the given unit id - tasks_to_download = UnitRole.tasks_to_review(user) - - output_zip = unit.generate_batch_task_zip(current_user, tasks_to_download) - - error!({ error: 'No files to download' }, 401) if output_zip.nil? - - # Set download headers... - content_type 'application/octet-stream' - download_id = "#{Time.new.strftime('%Y-%m-%d')}-#{unit.code}-#{current_user.username}" - header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" - env['api.format'] = :binary - - out = File.read(output_zip.path) - output_zip.unlink - out - end # get - - desc 'Upload submission documents for the given unit and user id' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'batch file upload' - requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' - optional :user_id, type: Integer, desc: 'User ID to upload marked submissions to (optional; will use current_user otherwise).' - end - post '/submission/assess/' do - user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) - unit = Unit.find(params[:unit_id]) - - unless authorise? user, unit, :provide_feedback - error!({ error: 'Not authorised to batch upload marks' }, 401) - end - - unit.upload_batch_task_zip_or_csv(current_user, params[:file]) - end # post - end - end -end diff --git a/app/api/submission/batch_task_api.rb b/app/api/submission/batch_task_api.rb new file mode 100644 index 000000000..e1f6642ea --- /dev/null +++ b/app/api/submission/batch_task_api.rb @@ -0,0 +1,66 @@ +require 'grape' + +module Submission + class BatchTaskApi < Grape::API + helpers GenerateHelpers + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc "Retrieve all submission documents ready to mark for the provided user's tutorials for the given unit id" + params do + requires :unit_id, type: Integer, desc: 'Unit ID to retrieve submissions for.' + optional :user_id, type: Integer, desc: 'User ID to retrieve submissions for (optional; will use current_user otherwise).' + end + get '/submission/assess/' do + user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + unit = Unit.find(params[:unit_id]) + + unless authorise? user, unit, :provide_feedback + error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) + end + + unless authorise? current_user, unit, :provide_feedback + error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) + end + + # Array of tasks that need marking for the given unit id + tasks_to_download = UnitRole.tasks_to_review(user) + + output_zip = unit.generate_batch_task_zip(current_user, tasks_to_download) + + error!({ error: 'No files to download' }, 401) if output_zip.nil? + + # Set download headers... + content_type 'application/octet-stream' + download_id = "#{Time.new.strftime('%Y-%m-%d')}-#{unit.code}-#{current_user.username}" + header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + + out = File.read(output_zip) + File.unlink(output_zip) + out + end # get + + desc 'Upload submission documents for the given unit and user id' + params do + requires :file, type: File, desc: 'batch file upload' + requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' + optional :user_id, type: Integer, desc: 'User ID to upload marked submissions to (optional; will use current_user otherwise).' + end + post '/submission/assess/' do + user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + unit = Unit.find(params[:unit_id]) + + unless authorise? user, unit, :provide_feedback + error!({ error: 'Not authorised to batch upload marks' }, 401) + end + + present unit.upload_batch_task_zip_or_csv(current_user, params[:file]), with: Grape::Presenters::Presenter + end # post + end +end diff --git a/app/api/submission/generate.rb b/app/api/submission/generate.rb deleted file mode 100644 index deca89184..000000000 --- a/app/api/submission/generate.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'grape' -require 'project_serializer' - -module Api - module Submission - class Generate < Grape::API - helpers GenerateHelpers - - desc 'Generate doubtfire-task-inspecific submission document' - params do - requires :upload_requirements, type: JSON, desc: "File details, eg: [ { key: 'file1', name: 'Shape Class', type: '[image/code/document]' }, ... ]" - requires :file0, type: Rack::Multipart::UploadedFile, desc: 'file 1.' - end - post '/submission/generate/' do - # Set download headers... - content_type 'application/octet-stream' - header['Content-Disposition'] = 'attachment; filename=output.pdf' - env['api.format'] = :binary - - file = combine_to_pdf(scoop_files(params, params[:upload_requirements])) - file.path - response = file.open.read - - # Remember to delete the file as we don't want to save it with this kind of inspecific request - file.unlink - - response - end - end - end -end diff --git a/app/api/submission/generate_helpers.rb b/app/api/submission/generate_helpers.rb index 665eefe68..d05adc4d0 100644 --- a/app/api/submission/generate_helpers.rb +++ b/app/api/submission/generate_helpers.rb @@ -1,7 +1,7 @@ # zipping files require 'zip' -module Api::Submission::GenerateHelpers +module Submission::GenerateHelpers # # Scoops out a files array from the params provided # @@ -15,13 +15,13 @@ def scoop_files(params, upload_reqs) upload_reqs.each do |detail| key = detail['key'] next unless files.key? key - files[key].id = files[key].name - files[key].name = detail['name'] - files[key].type = detail['type'] + files[key][:id] = files[key]['name'] + files[key][:name] = detail['name'] + files[key][:type] = detail['type'] end # File didn't get assigned an id above, then reject it since there was a mismatch - files = files.reject { |_key, file| file.id.nil? } + files = files.reject { |_key, file| file[:id].nil? } error!({ error: 'Upload requirements mismatch with files provided' }, 403) if files.length != upload_reqs.length # Kill the kvp diff --git a/app/api/submission/portfolio_api.rb b/app/api/submission/portfolio_api.rb index d1efa0610..e9acf0d4b 100644 --- a/app/api/submission/portfolio_api.rb +++ b/app/api/submission/portfolio_api.rb @@ -1,101 +1,102 @@ require 'grape' -require 'project_serializer' -module Api - module Submission - class PortfolioApi < Grape::API - helpers GenerateHelpers - helpers AuthenticationHelpers - helpers AuthorisationHelpers +module Submission + class PortfolioApi < Grape::API + helpers GenerateHelpers + helpers AuthenticationHelpers + helpers AuthorisationHelpers - before do - authenticated? + before do + authenticated? + end + + desc "Upload documents for inclusion in a project's portfolio" + params do + requires :name, type: String, desc: 'Name of the part being uploaded' + requires :kind, type: String, desc: 'The kind of file being uploaded: document, code, or image' + requires :file0, type: File, desc: 'file 0.' + end + post '/submission/project/:id/portfolio' do + project = Project.find(params[:id]) + + unless authorise? current_user, project, :make_submission + error!({ error: "Not authorised to submit portfolio for project '#{params[:id]}'" }, 401) end - desc "Upload documents for inclusion in a project's portfolio" - params do - requires :name, type: String, desc: 'Name of the part being uploaded' - requires :kind, type: String, desc: 'The kind of file being uploaded: document, code, or image' - requires :file0, type: Rack::Multipart::UploadedFile, desc: 'file 0.' + file = params[:file0] + name = params[:name] + kind = params[:kind] + + # Check that the file is OK to accept + unless FileHelper.accept_file(file, name, kind) + error!({ error: "'#{file[:filename]}' is not a valid #{kind} file" }, 403) end - post '/submission/project/:id/portfolio' do - project = Project.find(params[:id]) - unless authorise? current_user, project, :make_submission - error!({ error: "Not authorised to submit portfolio for project '#{params[:id]}'" }, 401) - end + # Move file into place + result = project.move_to_portfolio(file, name, kind) # returns details of file + + present result, Grape::Presenters::Presenter + end # post + + desc 'Remove a file from the portfolio files for a unit' + params do + optional :idx, type: Integer, desc: 'The index of the file' + optional :kind, type: String, desc: 'The kind of file being removed: document, code, or image' + optional :name, type: String, desc: 'Name of file to remove' + end + delete '/submission/project/:id/portfolio' do + project = Project.find(params[:id]) + + unless authorise? current_user, project, :make_submission + error!({ error: "Not authorised to alter portfolio for project '#{params[:id]}'" }, 401) + end - file = params[:file0] + # Remove file or portfolio? + if params[:idx].nil? && params[:name].nil? && params[:kind].nil? + project.remove_portfolio # returns details of file + elsif !(params[:idx].nil? || params[:name].nil? || params[:kind].nil?) + idx = params[:idx] name = params[:name] kind = params[:kind] - # Check that the file is OK to accept - unless FileHelper.accept_file(file, name, kind) - error!({ error: "'#{file.filename}' is not a valid #{kind} file" }, 403) - end + project.remove_portfolio_file(idx, kind, name) # returns details of file + end - # Move file into place - project.move_to_portfolio(file, name, kind) # returns details of file - end # post + nil + end - desc 'Remove a file from the portfolio files for a unit' - params do - optional :idx, type: Integer, desc: 'The index of the file' - optional :kind, type: String, desc: 'The kind of file being removed: document, code, or image' - optional :name, type: String, desc: 'Name of file to remove' - end - delete '/submission/project/:id/portfolio' do - project = Project.find(params[:id]) - - unless authorise? current_user, project, :make_submission - error!({ error: "Not authorised to alter portfolio for project '#{params[:id]}'" }, 401) - end - - # Remove file or portfolio? - if params[:idx].nil? && params[:name].nil? && params[:kind].nil? - project.remove_portfolio # returns details of file - elsif !(params[:idx].nil? || params[:name].nil? || params[:kind].nil?) - idx = params[:idx] - name = params[:name] - kind = params[:kind] - - project.remove_portfolio_file(idx, kind, name) # returns details of file - end - nil - end + desc 'Retrieve portfolio for project with the given id' + params do + optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + end + get '/submission/project/:id/portfolio' do + project = Project.find(params[:id]) - desc 'Retrieve portfolio for project with the given id' - params do - optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to download portfolio for project '#{params[:id]}'" }, 401) end - get '/submission/project/:id/portfolio' do - project = Project.find(params[:id]) - unless authorise? current_user, project, :get_submission - error!({ error: "Not authorised to download portfolio for project '#{params[:id]}'" }, 401) - end + evidence_loc = project.portfolio_path - evidence_loc = project.portfolio_path - - if evidence_loc.nil? || File.exist?(evidence_loc) == false - evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename = "FileNotFound.pdf" - else - filename = "#{project.unit.code}-#{project.student.username}-portfolio.pdf" - end + if evidence_loc.nil? || File.exist?(evidence_loc) == false + evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + filename = "FileNotFound.pdf" + else + filename = "#{project.unit.code}-#{project.student.username}-portfolio.pdf" + end - if params[:as_attachment] - header['Content-Disposition'] = "attachment; filename=#{filename}" - end + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{filename}" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + end - # Set download headers... - content_type 'application/pdf' - env['api.format'] = :binary + # Set download headers... + content_type 'application/pdf' + env['api.format'] = :binary - File.read(evidence_loc) - end # get + File.read(evidence_loc) + end # get - # "Retrieve portfolios for a unit" done using controller - end + # "Retrieve portfolios for a unit" done using controller end end diff --git a/app/api/submission/portfolio_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb index 494a4a549..f2faf1a4a 100644 --- a/app/api/submission/portfolio_evidence_api.rb +++ b/app/api/submission/portfolio_evidence_api.rb @@ -1,115 +1,292 @@ require 'grape' -require 'project_serializer' - -module Api - module Submission - class PortfolioEvidenceApi < Grape::API - helpers GenerateHelpers - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - desc 'Upload and generate doubtfire-task-specific submission document' - params do - requires :file0, type: Rack::Multipart::UploadedFile, desc: 'file 0.' - optional :file1, type: Rack::Multipart::UploadedFile, desc: 'file 1.' - optional :contributions, type: JSON, desc: "Contribution details JSON, eg: [ { project_id: 1, pct:'0.44', pts: 4 }, ... ]" - optional :alignment_data, type: JSON, desc: "Data for task alignment, eg: [ { ilo_id: 1, rating: 5, rationale: 'Hello' }, ... ]" - optional :trigger, type: String, desc: 'Can be need_help to indicate upload is not a ready to mark submission' - end - post '/projects/:id/task_def_id/:task_definition_id/submission' do - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - # check the user can put this task - unless authorise? current_user, project, :make_submission - error!({ error: "Not authorised to submit task '#{task_definition.name}'" }, 401) - end - task = project.task_for_task_definition(task_definition) +module Submission + class PortfolioEvidenceApi < Grape::API + helpers GenerateHelpers + helpers AuthenticationHelpers + helpers AuthorisationHelpers + include LogHelper - if task.group_task? && !task.group - error!({ error: "This task requires a group submission. Ensure you are in a group for the unit's #{task_definition.group_set.name}" }, 403) - end + def self.logger + LogHelper.logger + end - trigger = if params[:trigger] && params[:trigger].tr('"\'', '') == 'need_help' - 'need_help' - else - 'ready_to_mark' - end + before do + authenticated? + end + + desc 'Upload and generate doubtfire-task-specific submission document' + params do + optional :file0, type: File, desc: 'file 0.' + optional :file1, type: File, desc: 'file 1.' + optional :contributions, type: JSON, desc: "Contribution details JSON, eg: [ { project_id: 1, pct:'0.44', pts: 4 }, ... ]" + optional :alignment_data, type: JSON, desc: "Data for task alignment, eg: [ { ilo_id: 1, rating: 5, rationale: 'Hello' }, ... ]" + optional :trigger, type: String, desc: 'Can be need_help to indicate upload is not a ready to mark submission' + end + post '/projects/:id/task_def_id/:task_definition_id/submission' do - alignments = params[:alignment_data] - upload_reqs = task.upload_requirements - student = task.project.student - unit = task.project.unit + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - # Copy files to be PDFed - task.accept_submission(current_user, scoop_files(params, upload_reqs), student, self, params[:contributions], trigger, alignments) + # check the user can put this task + unless authorise? current_user, project, :make_submission + error!({ error: "Not authorised to submit task '#{task_definition.name}'" }, 401) + end - TaskUpdateSerializer.new(task) - end # post + task = project.task_for_task_definition(task_definition) - desc 'Retrieve submission document included for the task id' - params do - optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + if task.group_task? && !task.group + error!({ error: "This task requires a group submission. Ensure you are in a group for the unit's #{task_definition.group_set.name}" }, 403) end - get '/projects/:id/task_def_id/:task_definition_id/submission' do - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - # check the user can put this task - unless authorise? current_user, project, :get_submission - error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) - end + trigger = if params[:trigger] && params[:trigger].tr('"\'', '') == 'need_help' + 'need_help' + else + 'ready_for_feedback' + end - task = project.task_for_task_definition(task_definition) + alignments = params[:alignment_data] + upload_reqs = task.upload_requirements + student = task.project.student - evidence_loc = task.portfolio_evidence - student = task.project.student - unit = task.project.unit + # Copy files to be PDFed + task.accept_submission(current_user, scoop_files(params, upload_reqs), student, self, params[:contributions], trigger, alignments) - if task.processing_pdf? - evidence_loc = Rails.root.join('public', 'resources', 'AwaitingProcessing.pdf') - filename='AwaitingProcessing.pdf' - elsif evidence_loc.nil? - evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename='FileNotFound.pdf' - else - filename="#{task.task_definition.abbreviation}.pdf" - end + overseer_assessment = OverseerAssessment.create_for(task) + if overseer_assessment.present? + logger.info "Launching Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id}" + response = overseer_assessment.send_to_overseer - if params[:as_attachment] - header['Content-Disposition'] = "attachment; filename=#{filename}" + if response[:error].present? + error!({ error: response[:error] }, 403) end - # Set download headers... - content_type 'application/pdf' - env['api.format'] = :binary + present :updated_task, task, with: Entities::TaskEntity, update_only: true + present :comment, response[:comment].serialize(current_user), with: Grape::Presenters::Presenter + return + end - File.read(evidence_loc) - end # get + logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" - desc "Request for a task's documents to be re-processed tp recreate the task's PDF" - put '/projects/:id/task_def_id/:task_definition_id/submission' do - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + present task, with: Entities::TaskEntity, update_only: true + end + # post - unless authorise? current_user, project, :get_submission - error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) - end + desc 'Retrieve submission document included for the task id' + params do + optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + end + get '/projects/:id/task_def_id/:task_definition_id/submission' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - task = project.task_for_task_definition(task_definition) + # check the user can put this task + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end - if task && PortfolioEvidence.recreate_task_pdf(task) - { result: 'done' } - else - { result: 'false' } - end - end # put + task = project.task_for_task_definition(task_definition) + + evidence_loc = task.portfolio_evidence_path + student = task.project.student + unit = task.project.unit + + if task.processing_pdf? + evidence_loc = Rails.root.join('public', 'resources', 'AwaitingProcessing.pdf') + filename='AwaitingProcessing.pdf' + elsif evidence_loc.nil? + evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + filename='FileNotFound.pdf' + else + filename="#{task.task_definition.abbreviation}.pdf" + end + + + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{filename}" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + end + + # Set download headers... + content_type 'application/pdf' + env['api.format'] = :binary + + File.read(evidence_loc) + end # get + + desc "Request for a task's documents to be re-processed tp recreate the task's PDF" + put '/projects/:id/task_def_id/:task_definition_id/submission' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + + if task && PortfolioEvidence.recreate_task_pdf(task) + result = 'done' + else + result = 'false' + end + + present :result, result, with: Grape::Presenters::Presenter + end # put + + desc 'Get the timestamps of the last 10 submissions of a task' + get '/projects/:id/task_def_id/:task_definition_id/submissions/timestamps' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + + unless task + error!({ error: "A submission for this task definition have never been created" }, 401) + end + + result = OverseerAssessment.where(task_id: task.id).order(submission_timestamp: :desc).limit(10) + present result, with: Entities::OverseerAssessmentEntity + end + + desc 'Trigger an overseer assessment to run again' + put '/projects/:id/task_def_id/:task_definition_id/overseer_assessment/:oa_id/trigger' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + + unless task + error!({ error: "A submission for this task definition have never been created" }, 401) + end + + oa_id = timestamp = params[:oa_id] + + oa = task.overseer_assessments.find(oa_id) + response = oa.send_to_overseer + if response[:error].present? + error!({ error: response[:error] }, 403) + end + + present response[:comment].serialize(current_user), with: Grape::Presenters::Presenter + end + + desc 'Get the result of the submission of a task made at the given timestamp' + get '/projects/:id/task_def_id/:task_definition_id/submissions/timestamps/:timestamp' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + + unless task + error!({ error: "A submission for this task definition have never been created" }, 401) + end + + timestamp = params[:timestamp] + + path = FileHelper.task_submission_identifier_path_with_timestamp(:done, task, timestamp) + unless File.exist? path + error!({ error: "No submissions found for project: '#{params[:id]}' task: '#{params[:task_def_id]}' and timestamp: '#{timestamp}'" }, 401) + end + + unless File.exist? "#{path}/output.txt" + error!({ error: "There is no output for this assessment. Either the output wasn't generated, or processing failed. Please review your submission, and discuss with the teaching team if issues persist." }, 401) + end + + result = [] + result << { label: 'output', result: File.read("#{path}/output.txt") } + + if project.role_for(current_user) == :student + return result + end + + if File.exist? "#{path}/build-diff.txt" + result << { label: 'build-diff', result: File.read("#{path}/build-diff.txt") } + end + + if File.exist? "#{path}/run-diff.txt" + result << { label: 'run-diff', result: File.read("#{path}/run-diff.txt") } + end + + present result, with: Grape::Presenters::Presenter + end + + desc 'Get the result of the submission of a task made last' + get '/projects/:id/task_def_id/:task_definition_id/submissions/latest' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + + unless task + error!({ error: "A submission for this task definition have never been created" }, 401) + end + + path = FileHelper.task_submission_identifier_path(:done, task) + unless File.exist? path + error!({ error: "No submissions found for project: '#{params[:id]}' task: '#{params[:task_def_id]}'" }, 401) + end + + path = "#{path}/#{FileHelper.latest_submission_timestamp_entry_in_dir(path)}" + + unless File.exist? "#{path}/output.txt" + error!({ error: "There is no output for this assessment. Either the output wasn't generated, or processing failed. Please review your submission, and discuss with the teaching team if issues persist." }, 401) + end + + result = [] + result << { label: 'output', result: File.read("#{path}/output.txt") } + + if project.role_for(current_user) == :student + present result, with: Grape::Presenters::Presenter + return + end + + if File.exist? "#{path}/build-diff.txt" + result << { label: 'build-diff', result: File.read("#{path}/build-diff.txt") } + end + + if File.exist? "#{path}/run-diff.txt" + result << { label: 'run-diff', result: File.read("#{path}/run-diff.txt") } + end + + present result, with: Grape::Presenters::Presenter + end + + # TODO: Remove the dependency on units - figure out how to authorise + desc 'Get the list of supported overseer images' + get '/units/:unit_id/overseer/docker/images' do + unless Doubtfire::Application.config.overseer_enabled + error!({ error: 'Overseer is not enabled' }, 403) + end + + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to download task details of unit' }, 403) + end + + result = { + result: Doubtfire::Application.config.overseer_images + } + + present result, with: Grape::Presenters::Presenter end end end diff --git a/app/api/task_comments.rb b/app/api/task_comments.rb deleted file mode 100644 index a4b4350e5..000000000 --- a/app/api/task_comments.rb +++ /dev/null @@ -1,114 +0,0 @@ -require 'grape' - -module Api - class TaskComments < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - desc 'Add a new comment to a task' - params do - requires :comment, type: String, desc: 'The comment text to add to the task' - end - post '/projects/:project_id/task_def_id/:task_definition_id/comments' do - project = Project.find(params[:project_id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - unless authorise? current_user, project, :make_submission - error!({ error: 'Not authorised to create a comment for this task' }, 403) - end - - task = project.task_for_task_definition(task_definition) - result = task.add_comment current_user, params[:comment] - - if result.nil? - error!({ error: 'No comment added. Comment duplicates last comment, so ignored.' }, 403) - else - result.mark_as_read(current_user, project.unit) - result - end - end - - desc 'Get the comments related to a task' - get '/projects/:project_id/task_def_id/:task_definition_id/comments' do - project = Project.find(params[:project_id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - unless authorise? current_user, project, :get - error!({ error: 'You cannot read the comments for this task' }, 403) - end - - if project.has_task_for_task_definition? task_definition - task = project.task_for_task_definition(task_definition) - - comments = task.all_comments.order('created_at ASC') - result = comments.map do |c| - { - id: c.id, - comment: c.comment, - is_new: c.new_for?(current_user), - author: { - id: c.user.id, - name: c.user.name, - email: c.user.email - }, - recipient: { - id: c.recipient.id, - name: c.recipient.name, - email: c.user.email - }, - created_at: c.created_at, - recipient_read_time: c.time_read_by(c.recipient), - } - end - task.mark_comments_as_read(current_user, comments) - else - result = [] - end - result - end - - desc 'Delete a comment' - delete '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do - project = Project.find(params[:project_id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - unless authorise? current_user, project, :get - error!({ error: 'You cannot read the comments for this task' }, 403) - end - - task = project.task_for_task_definition(task_definition) - task_comment = task.comments.find(params[:id]) - - key = if current_user == task_comment.user - :delete_own_comment - else - :delete_other_comment - end - - unless authorise? current_user, task, key - error!({ error: 'Not authorised to delete this comment' }, 403) - end - - task_comment.destroy - end - - desc 'Mark a comment as unread' - post '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do - project = Project.find(params[:project_id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - unless authorise? current_user, project, :make_submission - error!({ error: 'Not authorised to mark comment as unread' }, 403) - end - - task = project.task_for_task_definition(task_definition) - - task_comment = task.comments.find(params[:id]) - task_comment.mark_as_unread(current_user) - end - end -end diff --git a/app/api/task_comments_api.rb b/app/api/task_comments_api.rb new file mode 100644 index 000000000..f9edc0b44 --- /dev/null +++ b/app/api/task_comments_api.rb @@ -0,0 +1,194 @@ +require 'grape' + +class TaskCommentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add a new comment to a task' + params do + optional :comment, type: String, desc: 'The comment text to add to the task' + optional :attachment, type: File, desc: 'Image, sound, PDF or video comment file' + optional :reply_to_id, type: Integer, desc: 'The comment to which this comment is replying' + end + post '/projects/:project_id/task_def_id/:task_definition_id/comments' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :make_submission + error!({ error: 'Not authorised to create a comment for this task' }, 403) + end + + text_comment = params[:comment] + attached_file = params[:attachment] + reply_to_id = params[:reply_to_id] + + if attached_file.present? + error!({error: "Attachment is empty."}) unless File.size?(attached_file["tempfile"].path).present? + error!({error: "Attachment exceeds the maximum attachment size of 30MB."}) unless File.size?(attached_file["tempfile"].path) < 30_000_000 + end + + task = project.task_for_task_definition(task_definition) + type_string = content_type.to_s + + if reply_to_id.present? + originalTaskComment = TaskComment.find(reply_to_id) + error!(error: 'You do not have permission to read the replied comment') unless authorise?(current_user, originalTaskComment.project, :get) || (task.group_task? && task.group.role_for(current_user) != nil) + error!(error: 'Original comment is not in this task.') unless task.all_comments.find(reply_to_id).present? + end + + logger.info("#{current_user.username} - added comment for task #{task.id} (#{task_definition.abbreviation})") + + if attached_file.nil? || attached_file.empty? + error!({ error: 'Comment text is empty, unable to add new comment' }, 403) unless text_comment.present? + result = task.add_text_comment(current_user, text_comment, reply_to_id) + else + unless FileHelper.accept_file(attached_file, 'comment attachment - TaskComment', 'comment_attachment') + error!({ error: 'Please upload only images, audio or PDF documents' }, 403) + end + + result = task.add_comment_with_attachment(current_user, attached_file, reply_to_id) + end + + if result.nil? + error!({ error: 'No comment added. Comment duplicates last comment, so ignored.' }, 403) + else + present result.serialize(current_user), with: Grape::Presenters::Presenter + end + end + + desc 'Get an attachment related to a task comment' + params do + optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + end + get '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get + error!({ error: 'You cannot read the comments for this task' }, 403) + end + + if project.has_task_for_task_definition? task_definition + task = project.task_for_task_definition(task_definition) + + comment = task.comments.find(params[:id]) + + error!({ error: 'No attachment for this comment.' }, 404) unless %w(audio image pdf).include? comment.content_type + + error!({ error: 'File missing' }, 404) unless File.exist? comment.attachment_path + + # Set return content type + content_type comment.attachment_mime_type + + env['api.format'] = :binary + + # mark as attachment + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{comment.attachment_file_name}" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + end + + # Work out what part to return + file_size = File.size(comment.attachment_path) + begin_point = 0 + end_point = file_size - 1 + + # Was it asked for just a part of the file? + if request.headers['Range'] + # indicate partial content + status 206 + + # extract part desired from the content + if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ + begin_point = Regexp.last_match(1).to_i + end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? + end + + end_point = file_size - 1 unless end_point < file_size - 1 + end + + # Return the requested content + content_length = end_point - begin_point + 1 + header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" + header['Content-Length'] = content_length.to_s + header['Accept-Ranges'] = 'bytes' + + # Read the binary data and return + IO.binread(comment.attachment_path, content_length, begin_point) + end + end + + desc 'Get the comments related to a task' + get '/projects/:project_id/task_def_id/:task_definition_id/comments' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get + error!({ error: 'You cannot read the comments for this task' }, 403) + end + + if project.has_task_for_task_definition? task_definition + task = project.task_for_task_definition(task_definition) + + comments = task.all_comments.order('created_at ASC') + result = comments.map { |c| c.serialize(current_user) } + # result = task.comments_for_user(current_user) + # result.each do |d| end # cache results... + + # mark every comment type except for DiscussionComments so we don't mark it as read. + comments_to_mark_as_read = comments.where("TYPE is null OR TYPE != 'DiscussionComment'") + task.mark_comments_as_read(current_user, comments_to_mark_as_read) + else + result = [] + end + present result, with: Grape::Presenters::Presenter + end + + desc 'Delete a comment' + delete '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get + error!({ error: 'You cannot read the comments for this task' }, 403) + end + + task = project.task_for_task_definition(task_definition) + task_comment = task.all_comments.find(params[:id]) + + key = if current_user == task_comment.user + :delete_own_comment + else + :delete_other_comment + end + + unless authorise? current_user, task, key + error!({ error: 'Not authorised to delete this comment' }, 403) + end + + task_comment.destroy + + present false + end + + desc 'Mark a comment as unread' + post '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :make_submission + error!({ error: 'Not authorised to mark comment as unread' }, 403) + end + + task = project.task_for_task_definition(task_definition) + + task_comment = task.comments.find(params[:id]) + task_comment.mark_as_unread(current_user) + + present task_comment.serialize(current_user), with: Grape::Presenters::Presenter + end +end diff --git a/app/api/task_definitions.rb b/app/api/task_definitions.rb deleted file mode 100644 index dec956707..000000000 --- a/app/api/task_definitions.rb +++ /dev/null @@ -1,354 +0,0 @@ -require 'grape' -require 'task_serializer' -require 'mime-check-helpers' - -module Api - class TaskDefinitions < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers FileHelper - helpers MimeCheckHelpers - - before do - authenticated? - end - - desc 'Add a new task definition to the given unit' - params do - requires :task_def, type: Hash do - requires :unit_id, type: Integer, desc: 'The unit to create the new task def for' - requires :name, type: String, desc: 'The name of this task def' - requires :description, type: String, desc: 'The description of this task def' - requires :weighting, type: Integer, desc: 'The weighting of this task' - requires :target_grade, type: Integer, desc: 'Minimum grade for task' - optional :group_set_id, type: Integer, desc: 'Related group set' - requires :start_date, type: Date, desc: 'The date when the task should be started' - requires :target_date, type: Date, desc: 'The date when the task is due' - optional :due_date, type: Date, desc: 'The deadline date' - requires :abbreviation, type: String, desc: 'The abbreviation of the task' - requires :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' - optional :upload_requirements, type: String, desc: 'Task file upload requirements' - optional :plagiarism_checks, type: String, desc: 'The list of checks to perform' - requires :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' - requires :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' - requires :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' - end - end - post '/task_definitions/' do - unit = Unit.find(params[:task_def][:unit_id]) - - puts current_user - unless authorise? current_user, unit, :add_task_def - error!({ error: 'Not authorised to create a task definition of this unit' }, 403) - end - - params[:task_def][:upload_requirements] = '[]' if params[:task_def][:upload_requirements].nil? - - task_params = ActionController::Parameters.new(params) - .require(:task_def) - .permit( - :unit_id, - :name, - :description, - :weighting, - :target_grade, - :start_date, - :target_date, - :due_date, - :abbreviation, - :restrict_status_updates, - :upload_requirements, - :plagiarism_checks, - :plagiarism_warn_pct, - :is_graded, - :max_quality_pts - ) - - task_def = TaskDefinition.new(task_params) - - # - # Link in group set if specified - # - if params[:task_def][:group_set_id] && params[:task_def][:group_set_id] >= 0 - gs = GroupSet.find(params[:task_def][:group_set_id]) - task_def.group_set = gs if gs.unit == unit - end - - task_def.save! - task_def - end - - desc 'Edits the given task definition' - params do - requires :id, type: Integer, desc: 'The task id to edit' - requires :task_def, type: Hash do - optional :unit_id, type: Integer, desc: 'The unit to create the new task def for' - optional :name, type: String, desc: 'The name of this task def' - optional :description, type: String, desc: 'The description of this task def' - optional :weighting, type: Integer, desc: 'The weighting of this task' - optional :target_grade, type: Integer, desc: 'Target grade for task' - optional :group_set_id, type: Integer, desc: 'Related group set' - optional :start_date, type: Date, desc: 'The date when the task should be started' - optional :target_date, type: Date, desc: 'The date when the task is due' - optional :due_date, type: Date, desc: 'The deadline date' - optional :abbreviation, type: String, desc: 'The abbreviation of the task' - optional :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' - optional :upload_requirements, type: String, desc: 'Task file upload requirements' - optional :plagiarism_checks, type: String, desc: 'The list of checks to perform' - optional :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' - optional :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' - optional :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' - end - end - put '/task_definitions/:id' do - task_def = TaskDefinition.find(params[:id]) - - unless authorise? current_user, task_def.unit, :add_task_def - error!({ error: 'Not authorised to create a task definition of this unit' }, 403) - end - - task_params = ActionController::Parameters.new(params) - .require(:task_def) - .permit( - :unit_id, - :name, - :description, - :weighting, - :target_grade, - :start_date, - :target_date, - :due_date, - :abbreviation, - :restrict_status_updates, - :upload_requirements, - :plagiarism_checks, - :plagiarism_warn_pct, - :is_graded, - :max_quality_pts - ) - - task_def.update!(task_params) - # - # Link in group set if specified - # - if params[:task_def][:group_set_id] - if params[:task_def][:group_set_id] >= 0 - gs = GroupSet.find(params[:task_def][:group_set_id]) - if gs.unit == task_def.unit - task_def.group_set = gs - task_def.save - end - else - task_def.group_set = nil - task_def.save - end - end - - task_def - end - - desc 'Upload CSV of task definitions to the provided unit' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - requires :unit_id, type: Integer, desc: 'The unit to upload tasks to' - end - post '/csv/task_definitions' do - # check mime is correct before uploading - ensure_csv!(params[:file][:tempfile]) - - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :upload_csv - error!({ error: 'Not authorised to upload CSV of tasks' }, 403) - end - - # Actually import... - unit.import_tasks_from_csv(params[:file][:tempfile]) - end - - desc 'Download CSV of all task definitions for the given unit' - params do - requires :unit_id, type: Integer, desc: 'The unit to download tasks from' - end - get '/csv/task_definitions' do - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :download_unit_csv - error!({ error: 'Not authorised to download CSV of tasks' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-Tasks.csv " - env['api.format'] = :binary - unit.task_definitions_csv - end - - desc 'Delete a task definition' - delete '/task_definitions/:id' do - task_def = TaskDefinition.find(params[:id]) - - unless authorise? current_user, task_def.unit, :add_task_def - error!({ error: 'Not authorised to delete a task definition of this unit' }, 403) - end - - task_def.destroy - end - - desc 'Upload the task sheet for a given task' - params do - requires :unit_id, type: Integer, desc: 'The related unit' - requires :task_def_id, type: Integer, desc: 'The related task definition' - requires :file, type: Rack::Multipart::UploadedFile, desc: 'The task sheet pdf' - end - post '/units/:unit_id/task_definitions/:task_def_id/task_sheet' do - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :add_task_def - error!({ error: 'Not authorised to upload tasks of unit' }, 403) - end - - task_def = unit.task_definitions.find(params[:task_def_id]) - - file = params[:file] - - unless FileHelper.accept_file(file, 'task sheet', 'document') - error!({ error: "'#{file.name}' is not a valid #{file.type} file" }, 403) - end - - # Actually import... - task_def.add_task_sheet(file[:tempfile].path) - end - - desc 'Upload the task resources for a given task' - params do - requires :unit_id, type: Integer, desc: 'The related unit' - requires :task_def_id, type: Integer, desc: 'The related task definition' - requires :file, type: Rack::Multipart::UploadedFile, desc: 'The task resources zip' - end - post '/units/:unit_id/task_definitions/:task_def_id/task_resources' do - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :add_task_def - error!({ error: 'Not authorised to upload tasks of unit' }, 403) - end - - task_def = unit.task_definitions.find(params[:task_def_id]) - - file_path = params[:file][:tempfile].path - - check_mime_against_list! file_path, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] - - # Actually import... - task_def.add_task_resources(file_path) - end - - desc 'Upload a zip file containing the task pdfs for a given task' - params do - requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' - requires :file, type: Rack::Multipart::UploadedFile, desc: 'batch file upload' - end - post '/units/:unit_id/task_definitions/task_pdfs' do - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :add_task_def - error!({ error: 'Not authorised to upload tasks of unit' }, 403) - end - - file = params[:file][:tempfile].path - - check_mime_against_list! file, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] - - # Actually import... - unit.import_task_files_from_zip file - end - - desc 'Download the tasks related to a task definition' - params do - requires :unit_id, type: Integer, desc: 'The unit containing the task definition' - requires :task_def_id, type: Integer, desc: "The task definition's id" - end - get '/units/:unit_id/task_definitions/:task_def_id/tasks' do - unit = Unit.find(params[:unit_id]) - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to access tasks for this unit' }, 403) - end - - unit.student_tasks - .joins(:project) - .joins(:task_status) - .select('projects.tutorial_id as tutorial_id', 'project_id', 'tasks.id as id', 'task_definition_id', 'task_statuses.name as status_name', 'completion_date', 'times_assessed', 'submission_date') - .where('task_definition_id = :id', id: params[:task_def_id]) - .map do |t| - { - project_id: t.project_id, - id: t.id, - task_definition_id: t.task_definition_id, - tutorial_id: t.tutorial_id, - status: TaskStatus.status_key_for_name(t.status_name), - completion_date: t.completion_date, - submission_date: t.submission_date, - times_assessed: t.times_assessed, - similar_to_count: t.similar_to_count - } - end - end - - desc 'Download the task pdf' - params do - requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' - requires :task_def_id, type: Integer, desc: 'The task definition to get the pdf of' - optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' - end - get '/units/:unit_id/task_definitions/:task_def_id/task_pdf' do - unit = Unit.find(params[:unit_id]) - task_def = unit.task_definitions.find(params[:task_def_id]) - - unless authorise? current_user, unit, :get_unit - error!({ error: 'Not authorised to download task details of unit' }, 403) - end - - if task_def.has_task_pdf? - path = unit.path_to_task_pdf(task_def) - filename = "#{task_def.unit.code}-#{task_def.abbreviation}.pdf" - else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename = "FileNotFound.pdf" - end - - if params[:as_attachment] - header['Content-Disposition'] = "attachment; filename=#{filename}" - end - - content_type 'application/pdf' - env['api.format'] = :binary - File.read(path) - end - - desc 'Download the task resources' - params do - requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' - requires :task_def_id, type: Integer, desc: 'The task definition to get the pdf of' - end - get '/units/:unit_id/task_definitions/:task_def_id/task_resources' do - unit = Unit.find(params[:unit_id]) - task_def = unit.task_definitions.find(params[:task_def_id]) - - unless authorise? current_user, unit, :get_unit - error!({ error: 'Not authorised to download task details of unit' }, 403) - end - - if task_def.has_task_resources? - path = unit.path_to_task_resources(task_def) - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-resources.zip" - else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - content_type 'application/pdf' - header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' - end - - env['api.format'] = :binary - File.read(path) - end - end -end diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb new file mode 100644 index 000000000..cd1ad7f61 --- /dev/null +++ b/app/api/task_definitions_api.rb @@ -0,0 +1,583 @@ +require 'grape' + +class TaskDefinitionsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers FileHelper + helpers MimeCheckHelpers + helpers Submission::GenerateHelpers + + before do + authenticated? + end + + desc 'Add a new task definition to the given unit' + params do + requires :task_def, type: Hash do + optional :tutorial_stream_abbr, type: String, desc: 'The abbreviation of tutorial stream' + requires :name, type: String, desc: 'The name of this task def' + requires :description, type: String, desc: 'The description of this task def' + requires :weighting, type: Integer, desc: 'The weighting of this task' + requires :target_grade, type: Integer, desc: 'Minimum grade for task' + optional :group_set_id, type: Integer, desc: 'Related group set' + requires :start_date, type: Date, desc: 'The date when the task should be started' + requires :target_date, type: Date, desc: 'The date when the task is due' + optional :due_date, type: Date, desc: 'The deadline date' + requires :abbreviation, type: String, desc: 'The abbreviation of the task' + requires :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' + optional :upload_requirements, type: String, desc: 'Task file upload requirements' + optional :plagiarism_checks, type: String, desc: 'The list of checks to perform' + requires :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' + requires :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' + requires :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' + optional :assessment_enabled, type: Boolean, desc: 'Enable or disable assessment' + optional :overseer_image_id, type: Integer, desc: 'The id of the Docker image for overseer' + end + end + post '/units/:unit_id/task_definitions/' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to create a task definition of this unit' }, 403) + end + + params[:task_def][:upload_requirements] = '[]' if params[:task_def][:upload_requirements].nil? + + task_params = ActionController::Parameters.new(params) + .require(:task_def) + .permit( + :name, + :description, + :weighting, + :target_grade, + :start_date, + :target_date, + :due_date, + :abbreviation, + :restrict_status_updates, + :upload_requirements, + :plagiarism_checks, + :plagiarism_warn_pct, + :is_graded, + :max_quality_pts, + :assessment_enabled, + :overseer_image_id + ) + + task_params[:unit_id] = unit.id + + task_def = TaskDefinition.new(task_params) + + # Set the tutorial stream + tutorial_stream_abbr = params[:task_def][:tutorial_stream_abbr] + unless tutorial_stream_abbr.nil? + tutorial_stream = unit.tutorial_streams.find_by!(abbreviation: tutorial_stream_abbr) + task_def.tutorial_stream = tutorial_stream + end + + # + # Link in group set if specified + # + if params[:task_def][:group_set_id] && params[:task_def][:group_set_id] >= 0 + gs = GroupSet.find(params[:task_def][:group_set_id]) + task_def.group_set = gs if gs.unit == unit + end + + task_def.save! + present task_def, with: Entities::TaskDefinitionEntity + end + + desc 'Edits the given task definition' + params do + requires :id, type: Integer, desc: 'The task id to edit' + requires :task_def, type: Hash do + optional :tutorial_stream_abbr, type: String, desc: 'The abbreviation of the tutorial stream' + optional :name, type: String, desc: 'The name of this task def' + optional :description, type: String, desc: 'The description of this task def' + optional :weighting, type: Integer, desc: 'The weighting of this task' + optional :target_grade, type: Integer, desc: 'Target grade for task' + optional :group_set_id, type: Integer, desc: 'Related group set' + optional :start_date, type: Date, desc: 'The date when the task should be started' + optional :target_date, type: Date, desc: 'The date when the task is due' + optional :due_date, type: Date, desc: 'The deadline date' + optional :abbreviation, type: String, desc: 'The abbreviation of the task' + optional :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' + optional :upload_requirements, type: String, desc: 'Task file upload requirements' + optional :plagiarism_checks, type: String, desc: 'The list of checks to perform' + optional :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' + optional :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' + optional :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' + optional :assessment_enabled, type: Boolean, desc: 'Enable or disable assessment' + optional :overseer_image_id, type: Integer, desc: 'The id of the Docker image name for overseer' + end + end + put '/units/:unit_id/task_definitions/:id' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:id]) + + unless authorise? current_user, task_def.unit, :add_task_def + error!({ error: 'Not authorised to create a task definition of this unit' }, 403) + end + + task_params = ActionController::Parameters.new(params) + .require(:task_def) + .permit( + :name, + :description, + :weighting, + :target_grade, + :start_date, + :target_date, + :due_date, + :abbreviation, + :restrict_status_updates, + :upload_requirements, + :plagiarism_checks, + :plagiarism_warn_pct, + :is_graded, + :max_quality_pts, + :assessment_enabled, + :overseer_image_id + ) + + # Ensure changes to a TD defined as a "draft task definition" are validated + if unit.draft_task_definition_id == params[:id] + if params[:task_def][:upload_requirements] + requirements = JSON.parse(params[:task_def][:upload_requirements]) + if requirements.length != 1 || requirements[0]["type"] != "document" + error!({ error: 'Task is marked as the draft learning summary task definition. A draft learning summary task can only contain a single document upload.' }, 403) + end + end + end + + task_def.update!(task_params) + + # Set the tutorial stream + tutorial_stream_abbr = params[:task_def][:tutorial_stream_abbr] + unless tutorial_stream_abbr.nil? + tutorial_stream = task_def.unit.tutorial_streams.find_by!(abbreviation: tutorial_stream_abbr) + task_def.tutorial_stream = tutorial_stream + task_def.save! + end + + # + # Link in group set if specified + # + if params[:task_def][:group_set_id] + if params[:task_def][:group_set_id] >= 0 + gs = GroupSet.find(params[:task_def][:group_set_id]) + if gs.unit == task_def.unit + task_def.group_set = gs + task_def.save! + end + else + task_def.group_set = nil + task_def.save! + end + end + + present task_def, with: Entities::TaskDefinitionEntity + end + + desc 'Upload CSV of task definitions to the provided unit' + params do + requires :file, type: File, desc: 'CSV upload file.' + requires :unit_id, type: Integer, desc: 'The unit to upload tasks to' + end + post '/csv/task_definitions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :upload_csv + error!({ error: 'Not authorised to upload CSV of tasks' }, 403) + end + + unless params[:file].present? + error!({ error: "No file uploaded" }, 403) + end + + path = params[:file][:tempfile].path + + # check mime is correct before uploading + ensure_csv!(path) + + # Actually import... + unit.import_tasks_from_csv(File.new(path)) + end + + desc 'Download CSV of all task definitions for the given unit' + params do + requires :unit_id, type: Integer, desc: 'The unit to download tasks from' + end + get '/csv/task_definitions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :download_unit_csv + error!({ error: 'Not authorised to download CSV of tasks' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-Tasks.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.task_definitions_csv + end + + desc 'Delete a task definition' + delete '/units/:unit_id/task_definitions/:id' do + task_def = TaskDefinition.find(params[:id]) + + unless authorise? current_user, task_def.unit, :add_task_def + error!({ error: 'Not authorised to delete a task definition of this unit' }, 403) + end + + task_def.destroy + task_def.destroyed? + end + + desc 'Upload the task sheet for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + requires :file, type: File, desc: 'The task sheet pdf' + end + post '/units/:unit_id/task_definitions/:task_def_id/task_sheet' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to upload tasks of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + file = params[:file] + + unless FileHelper.accept_file(file, 'task sheet', 'document') + error!({ error: "'#{file[:name]}' is not a valid #{file[:type]} file" }, 403) + end + + # Actually import... + task_def.add_task_sheet(file[:tempfile].path) + true + end + + desc 'Test overseer assessment for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + optional :file0, type: Rack::Multipart::UploadedFile, desc: 'file 0.' + optional :file1, type: Rack::Multipart::UploadedFile, desc: 'file 1.' + # This API accepts more than 2 files, file0 and file1 are just examples. + end + post '/units/:unit_id/task_definitions/:task_def_id/test_overseer_assessment' do + logger.info "********* - Starting overseer test" + return 'Overseer is not enabled' if !Doubtfire::Application.config.overseer_enabled + + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :perform_overseer_assessment_test + error!({ error: 'Not authorised to test overseer assessment of tasks of this unit' }, 403) + end + + task_definition = unit.task_definitions.find(params[:task_def_id]) + + project = Project.where(unit: unit, user: current_user).first + + if project.nil? + # Create a project for the unit chair + project = unit.enrol_student(current_user, Campus.first) + end + + task = project.task_for_task_definition(task_definition) + + upload_reqs = task.upload_requirements + + # Copy files to be PDFed + task.accept_submission(current_user, scoop_files(params, upload_reqs), current_user, self, nil, 'ready_for_feedback', nil) + + logger.info "********* - about to perform overseer submission" + overseer_assessment = OverseerAssessment.create_for(task) + if overseer_assessment.present? + response = overseer_assessment.send_to_overseer + + if response[:error].present? + error!({ error: response[:error] }, 403) + end + + logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was performed" + else + logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" + end + + #todo: Do we need to return additional details here? e.g. the comment, and project? + present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true + end + + desc 'Remove the task sheet for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + end + delete '/units/:unit_id/task_definitions/:task_def_id/task_sheet' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to remove task sheets of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + # Actually delete... + task_def.remove_task_sheet() + true + end + + desc 'Upload the task resources for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + requires :file, type: File, desc: 'The task resources zip' + end + post '/units/:unit_id/task_definitions/:task_def_id/task_resources' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to upload tasks of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless params[:file].present? + error!({ error: "No file uploaded" }, 403) + end + + file_path = params[:file][:tempfile].path + + check_mime_against_list! file_path, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] + + # Actually import... + task_def.add_task_resources(file_path) + true + end + + desc 'Remove the task resources for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + end + delete '/units/:unit_id/task_definitions/:task_def_id/task_resources' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to remove task resources of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + # Actually remove... + task_def.remove_task_resources + true + end + + desc 'Upload the task assessment resources for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + requires :file, type: Rack::Multipart::UploadedFile, desc: 'The task assessment resources zip' + end + post '/units/:unit_id/task_definitions/:task_def_id/task_assessment_resources' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to upload task assessment resources of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + file_path = params[:file][:tempfile].path + + check_mime_against_list! file_path, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] + + # Actually import... + task_def.add_task_assessment_resources(file_path) + true + end + + desc 'Remove the task assessment resources for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + end + delete '/units/:unit_id/task_definitions/:task_def_id/task_assessment_resources' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to remove task assessment resources of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + # Actually remove... + task_def.remove_task_assessment_resources + true + end + + desc 'Upload a zip file containing the task pdfs for a given task' + params do + requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' + requires :file, type: File, desc: 'batch file upload' + end + post '/units/:unit_id/task_definitions/task_pdfs' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to upload tasks of unit' }, 403) + end + + unless params[:file].present? + error!({ error: "No file uploaded" }, 403) + end + + file = params[:file][:tempfile].path + + check_mime_against_list! file, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] + + # Actually import... + unit.import_task_files_from_zip file + end + + desc 'Download the tasks related to a task definition' + params do + requires :unit_id, type: Integer, desc: 'The unit containing the task definition' + requires :task_def_id, type: Integer, desc: "The task definition's id" + end + get '/units/:unit_id/task_definitions/:task_def_id/tasks' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :provide_feedback + error!({ error: 'Not authorised to access tasks for this unit' }, 403) + end + + # Which task definition is this for + task_def = unit.task_definitions.find(params[:task_def_id]) + + # What stream does this relate to? + stream = task_def.tutorial_stream + + subquery = unit. + tutorial_enrolments. + joins(:tutorial). + where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL', sid: (stream.present? ? stream.id : nil)). + select('tutorials.tutorial_stream_id as tutorial_stream_id', 'tutorials.id as tutorial_id', 'project_id').to_sql + + result = unit.student_tasks. + joins(:project). + joins(:task_status). + joins("LEFT OUTER JOIN (#{subquery}) as sq ON sq.project_id = projects.id"). + select('sq.tutorial_stream_id as tutorial_stream_id', 'sq.tutorial_id as tutorial_id', 'project_id', 'tasks.id as id', 'task_definition_id', 'task_statuses.id as status_id', 'completion_date', 'times_assessed', 'submission_date', 'grade'). + where('task_definition_id = :id', id: params[:task_def_id]). + map do |t| + { + project_id: t.project_id, + id: t.id, + task_definition_id: t.task_definition_id, + tutorial_id: t.tutorial_id, + tutorial_stream_id: t.tutorial_stream_id, + status: TaskStatus.id_to_key(t.status_id), + completion_date: t.completion_date, + submission_date: t.submission_date, + times_assessed: t.times_assessed, + similar_to_count: t.similar_to_count, + grade: t.grade + } + end + + present result, with: Grape::Presenters::Presenter + end + + desc 'Download the task sheet containing the details related to performing that task' + params do + requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the pdf of' + optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' + end + get '/units/:unit_id/task_definitions/:task_def_id/task_pdf' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to download task details of unit' }, 403) + end + + if task_def.has_task_sheet? + path = task_def.task_sheet + filename = "#{task_def.unit.code}-#{task_def.abbreviation}.pdf" + else + path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + filename = "FileNotFound.pdf" + end + + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{filename}" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + end + + content_type 'application/pdf' + env['api.format'] = :binary + File.read(path) + end + + desc 'Download the task resources' + params do + requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the pdf of' + end + get '/units/:unit_id/task_definitions/:task_def_id/task_resources' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to download task details of unit' }, 403) + end + + if task_def.has_task_resources? + path = task_def.task_resources + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-resources.zip" + else + path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + content_type 'application/pdf' + header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' + end + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + + env['api.format'] = :binary + File.read(path) + end + + desc 'Download the task assessment resources' + params do + requires :unit_id, type: Integer, desc: 'The unit to upload tasks for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the assessment resources of' + end + get '/units/:unit_id/task_definitions/:task_def_id/task_assessment_resources' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to download task details of unit' }, 403) + end + + if task_def.has_task_assessment_resources? + path = task_def.task_assessment_resources + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-assessment-resources.zip" + else + path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + content_type 'application/pdf' + header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' + end + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + + env['api.format'] = :binary + File.read(path) + end +end diff --git a/app/api/tasks.rb b/app/api/tasks.rb deleted file mode 100644 index df10e954c..000000000 --- a/app/api/tasks.rb +++ /dev/null @@ -1,282 +0,0 @@ -require 'grape' -require 'task_serializer' - -module Api - class Tasks < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - # - # Tasks only used for the task summary stats view... - # - desc "Get all the current user's tasks" - params do - requires :unit_id, type: Integer, desc: 'Unit to fetch the task details for' - end - get '/tasks' do - unit = Unit.find(params[:unit_id]) - - unless authorise? current_user, unit, :get_students - error!({ error: 'You do not have permission to read these task details' }, 403) - end - - unit.student_tasks - .joins(:task_status) - .select( - 'tasks.id', - 'projects.tutorial_id as tutorial_id', - 'task_statuses.name as status_name', - 'task_definition_id' - ) - .where('tasks.task_status_id > 1 and projects.tutorial_id is not null') - .map do |r| - { - id: r.id, - tutorial_id: r.tutorial_id, - task_definition_id: r.task_definition_id, - status: TaskStatus.status_key_for_name(r.status_name) - } - end - end - - desc 'Get a similarity match for a given task' - get '/tasks/:id/similarity/:count' do - unless authenticated? - error!({ error: "Not authorised to download details for task '#{params[:id]}'" }, 401) - end - task = Task.find(params[:id]) - - unless authorise? current_user, task, :get_submission - error!({ error: "Not authorised to download details for task '#{params[:id]}'" }, 401) - end - - match = params[:count].to_i % task.similar_to_count - if match < 0 - error!({ error: 'Invalid match sequence, must be 0 or larger' }, 403) - end - - match_link = task.plagiarism_match_links.order('created_at DESC')[match] - return if match_link.nil? - - logger.debug "Plagiarism match link 1: #{match_link}" - other_match_link = match_link.other_party - logger.debug "Plagiarism match link 2: #{other_match_link}" - output = FileHelper.path_to_plagarism_html(match_link) - - if output.nil? || !File.exist?(output) - error!({ error: 'No files to download' }, 403) - end - - if authorise? current_user, match_link.task, :view_plagiarism - student_url = match_link.plagiarism_report_url - end - - student_hash = { - username: match_link.student.username, - email: match_link.student.email, - name: match_link.student.name, - tutor: match_link.tutor.name, - tutorial: match_link.tutorial, - html: File.read(output), - url: student_url, - pct: match_link.pct, - dismissed: match_link.dismissed - } - other_student_hash = { - username: nil, - email: nil, - name: nil, - tutor: match_link.other_tutor.name, - tutorial: match_link.other_tutorial, - html: nil, - url: nil, - pct: other_match_link.pct, - dismissed: other_match_link.dismissed - } - - # Check if returning both parties - authorised_to_view_both = authorise? current_user, other_match_link.task, :get_submission - if authorised_to_view_both - other_output = FileHelper.path_to_plagarism_html(other_match_link) - if authorise? current_user, other_match_link.task, :view_plagiarism - other_student_url = other_match_link.plagiarism_report_url - end - # Update other_student_hash to include details - other_student_hash[:username] = match_link.other_student.username - other_student_hash[:email] = match_link.other_student.email - other_student_hash[:name] = match_link.other_student.name - other_student_hash[:tutor] = match_link.other_tutor.name - other_student_hash[:tutorial] = match_link.other_tutorial - other_student_hash[:html] = File.read(other_output) - other_student_hash[:url] = other_student_url - other_student_hash[:pct] = other_match_link.pct - other_student_hash[:dismissed] = other_match_link.dismissed - end - { - student: student_hash, - other_student: other_student_hash - } - end - - desc 'Dismiss a similarity match for a given task' - params do - requires :dismissed, type: Boolean, desc: 'Should this similarity be dismissed?' - requires :other, type: Boolean, desc: 'This tasks match or its reverse?' - end - put '/tasks/:id/similarity/:count' do - unless authenticated? - error!({ error: "Not authorised to access this task '#{params[:id]}'" }, 401) - end - task = Task.find(params[:id]) - - unless authorise? current_user, task, :delete_plagiarism - error!({ error: "Not authorised to remove similarity for task '#{params[:id]}'" }, 401) - end - - match = params[:count].to_i % task.similar_to_count - if match < 0 - error!({ error: 'Invalid match sequence, must be 0 or larger' }, 403) - end - - match_link = task.plagiarism_match_links.order('created_at DESC')[match] - return if match_link.nil? - - match_link = match_link.other_party if params[:other] - - logger.info "#{current_user.username} changing plagiarism: setting dismissed for #{task.task_definition.abbreviation} by #{task.student.username} to #{params[:dismissed]}" - - logger.debug " plagiarism match link 1: #{match_link}" - - match_link.dismissed = params[:dismissed] - match_link.save! - match_link.dismissed - end - - desc 'Update a task using its related project and task definition' - params do - # requires :id, type: Integer, desc: 'The project id to locate' - # requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to update in this project' - optional :trigger, type: String, desc: 'New status' - optional :include_in_portfolio, type: Boolean, desc: 'Indicate if this task should be in the portfolio' - optional :grade, type: Integer, desc: 'Grade value if task is a graded task (required if task definition is a graded task)' - optional :quality_pts, type: Integer, desc: 'Quality points value if task has quality assessment' - end - put '/projects/:id/task_def_id/:task_definition_id' do - project = Project.find(params[:id]) - grade = params[:grade] - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - needs_upload_docs = !task_definition.upload_requirements.empty? - - # check the user can put this task - if authorise? current_user, project, :make_submission - task = project.task_for_task_definition(task_definition) - - # if trigger supplied... - unless params[:trigger].nil? - # Check if they should be using portfolio_evidence api - if needs_upload_docs && params[:trigger] == 'ready_to_mark' - error!({ error: 'Cannot set this task status to ready to mark without uploading documents.' }, 403) - end - - if task.group_task? && !task.group - error!({ error: "This task requires a group. Ensure you are in a group for the unit's #{task.task_definition.group_set.name}" }, 403) - end - - logger.info "#{current_user.username} assessing task #{task.id} to #{params[:trigger]}" - result = task.trigger_transition(trigger: params[:trigger], by_user: current_user, quality: params[:quality_pts]) - if result.nil? && task.task_definition.restrict_status_updates - error!({ error: 'This task can only be updated by your tutor.' }, 403) - end - end - - # if grade was supplied - unless grade.nil? - # try to grade the task - task.grade_task grade, self - end - - # if include in portfolio supplied - unless params[:include_in_portfolio].nil? - task.include_in_portfolio = params[:include_in_portfolio] - task.save - end - - TaskUpdateSerializer.new(task) - else - error!({ error: "Couldn't find Task with id=#{params[:id]}" }, 403) - end - end - - desc 'Get the submission details of a task, indicating if it has a pdf to view' - params do - requires :id, type: Integer, desc: 'The project id to locate' - requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to update in this project' - end - get '/projects/:id/task_def_id/:task_definition_id/submission_details' do - # Get the project and task_definition based on uploaded details. - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - # check the user can put this task - error!(error: 'You do not have permission to read submissions for this project.') unless authorise? current_user, project, :get_submission - - # ensure there can be a pdf... - needs_upload_docs = !task_definition.upload_requirements.empty? - - # check if we actually have this task... if not must be false. - if needs_upload_docs && project.has_task_for_task_definition?(task_definition) - task = project.task_for_task_definition(task_definition) - - # return the details as json - { - has_pdf: task.has_pdf, - submission_date: task.submission_date, - processing_pdf: task.processing_pdf? - } - else - { - has_pdf: false, - processing_pdf: false - } - end - end - - desc 'Get the files associated with a submission' - params do - requires :id, type: Integer, desc: 'The project id to locate' - requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to get the files from' - end - get '/projects/:id/task_def_id/:task_definition_id/submission_files' do - # Get the project and task_definition based on uploaded details. - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - - # check the user can put this task - error!(error: 'You do not have permission to read submissions for this project.') unless authorise? current_user, project, :get_submission - - # Get the actual task... - task = project.task_for_task_definition(task_definition) - - # Find the file - file_loc = FileHelper.zip_file_path_for_done_task(task) - - if file_loc.nil? - file_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' - else - header['Content-Disposition'] = "attachment; filename=#{project.student.username}-#{task.task_definition.abbreviation}.zip" - end - - # Set download headers... - content_type 'application/octet-stream' - env['api.format'] = :binary - - # Return the file data - File.read(file_loc) - end - end -end diff --git a/app/api/tasks_api.rb b/app/api/tasks_api.rb new file mode 100644 index 000000000..faf059310 --- /dev/null +++ b/app/api/tasks_api.rb @@ -0,0 +1,351 @@ +require 'grape' + +class TasksApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + # + # Tasks only used for the task summary stats view... + # + desc "Get all the current user's tasks" + params do + requires :unit_id, type: Integer, desc: 'Unit to fetch the task details for' + end + get '/tasks' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'You do not have permission to read these task details' }, 403) + end + + result = unit.student_tasks. + joins(:task_status). + joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id'). + joins('LEFT OUTER JOIN tutorials ON tutorial_enrolments.tutorial_id = tutorials.id AND (tutorials.tutorial_stream_id = task_definitions.tutorial_stream_id OR tutorials.tutorial_stream_id IS NULL)'). + select( + 'tasks.id', + 'task_statuses.id as status_id', + 'task_definition_id', + 'tutorials.id AS tutorial_id', + 'tutorials.tutorial_stream_id AS tutorial_stream_id' + ). + where('tasks.task_status_id > 1'). + map do |r| + { + id: r.id, + task_definition_id: r.task_definition_id, + status: TaskStatus.id_to_key(r.status_id), + tutorial_id: r.tutorial_id, + tutorial_stream_id: r.tutorial_stream_id + } + end + + present result, with: Grape::Presenters::Presenter + end + + desc 'Refresh the most frequently changed task details for a project - allowing easy refresh of student details' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task, or tasks to get' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition to get, when not provided all tasks are returned' + end + get '/projects/:project_id/refresh_tasks/:task_definition_id' do + project = Project.find(params[:project_id]) + + unless authorise? current_user, project, :get + error!({ error: 'You do not have permission to access this project' }, 403) + end + + base = project.tasks + + if params[:task_definition_id].present? + base = base.where('tasks.task_definition_id = :task_definition_id', task_definition_id: params[:task_definition_id]) + end + + result = base. + map do |task| + { + task_definition_id: task.task_definition_id, + status: TaskStatus.id_to_key(task.task_status_id), + due_date: task.due_date, + extensions: task.extensions + } + end + + if params[:task_definition_id].present? + result = result.first + end + + present result, with: Grape::Presenters::Presenter + end + + desc 'Get a similarity match for a given task' + get '/tasks/:id/similarity/:count' do + unless authenticated? + error!({ error: "Not authorised to download details for task '#{params[:id]}'" }, 401) + end + task = Task.find(params[:id]) + + unless authorise? current_user, task, :get_submission + error!({ error: "Not authorised to download details for task '#{params[:id]}'" }, 401) + end + + match = params[:count].to_i % task.similar_to_count + if match < 0 + error!({ error: 'Invalid match sequence, must be 0 or larger' }, 403) + end + + match_link = task.plagiarism_match_links.order('created_at DESC')[match] + return if match_link.nil? + + logger.debug "Plagiarism match link 1: #{match_link}" + other_match_link = match_link.other_party + logger.debug "Plagiarism match link 2: #{other_match_link}" + output = FileHelper.path_to_plagarism_html(match_link) + + if output.nil? || !File.exist?(output) + error!({ error: 'No files to download' }, 403) + end + + if authorise? current_user, match_link.task, :view_plagiarism + student_url = match_link.plagiarism_report_url + end + + student_hash = { + username: match_link.student.username, + email: match_link.student.email, + name: match_link.student.name, + tutor: match_link.tutor.name, + tutorial: match_link.tutorial, + html: File.read(output), + url: student_url, + pct: match_link.pct, + dismissed: match_link.dismissed + } + other_student_hash = { + username: nil, + email: nil, + name: nil, + tutor: match_link.other_tutor.name, + tutorial: match_link.other_tutorial, + html: nil, + url: nil, + pct: other_match_link.pct, + dismissed: other_match_link.dismissed + } + + # Check if returning both parties + authorised_to_view_both = authorise? current_user, other_match_link.task, :get_submission + if authorised_to_view_both + other_output = FileHelper.path_to_plagarism_html(other_match_link) + if authorise? current_user, other_match_link.task, :view_plagiarism + other_student_url = other_match_link.plagiarism_report_url + end + # Update other_student_hash to include details + other_student_hash[:username] = match_link.other_student.username + other_student_hash[:email] = match_link.other_student.email + other_student_hash[:name] = match_link.other_student.name + other_student_hash[:tutor] = match_link.other_tutor.name + other_student_hash[:tutorial] = match_link.other_tutorial + other_student_hash[:html] = File.read(other_output) + other_student_hash[:url] = other_student_url + other_student_hash[:pct] = other_match_link.pct + other_student_hash[:dismissed] = other_match_link.dismissed + end + + result = { + student: student_hash, + other_student: other_student_hash + } + + present result, with: Grape::Presenters::Presenter + end + + desc 'Dismiss a similarity match for a given task' + params do + requires :dismissed, type: Boolean, desc: 'Should this similarity be dismissed?' + requires :other, type: Boolean, desc: 'This tasks match or its reverse?' + end + put '/tasks/:id/similarity/:count' do + unless authenticated? + error!({ error: "Not authorised to access this task '#{params[:id]}'" }, 401) + end + task = Task.find(params[:id]) + + unless authorise? current_user, task, :delete_plagiarism + error!({ error: "Not authorised to remove similarity for task '#{params[:id]}'" }, 401) + end + + match = params[:count].to_i % task.similar_to_count + if match < 0 + error!({ error: 'Invalid match sequence, must be 0 or larger' }, 403) + end + + match_link = task.plagiarism_match_links.order('created_at DESC')[match] + return if match_link.nil? + + match_link = match_link.other_party if params[:other] + + logger.info "#{current_user.username} changing plagiarism: setting dismissed for #{task.task_definition.abbreviation} by #{task.student.username} to #{params[:dismissed]}" + + logger.debug " plagiarism match link 1: #{match_link}" + + match_link.dismissed = params[:dismissed] + match_link.save! + present match_link.dismissed, with: Grape::Presenters::Presenter + end + + desc 'Pin a task to the user\'s task inbox' + params do + requires :id, type: Integer, desc: 'The ID of the task to be pinned' + end + post '/tasks/:id/pin' do + task = Task.find(params[:id]) + + unless authorise? current_user, task.unit, :provide_feedback + error!({ error: 'Not authorised to pin task' }, 403) + end + + TaskPin.find_or_create_by(task: task, user: current_user) + + present true, Grape::Presenters::Presenter + end + + desc 'Unpin a task from the user\'s task inbox' + params do + requires :id, type: Integer, desc: 'The ID of the task to be unpinned' + end + delete '/tasks/:id/pin' do + TaskPin.find_by!(user: current_user, task_id: params[:id]).destroy + present true, Grape::Presenters::Presenter + end + + desc 'Update a task using its related project and task definition' + params do + # requires :id, type: Integer, desc: 'The project id to locate' + # requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to update in this project' + optional :trigger, type: String, desc: 'New status' + optional :include_in_portfolio, type: Boolean, desc: 'Indicate if this task should be in the portfolio' + optional :grade, type: Integer, desc: 'Grade value if task is a graded task (required if task definition is a graded task)' + optional :quality_pts, type: Integer, desc: 'Quality points value if task has quality assessment' + end + put '/projects/:id/task_def_id/:task_definition_id' do + project = Project.find(params[:id]) + grade = params[:grade] + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + needs_upload_docs = !task_definition.upload_requirements.empty? + + # check the user can put this task + if authorise? current_user, project, :make_submission + task = project.task_for_task_definition(task_definition) + + # if trigger supplied... + unless params[:trigger].nil? + # Check if they should be using portfolio_evidence api + if needs_upload_docs && params[:trigger] == 'ready_for_feedback' + error!({ error: 'Cannot set this task status to ready to mark without uploading documents.' }, 403) + end + + if task.group_task? && !task.group + error!({ error: "This task requires a group. Ensure you are in a group for the unit's #{task.task_definition.group_set.name}" }, 403) + end + + logger.info "#{current_user.username} assessing task #{task.id} to #{params[:trigger]}" + result = task.trigger_transition(trigger: params[:trigger], by_user: current_user, quality: params[:quality_pts]) + if result.nil? && task.task_definition.restrict_status_updates + error!({ error: 'This task can only be updated by your tutor.' }, 403) + end + end + + # if grade was supplied + unless grade.nil? + # try to grade the task + task.grade_task grade, self + end + + # if include in portfolio supplied + unless params[:include_in_portfolio].nil? + task.include_in_portfolio = params[:include_in_portfolio] + task.save + end + + present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true + else + error!({ error: "Couldn't find Task with id=#{params[:id]}" }, 403) + end + end + + desc 'Get the submission details of a task, indicating if it has a pdf to view' + params do + requires :id, type: Integer, desc: 'The project id to locate' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to update in this project' + end + get '/projects/:id/task_def_id/:task_definition_id/submission_details' do + # Get the project and task_definition based on uploaded details. + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + # check the user can put this task + error!(error: 'You do not have permission to read submissions for this project.') unless authorise? current_user, project, :get_submission + + # ensure there can be a pdf... + needs_upload_docs = !task_definition.upload_requirements.empty? + + # check if we actually have this task... if not must be false. + if needs_upload_docs && project.has_task_for_task_definition?(task_definition) + task = project.task_for_task_definition(task_definition) + + # return the details as json + result = { + has_pdf: task.has_pdf, + submission_date: task.submission_date, + processing_pdf: task.processing_pdf? + } + else + result = { + has_pdf: false, + processing_pdf: false + } + end + + present result, with: Grape::Presenters::Presenter + end + + desc 'Get the files associated with a submission' + params do + requires :id, type: Integer, desc: 'The project id to locate' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to get the files from' + end + get '/projects/:id/task_def_id/:task_definition_id/submission_files' do + # Get the project and task_definition based on uploaded details. + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + # check the user can put this task + error!(error: 'You do not have permission to read submissions for this project.') unless authorise? current_user, project, :get_submission + + # Get the actual task... + task = project.task_for_task_definition(task_definition) + + # Find the file + file_loc = FileHelper.zip_file_path_for_done_task(task) + + if file_loc.nil? + file_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' + else + header['Content-Disposition'] = "attachment; filename=#{project.student.username}-#{task.task_definition.abbreviation}.zip" + end + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + + # Set download headers... + content_type 'application/octet-stream' + env['api.format'] = :binary + + # Return the file data + File.read(file_loc) + end +end diff --git a/app/api/teaching_periods_authenticated_api.rb b/app/api/teaching_periods_authenticated_api.rb new file mode 100644 index 000000000..7e61816bd --- /dev/null +++ b/app/api/teaching_periods_authenticated_api.rb @@ -0,0 +1,97 @@ +require 'grape' + +class TeachingPeriodsAuthenticatedApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add a Teaching Period' + params do + requires :teaching_period, type: Hash do + requires :period, type: String, desc: 'The name of the teaching period' + requires :year, type: Integer, desc: 'The year of the teaching period' + requires :start_date, type: Date, desc: 'The start date of the teaching period' + requires :end_date, type: Date, desc: 'The end date of the teaching period' + requires :active_until, type: Date, desc: 'The teaching period will be active until this date' + end + end + post '/teaching_periods' do + unless authorise? current_user, User, :handle_teaching_period + error!({ error: 'Not authorised to create a teaching period' }, 403) + end + teaching_period_parameters = ActionController::Parameters.new(params) + .require(:teaching_period) + .permit(:period, + :year, + :start_date, + :end_date, + :active_until) + + result = TeachingPeriod.create!(teaching_period_parameters) + + if result.nil? + error!({ error: 'No teaching period added.' }, 403) + else + present result, with: Entities::TeachingPeriodEntity + end + end + + desc 'Update teaching period' + params do + requires :id, type: Integer, desc: 'The teaching period id to update' + requires :teaching_period, type: Hash do + optional :period, type: String, desc: 'The name of the teaching period' + optional :year, type: Integer, desc: 'The year of the teaching period' + optional :start_date, type: Date, desc: 'The start date of the teaching period' + optional :end_date, type: Date, desc: 'The end date of the teaching period' + optional :active_until, type: Date, desc: 'The teaching period will be active until this date' + end + end + put '/teaching_periods/:id' do + teaching_period = TeachingPeriod.find(params[:id]) + unless authorise? current_user, User, :handle_teaching_period + error!({ error: 'Not authorised to update a teaching period' }, 403) + end + teaching_period_parameters = ActionController::Parameters.new(params) + .require(:teaching_period) + .permit(:period, + :year, + :start_date, + :end_date, + :active_until) + + teaching_period.update!(teaching_period_parameters) + teaching_period + end + + desc 'Delete a teaching period' + delete '/teaching_periods/:teaching_period_id' do + unless authorise? current_user, User, :handle_teaching_period + error!({ error: 'Not authorised to delete a teaching period' }, 403) + end + + teaching_period_id = params[:teaching_period_id] + TeachingPeriod.find(teaching_period_id).destroy + end + + desc 'Rollover a Teaching Period' + params do + requires :new_teaching_period_id, type: Integer, desc: 'The id of the rolled over teaching period' + optional :rollover_inactive, type: Boolean, default: false, desc: 'Are in active units included in the roll over' + optional :search_forward, type: Boolean, default: true, desc: 'When rolling over units, ensure that latest version is rolled over to new teaching period' + end + post '/teaching_periods/:existing_teaching_period_id/rollover' do + unless authorise? current_user, User, :rollover + error!({ error: 'Not authorised to rollover a teaching period' }, 403) + end + + new_teaching_period_id = params[:new_teaching_period_id] + new_teaching_period = TeachingPeriod.find(new_teaching_period_id) + + existing_teaching_period = TeachingPeriod.find(params[:existing_teaching_period_id]) + error!({error: existing_teaching_period.errors.full_messages.first}, 403) unless existing_teaching_period.rollover(new_teaching_period, params[:search_forward], params[:rollover_inactive]) + end +end diff --git a/app/api/teaching_periods_public_api.rb b/app/api/teaching_periods_public_api.rb new file mode 100644 index 000000000..e40446ea1 --- /dev/null +++ b/app/api/teaching_periods_public_api.rb @@ -0,0 +1,16 @@ +require 'grape' + +class TeachingPeriodsPublicApi < Grape::API + + desc "Get a teaching period's details" + get '/teaching_periods/:id' do + teaching_period = TeachingPeriod.find(params[:id]) + present teaching_period, with: Entities::TeachingPeriodEntity, full_details: true + end + + desc 'Get all the Teaching Periods' + get '/teaching_periods' do + teaching_periods = TeachingPeriod.all + present teaching_periods, with: Entities::TeachingPeriodEntity + end +end diff --git a/app/api/tutorial_enrolments_api.rb b/app/api/tutorial_enrolments_api.rb new file mode 100644 index 000000000..65a0e7d51 --- /dev/null +++ b/app/api/tutorial_enrolments_api.rb @@ -0,0 +1,56 @@ +require 'grape' + +class TutorialEnrolmentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Enrol project in a tutorial' + post '/units/:unit_id/tutorials/:tutorial_abbr/enrolments/:project_id' do + unit = Unit.find(params[:unit_id]) + project = unit.active_projects.find(params[:project_id]) + unless authorise? current_user, project, :change_tutorial, ->(role, perm_hash, other) { project.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to change tutorial' }, 403) + end + + tutorial = unit.tutorials.find_by(abbreviation: params[:tutorial_abbr]) + error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) unless tutorial.present? + + # If the tutorial has a capacity, and we are at that capacity, and the user does not have permissions to exceed capacity... + if tutorial.capacity > 0 && tutorial.tutorial_enrolments.count >= tutorial.capacity && ! authorise?(current_user, unit, :exceed_capacity) + error!({ error: "Tutorial #{params[:tutorial_abbr]} is full and cannot accept further student enrolments" }, 403) + end + + result = project.enrol_in(tutorial) + + if result.nil? + error!({ error: 'No enrolment added' }, 403) + else + result + end + + present :enrolments, project.tutorial_enrolments, with: Entities::TutorialEnrolmentEntity + end + + desc 'Delete an enrolment in the tutorial' + delete '/units/:unit_id/tutorials/:tutorial_abbr/enrolments/:project_id' do + unit = Unit.find(params[:unit_id]) + project = unit.projects.find(params[:project_id]) + unless authorise? current_user, project, :change_tutorial, ->(role, perm_hash, other) { project.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to change tutorials' }, 403) + end + + tutorial = unit.tutorials.find_by(abbreviation: params[:tutorial_abbr]) + error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) unless tutorial.present? + + tutorial_enrolment = tutorial.tutorial_enrolments.find_by(project_id: params[:project_id]) + error!({ error: "Project not enrolled in the selected tutorial" }, 403) unless tutorial_enrolment.present? + tutorial_enrolment.destroy + + # present :enrolments, project.tutorial_enrolments, with: Entities::TutorialEnrolmentEntity + present true, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/tutorial_streams_api.rb b/app/api/tutorial_streams_api.rb new file mode 100644 index 000000000..3c06800a3 --- /dev/null +++ b/app/api/tutorial_streams_api.rb @@ -0,0 +1,59 @@ +require 'grape' + +class TutorialStreamsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Add a tutorial stream to the unit' + params do + requires :activity_type_abbr, type: String, desc: 'Abbreviation of the activity type' + end + post '/units/:unit_id/tutorial_streams' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :add_tutorial + error!({ error: 'Not authorised to add tutorial stream to this unit' }, 403) + end + + activity_type = ActivityType.find_by(abbreviation: params[:activity_type_abbr]) + institution_settings = Doubtfire::Application.config.institution_settings + + name,abbreviation = institution_settings.details_for_next_tutorial_stream(unit, activity_type) + + unit.add_tutorial_stream(name, abbreviation, activity_type) + end + + desc 'Update a tutorial stream in the unit' + params do + optional :name, type: String, desc: 'The name of the tutorial stream' + optional :abbreviation, type: String, desc: 'The abbreviation for the tutorial stream' + optional :activity_type, type: String, desc: 'Abbreviation of the activity type' + end + put '/units/:unit_id/tutorial_streams/:tutorial_stream_abbr' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :add_tutorial + error!({ error: 'Not authorised to update tutorial stream in this unit' }, 403) + end + + tutorial_stream = unit.tutorial_streams.find_by!(abbreviation: params[:tutorial_stream_abbr]) + activity_type = ActivityType.find_by!(abbreviation: params[:activity_type]) if params[:activity_type].present? + present unit.update_tutorial_stream(tutorial_stream, params[:name], params[:abbreviation], activity_type), with: Entities::TutorialStreamEntity + end + + desc 'Delete a tutorial stream in the unit' + delete '/units/:unit_id/tutorial_streams/:tutorial_stream_abbr' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :add_tutorial + error!({ error: 'Not authorised to delete tutorial stream in this unit' }, 403) + end + + tutorial_stream = unit.tutorial_streams.find_by!(abbreviation: params[:tutorial_stream_abbr]) + tutorial_stream.destroy + error!({ error: tutorial_stream.errors.full_messages.last }, 403) unless tutorial_stream.destroyed? + tutorial_stream.destroyed? + end + +end diff --git a/app/api/tutorials.rb b/app/api/tutorials.rb deleted file mode 100644 index 48609c4c6..000000000 --- a/app/api/tutorials.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'grape' - -module Api - class Tutorials < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - desc 'Update a tutorial' - params do - requires :id, type: Integer, desc: 'The user id to update' - requires :tutorial, type: Hash do - optional :abbreviation, type: String, desc: 'The tutorials code' - optional :meeting_location, type: String, desc: 'The tutorials location' - optional :meeting_day, type: String, desc: 'Day of the tutorial' - optional :tutor_id, type: Integer, desc: 'Id of the tutor' - optional :meeting_time, type: String, desc: 'Time of the tutorial' - end - end - put '/tutorials/:id' do - tutorial = Tutorial.find(params[:id]) - tut_params = params[:tutorial] - # can only modify if current_user.id is same as :id provided - # (i.e., user wants to update their own data) or if update_user token - unless authorise? current_user, tutorial.unit, :add_tutorial - error!({ error: "Cannot update tutorial with id=#{params[:id]} - not authorised" }, 403) - end - - tutorial_parameters = ActionController::Parameters.new(params) - .require(:tutorial) - .permit( - :abbreviation, - :meeting_location, - :meeting_day, - :meeting_time - ) - - if tut_params[:tutor_id] - tutor = User.find(tut_params[:tutor_id]) - tutorial.assign_tutor(tutor) - end - - tutorial.update!(tutorial_parameters) - tutorial - end - - desc 'Create tutorial' - params do - requires :tutorial, type: Hash do - requires :unit_id, type: Integer, desc: 'Id of the unit' - requires :tutor_id, type: Integer, desc: 'Id of the tutor' - requires :abbreviation, type: String, desc: 'The tutorials code' - requires :meeting_location, type: String, desc: 'The tutorials location' - requires :meeting_day, type: String, desc: 'Day of the tutorial' - requires :meeting_time, type: String, desc: 'Time of the tutorial' - end - end - post '/tutorials' do - tut_params = params[:tutorial] - unit = Unit.find(tut_params[:unit_id]) - - unless authorise? current_user, unit, :add_tutorial - error!({ error: 'Not authorised to create new tutorials' }, 403) - end - - tutor = User.find(tut_params[:tutor_id]) - - tutorial = unit.add_tutorial(tut_params[:meeting_day], tut_params[:meeting_time], tut_params[:meeting_location], tutor, tut_params[:abbreviation]) - tutorial - end - - desc 'Delete a tutorial' - params do - requires :id, type: Integer, desc: 'The tutorial id to delete' - end - delete '/tutorials/:id' do - tutorial = Tutorial.find(params[:id]) - - unless authorise? current_user, tutorial.unit, :add_tutorial - error!({ error: 'Cannot delete tutorial - not authorised' }, 403) - end - - tutorial.destroy! - tutorial - end - end -end diff --git a/app/api/tutorials_api.rb b/app/api/tutorials_api.rb new file mode 100644 index 000000000..24b24106a --- /dev/null +++ b/app/api/tutorials_api.rb @@ -0,0 +1,105 @@ +require 'grape' + +class TutorialsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Update a tutorial' + params do + requires :id, type: Integer, desc: 'The user id to update' + requires :tutorial, type: Hash do + optional :abbreviation, type: String, desc: 'The tutorials code' + optional :meeting_location, type: String, desc: 'The tutorials location' + optional :meeting_day, type: String, desc: 'Day of the tutorial' + optional :tutor_id, type: Integer, desc: 'Id of the tutor' + optional :campus_id, type: Integer, desc: 'Id of the campus' + optional :capacity, type: Integer, desc: 'Capacity of the tutorial' + optional :meeting_time, type: String, desc: 'Time of the tutorial' + end + end + put '/tutorials/:id' do + tutorial = Tutorial.find(params[:id]) + tut_params = params[:tutorial] + # can only modify if current_user.id is same as :id provided + # (i.e., user wants to update their own data) or if update_user token + unless authorise? current_user, tutorial.unit, :add_tutorial + error!({ error: "Cannot update tutorial with id=#{params[:id]} - not authorised" }, 403) + end + + tutorial_parameters = ActionController::Parameters.new(params) + .require(:tutorial) + .permit( + :abbreviation, + :meeting_location, + :meeting_day, + :meeting_time, + :campus_id, + :capacity + ) + + if tut_params[:tutor_id] + tutor = User.find(tut_params[:tutor_id]) + tutorial.assign_tutor(tutor) + end + + if tutorial_parameters[:campus_id] == -1 + tutorial_parameters[:campus_id] = nil + end + + tutorial.update!(tutorial_parameters) + present tutorial, with: Entities::TutorialEntity + end + + desc 'Create tutorial' + params do + requires :tutorial, type: Hash do + requires :unit_id, type: Integer, desc: 'Id of the unit' + requires :tutor_id, type: Integer, desc: 'Id of the tutor' + requires :campus_id, type: Integer, desc: 'Id of the campus', allow_blank: false + requires :capacity, type: Integer, desc: 'Capacity of the tutorial', allow_blank: false + requires :abbreviation, type: String, desc: 'The tutorials code', allow_blank: false + requires :meeting_location, type: String, desc: 'The tutorials location', allow_blank: false + requires :meeting_day, type: String, desc: 'Day of the tutorial', allow_blank: false + requires :meeting_time, type: String, desc: 'Time of the tutorial', allow_blank: false + optional :tutorial_stream_abbr, type: String, desc: 'Abbreviation of the associated tutorial stream', allow_blank: false + end + end + post '/tutorials' do + tut_params = params[:tutorial] + unit = Unit.find(tut_params[:unit_id]) + + unless authorise? current_user, unit, :add_tutorial + error!({ error: 'Not authorised to create new tutorials' }, 403) + end + + tutor = User.find(tut_params[:tutor_id]) + campus = tut_params[:campus_id] == -1 ? nil : Campus.find(tut_params[:campus_id]) + + # Set Tutorial Stream if available + tutorial_stream_abbr = tut_params[:tutorial_stream_abbr] + tutorial_stream = unit.tutorial_streams.find_by!(abbreviation: tutorial_stream_abbr) unless tutorial_stream_abbr.nil? + + tutorial = unit.add_tutorial(tut_params[:meeting_day], tut_params[:meeting_time], tut_params[:meeting_location], tutor, campus, tut_params[:capacity], tut_params[:abbreviation], tutorial_stream) + + present tutorial, with: Entities::TutorialEntity + end + + desc 'Delete a tutorial' + params do + requires :id, type: Integer, desc: 'The tutorial id to delete' + end + delete '/tutorials/:id' do + tutorial = Tutorial.find(params[:id]) + + unless authorise? current_user, tutorial.unit, :add_tutorial + error!({ error: 'Cannot delete tutorial - not authorised' }, 403) + end + + tutorial.destroy! + present true, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/unit_roles.rb b/app/api/unit_roles.rb deleted file mode 100644 index d8f8327ee..000000000 --- a/app/api/unit_roles.rb +++ /dev/null @@ -1,103 +0,0 @@ -require 'grape' - -module Api - class UnitRoles < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - - before do - authenticated? - end - - desc 'Get unit roles for authenticated user' - params do - optional :unit_id, type: Integer, desc: 'Get user roles in indicated unit' - end - get '/unit_roles' do - return [] unless authorise? current_user, User, :act_tutor - - unit_roles = UnitRole.for_user current_user - - if params[:unit_id] - unit_roles = unit_roles.where(unit_id: params[:unit_id]) - end - - ActiveModel::ArraySerializer.new(unit_roles.joins(:unit).select('unit_roles.*', 'units.start_date'), each_serializer: UnitRoleSerializer) - end - - desc 'Delete a unit role' - delete '/unit_roles/:id' do - unit_role = UnitRole.find(params[:id]) - - unless (authorise? current_user, unit_role.unit, :employ_staff) || (authorise? current_user, User, :admin_units) - error!({ error: "Couldn't find UnitRole with id=#{params[:id]}" }, 403) - end - - unit_role.destroy - end - - desc "Get a unit_role's details" - get '/unit_roles/:id' do - unit_role = UnitRole.find(params[:id]) - - unless authorise? current_user, unit_role, :get - error!({ error: "Couldn't find UnitRole with id=#{params[:id]}" }, 403) - end - - unit_role - end - - desc 'Employ a user as a teaching role in a unit' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit to employ the staff for' - requires :user_id, type: Integer, desc: 'The id of the tutor' - requires :role, type: String, desc: 'The role for the staff member' - end - post '/unit_roles' do - unit = Unit.find(params[:unit_id]) - - unless (authorise? current_user, unit, :employ_staff) || (authorise? current_user, User, :admin_units) - error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) - end - user = User.find(params[:user_id]) - role = Role.with_name(params[:role]) - - if role.nil? - error!({ error: "Couldn't find Role with name=#{params[:role]}" }, 403) - end - - if role == Role.student - error!({ error: 'Enrol students as projects not unit roles' }, 403) - end - - unit.employ_staff(user, role) - end - - desc 'Update a role ' - params do - requires :unit_role, type: Hash do - requires :role_id, type: Integer, desc: 'The role to create with' - end - end - put '/unit_roles/:id' do - unit_role = UnitRole.find_by(id: params[:id]) - - unless (authorise? current_user, unit_role.unit, :employ_staff) || (authorise? current_user, User, :admin_units) - error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) - end - - unit_role_parameters = ActionController::Parameters.new(params) - .require(:unit_role) - .permit( - :role_id - ) - - if unit_role_parameters[:role_id] == Role.tutor.id && unit_role.role == Role.convenor && unit_role.unit.convenors.count == 1 - error!({ error: 'There must be at least one convenor for the unit' }, 403) - end - - unit_role.update!(unit_role_parameters) - unit_role - end - end -end diff --git a/app/api/unit_roles_api.rb b/app/api/unit_roles_api.rb new file mode 100644 index 000000000..11a750d1c --- /dev/null +++ b/app/api/unit_roles_api.rb @@ -0,0 +1,106 @@ +require 'grape' + +class UnitRolesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get unit roles for authenticated user' + params do + optional :active_only, type: Boolean, desc: 'Show only active roles' + end + get '/unit_roles' do + return [] unless authorise? current_user, User, :act_tutor + + result = UnitRole.includes(:unit).where(unit_roles: { user_id: current_user.id }) + + if params[:active_only] + result = result.where(unit_roles: { active: true }) + end + + present result, with: Entities::UnitRoleWithUnitEntity + end + + desc 'Delete a unit role' + delete '/unit_roles/:id' do + unit_role = UnitRole.find(params[:id]) + + unless (authorise? current_user, unit_role.unit, :employ_staff) || (authorise? current_user, User, :admin_units) + error!({ error: "Couldn't find UnitRole with id=#{params[:id]}" }, 403) + end + + unit_role.destroy! + end + + desc "Get a unit_role's details" + get '/unit_roles/:id' do + unit_role = UnitRole.find(params[:id]) + + unless authorise? current_user, unit_role, :get + error!({ error: "Couldn't find UnitRole with id=#{params[:id]}" }, 403) + end + + present unit_role, with: Entities::UnitRoleEntity + end + + desc 'Employ a user as a teaching role in a unit' + params do + requires :unit_id, type: Integer, desc: 'The id of the unit to employ the staff for' + requires :user_id, type: Integer, desc: 'The id of the tutor' + requires :role, type: String, desc: 'The role for the staff member' + end + post '/unit_roles' do + unit = Unit.find(params[:unit_id]) + + unless (authorise? current_user, unit, :employ_staff) || (authorise? current_user, User, :admin_units) + error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) + end + user = User.find(params[:user_id]) + role = Role.with_name(params[:role]) + + if role.nil? + error!({ error: "Couldn't find Role with name=#{params[:role]}" }, 403) + end + + if role == Role.student + error!({ error: 'Enrol students as projects not unit roles' }, 403) + end + + unless user.has_tutor_capability? + error!({ error: 'The selected user is not a tutor. Please update their system role before adding them' }, 403) + end + + result = unit.employ_staff(user, role) + present result, with: Entities::UnitRoleEntity + end + + desc 'Update a role' + params do + requires :unit_role, type: Hash do + requires :role_id, type: Integer, desc: 'The role to create with' + end + end + put '/unit_roles/:id' do + unit_role = UnitRole.find_by(id: params[:id]) + + unless (authorise? current_user, unit_role.unit, :employ_staff) || (authorise? current_user, User, :admin_units) + error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) + end + + unit_role_parameters = ActionController::Parameters.new(params) + .require(:unit_role) + .permit( + :role_id + ) + + if unit_role_parameters[:role_id] == Role.tutor.id && unit_role.role == Role.convenor && unit_role.unit.convenors.count == 1 + error!({ error: 'There must be at least one convenor for the unit' }, 403) + end + + unit_role.update!(unit_role_parameters) + present unit_role, with: Entities::UnitRoleEntity + end +end diff --git a/app/api/units.rb b/app/api/units.rb deleted file mode 100644 index 2d6ba748c..000000000 --- a/app/api/units.rb +++ /dev/null @@ -1,312 +0,0 @@ -require 'grape' -require 'unit_serializer' -require 'mime-check-helpers' - -module Api - class Units < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers MimeCheckHelpers - - before do - authenticated? - - if params[:unit] - for key in [ :start_date, :end_date ] do - if params[:unit][key].present? - date_val = DateTime.parse(params[:unit][key]) - params[:unit][key] = date_val - end - end - end - end - - desc 'Get units related to the current user for admin purposes' - params do - optional :include_in_active, type: Boolean, desc: 'Include units that are not active' - end - get '/units' do - unless authorise? current_user, User, :convene_units - error!({ error: 'Unable to list units' }, 403) - end - - # gets only the units the current user can "see" - units = Unit.for_user_admin current_user - - units = units.where('active = true') unless params[:include_in_active] - - ActiveModel::ArraySerializer.new(units, each_serializer: ShallowUnitSerializer) - end - - desc "Get a unit's details" - get '/units/:id' do - unit = Unit.find(params[:id]) - unless (authorise? current_user, unit, :get_unit) || (authorise? current_user, User, :admin_units) - error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) - end - # - # Unit uses user from thread to limit exposure - # - Thread.current[:user] = current_user - unit - end - - desc 'Update unit' - params do - requires :id, type: Integer, desc: 'The unit id to update' - requires :unit, type: Hash do - optional :name - optional :code - optional :description - optional :start_date - optional :end_date - optional :active - end - end - put '/units/:id' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :update - error!({ error: 'Not authorised to update a unit' }, 403) - end - unit_parameters = ActionController::Parameters.new(params) - .require(:unit) - .permit(:name, - :code, - :description, - :start_date, - :end_date, - :active) - - unit.update!(unit_parameters) - unit_parameters - end - - desc 'Create unit' - params do - requires :unit, type: Hash do - requires :name - requires :code - optional :description - optional :start_date - optional :end_date - end - end - post '/units' do - unless authorise? current_user, User, :create_unit - error!({ error: 'Not authorised to create a unit' }, 403) - end - - unit_parameters = ActionController::Parameters.new(params) - .require(:unit) - .permit( - :name, - :code, - :description, - :start_date, - :end_date - ) - - if unit_parameters[:description].nil? - unit_parameters[:description] = unit_parameters[:name] - end - if unit_parameters[:start_date].nil? - start_date = Date.parse('Monday') - delta = start_date > Date.today ? 0 : 7 - unit_parameters[:start_date] = start_date + delta - end - if unit_parameters[:end_date].nil? - unit_parameters[:end_date] = unit_parameters[:start_date] + 16.weeks - end - - unit = Unit.create!(unit_parameters) - - # Employ current user as convenor - unit.employ_staff(current_user, Role.convenor) - ShallowUnitSerializer.new(unit) - end - - desc 'Add a tutorial with the provided details to this unit' - params do - # day, time, location, tutor_username, abbrev - requires :tutorial, type: Hash do - requires :day - requires :time - requires :location - requires :tutor_username - requires :abbrev - end - end - post '/units/:id/tutorials' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :add_tutorial - error!({ error: 'Not authorised to create a tutorial' }, 403) - end - - new_tutorial = params[:tutorial] - tutor = User.find_by(username: new_tutorial[:tutor_username]) - if tutor.nil? - error!({ error: "Couldn't find User with username=#{new_tutorial[:tutor_username]}" }, 403) - end - - result = unit.add_tutorial(new_tutorial[:day], new_tutorial[:time], new_tutorial[:location], tutor, new_tutorial[:abbrev]) - if result.nil? - error!({ error: 'Tutor username invalid (not a tutor for this unit)' }, 403) - end - - result - end - - desc 'Download the tasks that are awaiting feedback for a unit' - get '/units/:id/feedback' do - unit = Unit.find(params[:id]) - - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to provide feedback for this unit' }, 403) - end - - tasks = unit.tasks_awaiting_feedback(current_user) - unit.tasks_as_hash(tasks) - end - - desc 'Download the tasks that should be listed under the task inbox' - get '/units/:id/tasks/inbox' do - unit = Unit.find(params[:id]) - - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to provide feedback for this unit' }, 403) - end - - tasks = unit.tasks_for_task_inbox(current_user) - unit.tasks_as_hash(tasks) - end - - desc 'Download the tasks that should be listed under the task inbox' - get '/units/:id/tasks/inbox' do - unit = Unit.find(params[:id]) - - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to provide feedback for this unit' }, 403) - end - - tasks = unit.tasks_for_task_inbox - unit.tasks_as_hash(tasks) - end - - desc 'Download the grades for a unit' - get '/units/:id/grades' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_grades - error!({ error: 'Not authorised to download grades for this unit' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-Students.csv " - env['api.format'] = :binary - - unit.student_grades_csv - end - - desc 'Upload CSV of all the students in a unit' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - end - post '/csv/units/:id' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :upload_csv - error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) - end - - ensure_csv!(params[:file][:tempfile]) - - # Actually import... - unit.import_users_from_csv(params[:file][:tempfile]) - end - - desc 'Upload CSV with the students to un-enrol from the unit' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - end - post '/csv/units/:id/withdraw' do - # check mime is correct before uploading - ensure_csv!(params[:file][:tempfile]) - - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :upload_csv - error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) - end - - # Actually withdraw... - unit.unenrol_users_from_csv(params[:file][:tempfile]) - end - - desc 'Download CSV of all students in this unit' - get '/csv/units/:id' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_unit_csv - error!({ error: "Not authorised to download CSV of students enrolled in #{unit.code}" }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-Students.csv " - env['api.format'] = :binary - unit.export_users_to_csv - end - - desc 'Download CSV of all student tasks in this unit' - get '/csv/units/:id/task_completion' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_unit_csv - error!({ error: "Not authorised to download CSV of student tasks in #{unit.code}" }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-TaskCompletion.csv " - env['api.format'] = :binary - unit.task_completion_csv - end - - desc 'Download the stats related to the number of students aiming for each grade' - get '/units/:id/stats/student_target_grade' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_stats - error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) - end - - unit.student_target_grade_stats - end - - desc 'Download stats related to the status of students with tasks' - get '/units/:id/stats/task_status_pct' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_stats - error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) - end - - unit.task_status_stats - end - - desc 'Download stats related to the number of completed tasks' - get '/units/:id/stats/task_completion_stats' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_stats - error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) - end - - unit.student_task_completion_stats - end - - desc 'Download stats related to the number of tasks assessed by each tutor' - get '/csv/units/:id/tutor_assessments' do - unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_stats - error!({ error: "Not authorised to download stats of statistics for #{unit.code}" }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-TutorAssessments.csv " - env['api.format'] = :binary - - unit.tutor_assessment_csv - end - end -end diff --git a/app/api/units_api.rb b/app/api/units_api.rb new file mode 100644 index 000000000..d5c64a56c --- /dev/null +++ b/app/api/units_api.rb @@ -0,0 +1,396 @@ +require 'grape' +require 'csv_helper' +require 'entities/unit_entity' + +class UnitsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers MimeCheckHelpers + helpers CsvHelper + + before do + authenticated? + + if params[:unit] + for key in [ :start_date, :end_date ] do + if params[:unit][key].present? + date_val = DateTime.parse(params[:unit][key]) + params[:unit][key] = date_val + end + end + end + end + + desc 'Get units related to the current user for admin purposes' + params do + optional :include_in_active, type: Boolean, desc: 'Include units that are not active' + end + get '/units' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Unable to list units' }, 403) + end + + # gets only the units the current user can "see" + units = Unit.for_user_admin current_user + + units = units.where('active = true') unless params[:include_in_active] + + present units, with: Entities::UnitEntity, user: current_user, summary_only: true + end + + desc "Get a unit's details" + get '/units/:id' do + unit = Unit.includes( + {unit_roles: [:role, :user]}, + {task_definitions: :tutorial_stream}, + :learning_outcomes, + {tutorial_streams: :activity_type}, + {tutorials: [:tutor, :tutorial_stream]}, + :tutorial_enrolments, + {staff: [:role, :user]}, + :group_sets, + :groups, + :group_memberships + ).find(params[:id]) + + unless (authorise? current_user, unit, :get_unit) || (authorise? current_user, User, :admin_units) + error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) + end + # + # Unit uses user from thread to limit exposure + # + present unit, with: Entities::UnitEntity, user: current_user, in_unit: true + end + + desc 'Update unit' + params do + requires :id, type: Integer, desc: 'The unit id to update' + requires :unit, type: Hash do + optional :name, type: String + optional :code, type: String + optional :description, type: String + optional :active, type: Boolean + optional :teaching_period_id, type: Integer + optional :start_date, type: Date + optional :end_date, type: Date + optional :main_convenor_id, type: Integer + optional :auto_apply_extension_before_deadline, type: Boolean, desc: 'Indicates if extensions before the deadline should be automatically applied' + optional :send_notifications, type: Boolean, desc: 'Indicates if emails should be sent on updates each week' + optional :enable_sync_timetable, type: Boolean, desc: 'Sync to timetable automatically if supported by deployment' + optional :enable_sync_enrolments, type: Boolean, desc: 'Sync student enrolments automatically if supported by deployment' + optional :draft_task_definition_id, type: Integer, desc: 'Indicates the ID of the task definition used as the "draft learning summary task"' + optional :allow_student_extension_requests, type: Boolean, desc: 'Can turn on/off student extension requests', default: true + optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials', default: true + optional :extension_weeks_on_resubmit_request, type: Integer, desc: 'Determines the number of weeks extension on a resubmit request', default: 1 + optional :overseer_image_id, type: Integer, desc: 'The id of the docker image used with ' + optional :assessment_enabled, type: Boolean + + mutually_exclusive :teaching_period_id,:start_date + all_or_none_of :start_date, :end_date + end + end + put '/units/:id' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update this unit' }, 403) + end + unit_parameters = ActionController::Parameters.new(params) + .require(:unit) + .permit(:name, + :code, + :description, + :start_date, + :end_date, + :teaching_period_id, + :active, + :main_convenor_id, + :auto_apply_extension_before_deadline, + :send_notifications, + :enable_sync_timetable, + :enable_sync_enrolments, + :draft_task_definition_id, + :allow_student_extension_requests, + :extension_weeks_on_resubmit_request, + :allow_student_change_tutorial, + :overseer_image_id, + :assessment_enabled + ) + + if unit.teaching_period_id.present? && unit_parameters.key?(:start_date) + unit.teaching_period = nil + end + + if unit_parameters[:draft_task_definition_id].present? + # Ensure the task definition belongs to unit + unless unit.task_definitions.exists?(unit_parameters[:draft_task_definition_id]) + error!({ error: 'Draft task definition ID does not belong to unit' }, 403) + end + + # Validate that the task only has 1 upload requirement and it is a document + task = TaskDefinition.find(unit_parameters[:draft_task_definition_id]) + if task.upload_requirements.length != 1 || task.upload_requirements.first['type'] != "document" + error!({ error: 'Task definition should contain only a single document upload' }, 403) + end + end + + unit.update!(unit_parameters) + present unit_parameters, with: Grape::Presenters::Presenter + end + + desc 'Create unit' + params do + requires :unit, type: Hash do + requires :name, type: String + requires :code, type: String + optional :description, type: String + optional :active, type: Boolean + optional :teaching_period_id, type: Integer + optional :start_date, type: Date + optional :end_date, type: Date + optional :main_convenor_id, type: Integer + optional :auto_apply_extension_before_deadline, type: Boolean, desc: 'Indicates if extensions before the deadline should be automatically applied', default: true + optional :send_notifications, type: Boolean, desc: 'Indicates if emails should be sent on updates each week', default: true + optional :enable_sync_timetable, type: Boolean, desc: 'Sync to timetable automatically if supported by deployment', default: true + optional :enable_sync_enrolments, type: Boolean, desc: 'Sync student enrolments automatically if supported by deployment', default: true + optional :allow_student_extension_requests, type: Boolean, desc: 'Can turn on/off student extension requests', default: true + optional :extension_weeks_on_resubmit_request, type: Integer, desc: 'Determines the number of weeks extension on a resubmit request', default: 1 + optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials', default: true + + mutually_exclusive :teaching_period_id,:start_date + mutually_exclusive :teaching_period_id,:end_date + end + end + post '/units' do + unless authorise? current_user, User, :create_unit + error!({ error: 'Not authorised to create a unit' }, 403) + end + + unit_parameters = ActionController::Parameters.new(params) + .require(:unit) + .permit( + :name, + :code, + :teaching_period_id, + :description, + :start_date, + :end_date, + :auto_apply_extension_before_deadline, + :send_notifications, + :enable_sync_timetable, + :enable_sync_enrolments, + :allow_student_extension_requests, + :extension_weeks_on_resubmit_request, + :allow_student_change_tutorial, + ) + + if unit_parameters[:description].nil? + unit_parameters[:description] = unit_parameters[:name] + end + + teaching_period_id = unit_parameters[:teaching_period_id] + if teaching_period_id.blank? + if unit_parameters[:start_date].nil? + start_date = Date.parse('Monday') + delta = start_date > Date.today ? 0 : 7 + unit_parameters[:start_date] = start_date + delta + end + + if unit_parameters[:end_date].nil? + unit_parameters[:end_date] = unit_parameters[:start_date] + 16.weeks + end + else + if unit_parameters[:start_date].present? || unit_parameters[:end_date].present? + error!({ error: 'Cannot specify dates as teaching period is selected' }, 403) + end + end + + unit = Unit.create!(unit_parameters) + + # Employ current user as convenor + unit.employ_staff(current_user, Role.convenor) + present unit, with: Entities::UnitEntity, user: current_user + end + + desc 'Rollover unit' + params do + optional :teaching_period_id + optional :start_date + optional :end_date + + exactly_one_of :teaching_period_id, :start_date + all_or_none_of :start_date, :end_date + end + post '/units/:id/rollover' do + unit = Unit.find(params[:id]) + + if !(authorise?( current_user, User, :rollover) || authorise?( current_user, unit, :rollover_unit)) + error!({ error: 'Not authorised to rollover a unit' }, 403) + end + + teaching_period_id = params[:teaching_period_id] + + if teaching_period_id.present? + tp = TeachingPeriod.find(teaching_period_id) + unit.rollover(tp, nil, nil) + else + unit.rollover(nil, params[:start_date], params[:end_date]) + end + + present unit, with: Entities::UnitEntity, user: current_user + end + + desc 'Download the tasks that are awaiting feedback for a unit' + get '/units/:id/feedback' do + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :provide_feedback + error!({ error: 'Not authorised to provide feedback for this unit' }, 403) + end + + tasks = unit.tasks_awaiting_feedback(current_user) + present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter + end + + desc 'Download the tasks that should be listed under the task inbox' + get '/units/:id/tasks/inbox' do + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :provide_feedback + error!({ error: 'Not authorised to provide feedback for this unit' }, 403) + end + + tasks = unit.tasks_for_task_inbox(current_user) + present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter + end + + desc 'Download the grades for a unit' + get '/units/:id/grades' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_grades + error!({ error: 'Not authorised to download grades for this unit' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-Students.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + + unit.student_grades_csv + end + + desc 'Upload CSV of all the students in a unit' + params do + requires :file, type: File, desc: 'CSV upload file.' + end + post '/csv/units/:id' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :upload_csv + error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) + end + + unless params[:file].present? + error!({ error: "No file uploaded" }, 403) + end + + ensure_csv!(params[:file][:tempfile]) + + # Actually import... + unit.import_users_from_csv(params[:file][:tempfile]) + end + + desc 'Upload CSV with the students to un-enrol from the unit' + params do + requires :file, type: File, desc: 'CSV upload file.' + end + post '/csv/units/:id/withdraw' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :upload_csv + error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) + end + + unless params[:file].present? + error!({ error: "No file uploaded" }, 403) + end + + path = params[:file][:tempfile].path + + ensure_csv! path + + # Actually withdraw... + response = unit.unenrol_users_from_csv(File.new(path)) + present response, with: Grape::Presenters::Presenter + end + + desc 'Download CSV of all students in this unit' + get '/csv/units/:id' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_unit_csv + error!({ error: "Not authorised to download CSV of students enrolled in #{unit.code}" }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-Students.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.export_users_to_csv + end + + desc 'Download CSV of all student tasks in this unit' + get '/csv/units/:id/task_completion' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_unit_csv + error!({ error: "Not authorised to download CSV of student tasks in #{unit.code}" }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-TaskCompletion.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + unit.task_completion_csv + end + + desc 'Download the stats related to the number of students aiming for each grade' + get '/units/:id/stats/student_target_grade' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) + end + + present unit.student_target_grade_stats, with: Grape::Presenters::Presenter + end + + desc 'Download stats related to the status of students with tasks' + get '/units/:id/stats/task_status_pct' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) + end + + present unit.task_status_stats, with: Grape::Presenters::Presenter + end + + desc 'Download stats related to the number of completed tasks' + get '/units/:id/stats/task_completion_stats' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) + end + + present unit.student_task_completion_stats, with: Grape::Presenters::Presenter + end + + desc 'Download stats related to the number of tasks assessed by each tutor' + get '/csv/units/:id/tutor_assessments' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of statistics for #{unit.code}" }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{unit.code}-TutorAssessments.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + + unit.tutor_assessment_csv + end +end diff --git a/app/api/users.rb b/app/api/users.rb deleted file mode 100644 index 978f77c77..000000000 --- a/app/api/users.rb +++ /dev/null @@ -1,220 +0,0 @@ -require 'grape' -require 'mime-check-helpers' - -module Api - class Users < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers MimeCheckHelpers - - before do - authenticated? - end - - desc 'Get the list of users' - get '/users' do - unless authorise? current_user, User, :list_users - error!({ error: 'Cannot list users - not authorised' }, 403) - end - - @users = User.all - end - - desc 'Get user' - get '/users/:id', requirements: { id: /[0-9]*/ } do - user = User.find(params[:id]) - unless (user.id == current_user.id) || (authorise? current_user, User, :admin_users) - error!({ error: "Cannot find User with id #{params[:id]}" }, 403) - end - user - end - - desc 'Get convenors' - get '/users/convenors' do - unless authorise? current_user, User, :convene_units - error!({ error: 'Cannot list convenors - not authorised' }, 403) - end - @user_roles = User.convenors - end - - desc 'Get tutors' - get '/users/tutors' do - unless authorise? current_user, User, :convene_units - error!({ error: 'Cannot list tutors - not authorised' }, 403) - end - @user_roles = User.tutors - end - - desc 'Update a user' - params do - requires :id, type: Integer, desc: 'The user id to update' - requires :user, type: Hash do - optional :first_name, type: String, desc: 'New first name for user' - optional :last_name, type: String, desc: 'New last name for user' - optional :email, type: String, desc: 'New email address for user' - optional :student_id, type: String, desc: 'New student_id for user' - optional :nickname, type: String, desc: 'New nickname for user' - optional :system_role, type: String, desc: 'New role for user [Admin, Convenor, Tutor, Student]' - optional :receive_task_notifications, type: Boolean, desc: 'Allow user to be sent task notifications' - optional :receive_portfolio_notifications, type: Boolean, desc: 'Allow user to be sent portfolio notifications' - optional :receive_feedback_notifications, type: Boolean, desc: 'Allow user to be sent feedback notifications' - optional :opt_in_to_research, type: Boolean, desc: 'Allow user to opt in to research conducted by Doubtfire' - optional :has_run_first_time_setup, type: Boolean, desc: 'Whether or not user has run first-time setup' - end - end - put '/users/:id' do - change_self = (params[:id] == current_user.id) - - # can only modify if current_user.id is same as :id provided - # (i.e., user wants to update their own data) or if update_user token - if change_self || (authorise? current_user, User, :update_user) - - user = User.find(params[:id]) - - user_parameters = ActionController::Parameters.new(params) - .require(:user) - .permit( - :first_name, - :last_name, - :email, - :student_id, - :nickname, - :receive_task_notifications, - :receive_portfolio_notifications, - :receive_feedback_notifications, - :opt_in_to_research, - :has_run_first_time_setup - ) - - user.role = Role.student if user.role.nil? - old_role = user.role - - # have to translate the system_role -> role - # note we only let user_parameters role if we're actually *changing* the role - # (i.e., not passing in the *same* role) - # - # You cannot change your own permissions - # - if !change_self && params[:user][:system_role] && old_role.id != Role.with_name(params[:user][:system_role]).id - user_parameters[:role] = params[:user][:system_role] - end - - # - # Only allow change of role if current user has permissions to demote/promote the user to the new role - # - if user_parameters[:role] - # work out if promoting or demoting - new_role = Role.with_name(user_parameters[:role]) - - if new_role.nil? - error!({ error: "No such role name #{user_parameters[:role]}" }, 403) - end - action = new_role.id > old_role.id ? :promote_user : :demote_user - - # current user not authorised to peform action with new role? - unless authorise? current_user, User, action, User.get_change_role_perm_fn, [ old_role.to_sym, new_role.to_sym ] - error!({ error: "Not authorised to #{action} user with id=#{params[:id]} to #{new_role.name}" }, 403) - end - # update :role to actual Role object rather than String type - user_parameters[:role] = new_role - end - - # Update changes made to user - user.update!(user_parameters) - user - - else - error!({ error: "Cannot modify user with id=#{params[:id]} - not authorised" }, 403) - end - end - - desc 'Create user' - params do - requires :user, type: Hash do - requires :first_name, type: String, desc: 'New first name for user' - requires :last_name, type: String, desc: 'New last name for user' - requires :email, type: String, desc: 'New email address for user' - optional :student_id, type: String, desc: 'New student_id for user' - requires :username, type: String, desc: 'New username for user' - requires :nickname, type: String, desc: 'New nickname for user' - requires :system_role, type: String, desc: 'New system role for user [Admin, Convenor, Tutor, Student]' - end - end - post '/users' do - # - # Only admins and convenors can create users - # - unless authorise? current_user, User, :create_user - error!({ error: 'Not authorised to create new users' }, 403) - end - - params[:user][:password] = 'password' - user_parameters = ActionController::Parameters.new(params) - .require(:user) - .permit( - :first_name, - :last_name, - :student_id, - :email, - :username, - :nickname, - :password - ) - - # have to translate the system_role -> role - user_parameters[:role] = params[:user][:system_role] - user_parameters[:role] = params[:user][:system_role] - - # - # Give new user their new role - # - new_role = Role.with_name(user_parameters[:role]) - if new_role.nil? - error!({ error: "No such role name #{user_parameters[:role]}" }, 403) - end - - # - # Check permission to create user with this role - # - unless authorise? current_user, User, :create_user, User.get_change_role_perm_fn, [ :nil, new_role.name.downcase.to_sym ] - error!({ error: "Not authorised to create new users with role #{new_role.name}" }, 403) - end - - # update :role to actual Role object rather than String type - user_parameters[:role] = new_role - - logger.info "#{current_user.username}: Created new user #{user_parameters[:username]} with role #{new_role.name}" - - user = User.create!(user_parameters) - user - end - - desc 'Upload CSV of users' - params do - requires :file, type: Rack::Multipart::UploadedFile, desc: 'CSV upload file.' - end - post '/csv/users' do - # check mime is correct before uploading - ensure_csv!(params[:file][:tempfile]) - - unless authorise? current_user, User, :upload_csv - error!({ error: 'Not authorised to upload CSV of users' }, 403) - end - - # Actually import... - User.import_from_csv(current_user, params[:file][:tempfile]) - end - - desc 'Download CSV of all users' - get '/csv/users' do - unless authorise? current_user, User, :download_system_csv - error!({ error: 'Not authorised to download CSV of all users' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = 'attachment; filename=doubtfire_users.csv ' - env['api.format'] = :binary - User.export_to_csv - end - end -end diff --git a/app/api/users_api.rb b/app/api/users_api.rb new file mode 100644 index 000000000..734c42ac8 --- /dev/null +++ b/app/api/users_api.rb @@ -0,0 +1,227 @@ +require 'grape' + +class UsersApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers MimeCheckHelpers + + before do + authenticated? + end + + desc 'Get the list of users' + get '/users' do + unless authorise? current_user, User, :list_users + error!({ error: 'Cannot list users - not authorised' }, 403) + end + + present User.all, with: Entities::UserEntity + end + + desc 'Get user' + get '/users/:id', requirements: { id: /[0-9]*/ } do + user = User.find(params[:id]) + unless (user.id == current_user.id) || (authorise? current_user, User, :admin_users) + error!({ error: "Cannot find User with id #{params[:id]}" }, 403) + end + + present user, with: Entities::UserEntity + end + + desc 'Get convenors' + get '/users/convenors' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Cannot list convenors - not authorised' }, 403) + end + + present User.convenors, with: Entities::UserEntity + end + + desc 'Get tutors' + get '/users/tutors' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Cannot list tutors - not authorised' }, 403) + end + + present User.tutors, with: Entities::UserEntity + end + + desc 'Update a user' + params do + requires :id, type: Integer, desc: 'The user id to update' + requires :user, type: Hash do + optional :first_name, type: String, desc: 'New first name for user' + optional :last_name, type: String, desc: 'New last name for user' + optional :email, type: String, desc: 'New email address for user' + optional :student_id, type: String, desc: 'New student_id for user' + optional :nickname, type: String, desc: 'New nickname for user' + optional :system_role, type: String, desc: 'New role for user [Admin, Convenor, Tutor, Student]' + optional :receive_task_notifications, type: Boolean, desc: 'Allow user to be sent task notifications' + optional :receive_portfolio_notifications, type: Boolean, desc: 'Allow user to be sent portfolio notifications' + optional :receive_feedback_notifications, type: Boolean, desc: 'Allow user to be sent feedback notifications' + optional :opt_in_to_research, type: Boolean, desc: 'Allow user to opt in to research conducted by Doubtfire' + optional :has_run_first_time_setup, type: Boolean, desc: 'Whether or not user has run first-time setup' + end + end + put '/users/:id' do + change_self = (params[:id] == current_user.id) + + params[:receive_portfolio_notifications] = true if params.key?(:receive_portfolio_notifications) && params[:receive_portfolio_notifications].nil? + params[:receive_portfolio_notifications] = true if params.key?(:receive_feedback_notifications) && params[:receive_feedback_notifications].nil? + params[:receive_portfolio_notifications] = true if params.key?(:receive_task_notifications) && params[:receive_task_notifications].nil? + + # can only modify if current_user.id is same as :id provided + # (i.e., user wants to update their own data) or if update_user token + if change_self || (authorise? current_user, User, :update_user) + + user = User.find(params[:id]) + + user_parameters = ActionController::Parameters.new(params) + .require(:user) + .permit( + :first_name, + :last_name, + :email, + :student_id, + :nickname, + :receive_task_notifications, + :receive_portfolio_notifications, + :receive_feedback_notifications, + :opt_in_to_research, + :has_run_first_time_setup + ) + + user.role = Role.student if user.role.nil? + old_role = user.role + + # have to translate the system_role -> role + # note we only let user_parameters role if we're actually *changing* the role + # (i.e., not passing in the *same* role) + # + # You cannot change your own permissions + # + if !change_self && params[:user][:system_role] && old_role.id != Role.with_name(params[:user][:system_role]).id + user_parameters[:role] = params[:user][:system_role] + end + + # + # Only allow change of role if current user has permissions to demote/promote the user to the new role + # + if user_parameters[:role] + # work out if promoting or demoting + new_role = Role.with_name(user_parameters[:role]) + + if new_role.nil? + error!({ error: "No such role name #{user_parameters[:role]}" }, 403) + end + action = new_role.id > old_role.id ? :promote_user : :demote_user + + # current user not authorised to peform action with new role? + unless authorise? current_user, User, action, User.get_change_role_perm_fn, [ old_role.to_sym, new_role.to_sym ] + error!({ error: "Not authorised to #{action} user with id=#{params[:id]} to #{new_role.name}" }, 403) + end + # update :role to actual Role object rather than String type + user_parameters[:role] = new_role + end + + # Update changes made to user + user.update!(user_parameters) + present user, with: Entities::UserEntity + else + error!({ error: "Cannot modify user with id=#{params[:id]} - not authorised" }, 403) + end + end + + desc 'Create user' + params do + requires :user, type: Hash do + requires :first_name, type: String, desc: 'New first name for user' + requires :last_name, type: String, desc: 'New last name for user' + requires :email, type: String, desc: 'New email address for user' + optional :student_id, type: String, desc: 'New student_id for user' + requires :username, type: String, desc: 'New username for user' + requires :nickname, type: String, desc: 'New nickname for user' + requires :system_role, type: String, desc: 'New system role for user [Admin, Convenor, Tutor, Student]' + end + end + post '/users' do + # + # Only admins and convenors can create users + # + unless authorise? current_user, User, :create_user + error!({ error: 'Not authorised to create new users' }, 403) + end + + user_parameters = ActionController::Parameters.new(params) + .require(:user) + .permit( + :first_name, + :last_name, + :student_id, + :email, + :username, + :nickname + ) + + # have to translate the system_role -> role + user_parameters[:role] = params[:user][:system_role] + + # + # Give new user their new role + # + new_role = Role.with_name(user_parameters[:role]) + if new_role.nil? + error!({ error: "No such role name #{user_parameters[:role]}" }, 403) + end + + # + # Check permission to create user with this role + # + unless authorise? current_user, User, :create_user, User.get_change_role_perm_fn, [ :nil, new_role.name.downcase.to_sym ] + error!({ error: "Not authorised to create new users with role #{new_role.name}" }, 403) + end + + # update :role to actual Role object rather than String type + user_parameters[:role] = new_role + + logger.info "#{current_user.username}: Created new user #{user_parameters[:username]} with role #{new_role.name}" + + user = User.create!(user_parameters) + present user, with: Entities::UserEntity + end + + desc 'Upload CSV of users' + params do + requires :file, type: File, desc: 'CSV upload file.' + end + post '/csv/users' do + unless authorise? current_user, User, :upload_csv + error!({ error: 'Not authorised to upload CSV of users' }, 403) + end + + unless params[:file].present? + error!({ error: "No file uploaded" }, 403) + end + + path = params[:file][:tempfile].path + + # check mime is correct before uploading + ensure_csv!(path) + + # Actually import... + User.import_from_csv(current_user, File.new(path)) + end + + desc 'Download CSV of all users' + get '/csv/users' do + unless authorise? current_user, User, :download_system_csv + error!({ error: 'Not authorised to download CSV of all users' }, 403) + end + + content_type 'application/octet-stream' + header['Content-Disposition'] = 'attachment; filename=doubtfire_users.csv' + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + User.export_to_csv + end +end diff --git a/app/api/webcal_api.rb b/app/api/webcal_api.rb new file mode 100644 index 000000000..0a9cc997b --- /dev/null +++ b/app/api/webcal_api.rb @@ -0,0 +1,125 @@ +require 'grape' +require 'icalendar' + +class WebcalApi < Grape::API + helpers AuthenticationHelpers + + helpers do + # + # Wraps the specified value (expected to be either `nil` or a `Webcal`) in a hash `{ enabled: true | false }` used + # to prevent the API returning `null`. + # + def present_webcal(webcal) + if webcal.present? + present webcal, with: Entities::WebcalEntity + else + response = { enabled: false } + present response, with: Grape::Presenters::Presenter + end + end + end + + # Declare content types + content_type :txt, 'text/calendar' + + before do + authenticated? + end + + desc 'Get webcal details of the authenticated user' + get '/webcal' do + present_webcal current_user.webcal + end + + desc 'Update webcal details of the authenticated user' + params do + requires :webcal, type: Hash do + optional :enabled, type: Boolean, desc: 'Is the webcal enabled?' + optional :should_change_guid, type: Boolean, desc: 'Should the GUID of the webcal be changed?' + optional :include_start_dates, type: Boolean, desc: 'Should events for start dates be included?' + optional :unit_exclusions, type: Array[Integer], desc: 'IDs of units that must be excluded from the webcal' + + # `all_or_none_of` is used here instead of 2 `requires` parameters to allow `reminder` to be set to `null`. + optional :reminder, type: Hash do + optional :time, type: Integer + optional :unit, type: String, values: Webcal.valid_time_units, desc: 'w: weeks, d: days, h: hours, m: minutes' + all_or_none_of :time, :unit + end + end + end + put '/webcal' do + webcal_params = params[:webcal] + + user = current_user + + cal = Webcal + .includes(:webcal_unit_exclusions) + .where(user_id: user.id) + .load + .first + + # Create or destroy the user's webcal, according to the `enabled` parameter. + if webcal_params.key?(:enabled) + if webcal_params[:enabled] and cal.nil? + cal = user.create_webcal(guid: SecureRandom.uuid) + elsif !webcal_params[:enabled] and cal.present? + cal.destroy + end + end + + if cal.nil? || cal.destroyed? + present_webcal nil + return + end + + webcal_update_params = {} + + # Change the GUID if requested. + if webcal_params.key?(:should_change_guid) + webcal_update_params[:guid] = SecureRandom.uuid + end + + # Change the reminder if requested. + if webcal_params.key?(:reminder) + if webcal_params[:reminder].nil? + webcal_update_params[:reminder_time] = webcal_update_params[:reminder_unit] = nil + else + webcal_update_params[:reminder_time] = webcal_params[:reminder][:time] + webcal_update_params[:reminder_unit] = webcal_params[:reminder][:unit] + end + end + + # Set any other properties that have to be updated verbatim. + webcal_update_params.merge! ActionController::Parameters.new(webcal_params).permit( + :include_start_dates, + :reminder_time, + :reminder_unit + ) + + # Update calendar. + cal.update! webcal_update_params + + # Update unit exclusions, if specified. + if webcal_params.key?(:unit_exclusions) + + # Delete existing exclusions. + cal.webcal_unit_exclusions.destroy_all + + # Add exclusions with valid unit IDs. + if webcal_params[:unit_exclusions].any? + cal.webcal_unit_exclusions.create( + Unit + .joins(:projects) + .where( + projects: { user_id: user.id }, + units: { id: webcal_params[:unit_exclusions], active: true } + ) + .pluck(:id) + .map { |i| { unit_id: i } } + ) + end + end + + present_webcal cal + end +end diff --git a/app/api/webcal_public_api.rb b/app/api/webcal_public_api.rb new file mode 100644 index 000000000..420358238 --- /dev/null +++ b/app/api/webcal_public_api.rb @@ -0,0 +1,22 @@ +require 'grape' +require 'icalendar' + +class WebcalPublicApi < Grape::API + + desc 'Serve webcal with the specified GUID' + params do + requires :guid, type: String, desc: 'The GUID of the webcal' + end + get '/webcal/:guid' do + + # Retrieve the specified webcal. + webcal = Webcal.find_by!(guid: params[:guid]) + + # Serve the iCalendar with the correct MIME type. + content_type 'text/calendar' + + # Seve ical. + webcal.to_ical.to_ical + end + +end diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 000000000..d19e9086a --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link grape_swagger_rails/application.css +//= link grape_swagger_rails/application.js diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 000000000..db6ff6e11 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end + end \ No newline at end of file diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..2fbbbca7d --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,19 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + protected + + def find_verified_user + if verified_user = env['warden'].user + verified_user + else + reject_unauthorized_connection + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ad91af316..b21e5fbef 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,4 +6,8 @@ class ApplicationController < ActionController::Base # rescue_from CanCan::AccessDenied do |exception| # redirect_to root_url, alert: exception.message # end + + def headers + request.headers + end end diff --git a/app/controllers/lecture_resource_downloads_controller.rb b/app/controllers/lecture_resource_downloads_controller.rb index a330a21df..bb4c8bc60 100644 --- a/app/controllers/lecture_resource_downloads_controller.rb +++ b/app/controllers/lecture_resource_downloads_controller.rb @@ -36,7 +36,7 @@ def index download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) - send_file output_zip.path, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/portfolio_downloads_controller.rb b/app/controllers/portfolio_downloads_controller.rb index 2478be969..f8ee4498e 100644 --- a/app/controllers/portfolio_downloads_controller.rb +++ b/app/controllers/portfolio_downloads_controller.rb @@ -41,15 +41,15 @@ def index # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" # env['api.format'] = :binary - logger.debug "Downloading portfolios from #{output_zip.path}" + logger.debug "Downloading portfolios from #{output_zip}" - # out = File.open(output_zip.path, "rb") + # out = File.open(output_zip, "rb") # output_zip.unlink # response_body = out.read - # File.binread output_zip.path + # File.binread output_zip # sending_file = true - send_file output_zip.path, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/task_downloads_controller.rb b/app/controllers/task_downloads_controller.rb index 8fdce8086..600389554 100644 --- a/app/controllers/task_downloads_controller.rb +++ b/app/controllers/task_downloads_controller.rb @@ -37,16 +37,15 @@ def index # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}" + download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-files" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" # env['api.format'] = :binary - logger.debug "Downloading task for #{td.abbreviation} from #{output_zip.path}" + logger.debug "Downloading task for #{td.abbreviation} from #{output_zip}" - send_file output_zip.path, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" - output_zip.close + send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/task_submission_pdfs_controller.rb b/app/controllers/task_submission_pdfs_controller.rb new file mode 100644 index 000000000..dbd20449e --- /dev/null +++ b/app/controllers/task_submission_pdfs_controller.rb @@ -0,0 +1,52 @@ +require 'grape' + +class TaskSubmissionPdfsController < ApplicationController + include AuthenticationHelpers + include AuthorisationHelpers + include LogHelper + + class MyException < RuntimeError + attr_reader :status + + def initialize(status) + @status = status + end + end + + def error!(message, status = options[:default_status], _headers = {}, _backtrace = []) + raise MyException.new(status), message + end + + # desc "Retrieve student PDFs for a unit" + def index + unless authenticated? + error!({ error: "Not authorised to download student PDFs for unit '#{params[:id]}'" }, 401) + end + + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :provide_feedback + error!({ error: "Not authorised to download student PDFs for unit '#{params[:id]}'" }, 401) + end + + td = unit.task_definitions.find(params[:task_def_id]) + + output_zip = unit.get_task_submissions_pdf_zip(current_user, td) + + error!({ error: 'No files to download' }, 403) if output_zip.nil? + + # Set download headers... + # content_type "application/octet-stream" + download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-pdfs" + download_id.gsub! /[\\\/]/, '-' + download_id = FileHelper.sanitized_filename(download_id) + # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" + # env['api.format'] = :binary + + logger.debug "Downloading task for #{td.abbreviation} from #{output_zip}" + + send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + rescue MyException => e + render json: e.message, status: e.status + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 63c80b4d0..5520a5e4a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,7 +8,9 @@ def application_reference_date end end + # Escape text for inclusion in Latex documents def lesc(text) - LatexToPdf.escape_latex(text) + # Convert to latex text, then use gsub to remove any characters that are not printable + raw(LatexToPdf.escape_latex(text).gsub(/[^[:print:]]/,'')) end end diff --git a/app/helpers/auth/auth_saml_helper.rb b/app/helpers/auth/auth_saml_helper.rb new file mode 100644 index 000000000..3d447eb64 --- /dev/null +++ b/app/helpers/auth/auth_saml_helper.rb @@ -0,0 +1,67 @@ +require 'json/jwt' +require 'onelogin/ruby-saml' + +module Auth::AuthSamlHelper + def auth_saml2 (respSAML) + response = OneLogin::RubySaml::Response.new(respSAML, allowed_clock_drift: 1.second, + settings: AuthenticationHelpers.saml_settings) + + # We validate the SAML Response and check if the user already exists in the system + return error!({ error: 'Invalid SAML response.' }, 401) unless response.is_valid? + + attributes = response.attributes + + login_id = response.name_id || response.nameid + email = login_id + + logger.info "Authenticate #{email} from #{request.ip}" + + # Lookup using login_id if it exists + # Lookup using email otherwise and set login_id + # Otherwise create new + user = User.find_by(login_id: login_id) || + User.find_by_username(email[/(.*)@/, 1]) || + User.find_by(email: email) || + User.find_or_create_by(login_id: login_id) do |new_user| + role_response = attributes.fetch(/role/) || attributes.fetch(/userRole/) + role = role_response.include?('Staff') ? Role.tutor.id : Role.student.id + first_name = (attributes.fetch(/givenname/) || attributes.fetch(/cn/)).capitalize + last_name = attributes.fetch(/surname/).capitalize + username = email.split('@').first + # Some institutions may provide givenname and surname, others + # may only provide common name which we will use as first name + new_user.first_name = first_name + new_user.last_name = last_name + new_user.email = email + new_user.username = username + new_user.nickname = first_name + new_user.role_id = role + end + + # Set login id + username if not yet specified + user.login_id = login_id if user.login_id.nil? + user.username = username if user.username.nil? + + # Try and save the user once authenticated if new + if user.new_record? + user.encrypted_password = BCrypt::Password.create(SecureRandom.hex(32)) + unless user.valid? + error!(error: 'There was an error creating your account in Doubtfire. ' \ + 'Please get in contact with your unit convenor or the ' \ + 'Doubtfire administrators.') + end + user.save + end + + # Generate a temporary auth_token for future requests + onetime_token = user.generate_temporary_authentication_token! + + logger.info "Redirecting #{user.username} from #{request.ip}" + + # Must redirect to the front-end after sign in + protocol = Rails.env.development? ? 'http' : 'https' + host = Rails.env.development? ? "#{protocol}://localhost:3000" : Doubtfire::Application.config.institution[:host] + host = "#{protocol}://#{host}" unless host.starts_with?('http') + redirect "#{host}/#sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + end +end diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb index f7f25ddf8..7ece78370 100644 --- a/app/helpers/authentication_helpers.rb +++ b/app/helpers/authentication_helpers.rb @@ -1,3 +1,5 @@ +require 'onelogin/ruby-saml' + # # The AuthenticationHelpers include functions to check if the user # is authenticated and to fetch the current user. @@ -5,9 +7,6 @@ # This is used by the grape api. # module AuthenticationHelpers - def warden - env['warden'] - end module_function @@ -16,27 +15,43 @@ def warden # Reads details from the params fetched from the caller context. # def authenticated? - user_by_token = User.find_by_auth_token(params[:auth_token]) if params[:auth_token] - # Check warden -- authenticate using DB or LDAP etc. - return true if warden.authenticated? + auth_param = headers['Auth-Token'] || headers['Auth_Token'] || headers['auth_token'] || params['auth_token'] || params['Auth_Token'] + user_param = headers['Username'] || params['username'] + + # Check for valid auth token and username in request header + user = current_user + + # Authenticate from header or params + if auth_param.present? && user_param.present? && user.present? + # Get the list of tokens for a user + token = user.token_for_text?(auth_param) + end + # Check user by token - if params[:auth_token] && user_by_token && user_by_token.auth_token_expiry - # Non-expired token - return true if user_by_token.auth_token_expiry > Time.zone.now - # Time out this token + if user.present? && token.present? + if token.auth_token_expiry > Time.zone.now + logger.info("Authenticated #{user.username} from #{request.ip}") + return true + end + + # Token is timed out - destroy it and throw error + token.destroy! error!({ error: 'Authentication token expired.' }, 419) else # Add random delay then fail - sleep((200 + rand(200)) / 1000.0) - error!({ error: 'Could not authenticate with token. Token invalid.' }, 419) + sleep(rand(200..399) / 1000.0) + error!({ error: 'Could not authenticate with token. Username or Token invalid.' }, 419) end end # - # Get the current user either from warden or from the token + # Get the current user either from warden or from the header # def current_user - warden.user || User.find_by_auth_token(params[:auth_token]) + username = headers['Username'] || params['username'] + Rails.cache.fetch("user/#{username}", expires_in: 1.hours) do + User.find_by_username(username) + end end # @@ -46,31 +61,70 @@ def current_user def add_auth_to(service) service.routes.each do |route| options = route.instance_variable_get('@options') - next if options[:params]['auth_token'] - options[:params]['auth_token'] = { + next if options[:params]['Auth_Token'] + + options[:params]['Username'] = { required: true, - type: 'String', - desc: 'Authentication token' + type: 'String', + in: 'header', + desc: 'Username' } + options[:params]['Auth_Token'] = { + required: true, + type: 'String', + in: 'header', + desc: 'Authentication token' + } + end + end + + # + # Returns the SAML2.0 settings object using information provided as env variables + # + def saml_settings + return unless saml_auth? + + metadata_url = Doubtfire::Application.config.saml[:SAML_metadata_url] || nil + + if metadata_url + idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new + settings = idp_metadata_parser.parse_remote(metadata_url) + else + settings = OneLogin::RubySaml::Settings.new + settings.idp_cert = Doubtfire::Application.config.saml[:idp_sso_cert] + settings.name_identifier_format = Doubtfire::Application.config.saml[:idp_name_identifier_format] end + settings.assertion_consumer_service_url = Doubtfire::Application.config.saml[:assertion_consumer_service_url] + settings.sp_entity_id = Doubtfire::Application.config.saml[:entity_id] + settings.idp_sso_target_url = Doubtfire::Application.config.saml[:idp_sso_target_url] + settings.idp_slo_target_url = Doubtfire::Application.config.saml[:idp_sso_target_url] + + settings + end + + # + # Returns true if using SAML2.0 auth strategy + # + def saml_auth? + Doubtfire::Application.config.auth_method == :saml end # - # Returns true iff using AAF devise auth strategy + # Returns true if using AAF devise auth strategy # def aaf_auth? Doubtfire::Application.config.auth_method == :aaf end # - # Returns true iff using LDAP devise auth strategy + # Returns true if using LDAP devise auth strategy # def ldap_auth? Doubtfire::Application.config.auth_method == :ldap end # - # Returns true iff using database devise auth strategy + # Returns true if using database devise auth strategy # def db_auth? Doubtfire::Application.config.auth_method == :database diff --git a/app/helpers/authorisation_helpers.rb b/app/helpers/authorisation_helpers.rb index eb76aaa54..d09cbf75a 100644 --- a/app/helpers/authorisation_helpers.rb +++ b/app/helpers/authorisation_helpers.rb @@ -6,6 +6,11 @@ def get_permission_hash(role, perm_hash, _other) # # Authorises if the user can perform an action on the object # + # user - who + # object - context, what are we asking for permissions from + # action - what action + # perm_get_fn - which method do we call to get the permission hash. Can be used to get different hashes in different contexts. This returns hash of actions permitted + # def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), other = nil) # Can pass in instance or class obj_class = object.class == Class ? object : object.class diff --git a/app/helpers/csv_helper.rb b/app/helpers/csv_helper.rb index 23770e8a4..ba339b499 100644 --- a/app/helpers/csv_helper.rb +++ b/app/helpers/csv_helper.rb @@ -24,5 +24,11 @@ def csv_date_to_date(date) Date.parse(date) end + def missing_headers(row, headers) + headers - row.to_hash.keys + end + + module_function :csv_date_to_date + module_function :missing_headers end diff --git a/app/helpers/db_helpers.rb b/app/helpers/db_helpers.rb index fba00a84e..ef233c7d8 100644 --- a/app/helpers/db_helpers.rb +++ b/app/helpers/db_helpers.rb @@ -1,7 +1,7 @@ module DbHelpers def db_concat(*args) env = ENV['RAILS_ENV'] || 'development' - adapter = ActiveRecord::Base.configurations[env]['adapter'].to_sym + adapter = ApplicationRecord.configurations.configs_for(env_name: env).first.configuration_hash[:adapter].to_sym args.map! { |arg| arg.class == Symbol ? arg.to_s : arg } case adapter diff --git a/app/helpers/doubtfire_logger.rb b/app/helpers/doubtfire_logger.rb deleted file mode 100644 index e6d9a90ae..000000000 --- a/app/helpers/doubtfire_logger.rb +++ /dev/null @@ -1,33 +0,0 @@ -class DoubtfireLogger < ActiveSupport::Logger - # By default, nil is provided - # - # Arguments match: - # 1. logdev - filename or IO object (STDOUT or STDERR) - # 2. shift_age - number of files to keep, or age (e.g., monthly) - # 3. shift_size - maximum log file size (only used when shift_age) - #     is a number - # - # Rails.logger initialises these as nil, so we will do the same - @@logger = DoubtfireLogger.new(Doubtfire::Application.config.paths['log'].first) - - # - # Singleton logger returned - # - def self.logger - @@logger - end - - # - # Override fatal and error to puts to the console - # as well as log using Rails - # - def fatal(msg) - puts msg - super(msg) - end - - def error(msg) - puts msg - super(msg) - end -end diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 306d332cc..8267a611a 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -11,30 +11,34 @@ module FileHelper # - file is passed the file uploaded to Doubtfire (a hash with all relevant data about the file) # def accept_file(file, name, kind) - logger.debug "FileHelper is accepting file: filename=#{file.filename}, name=#{name}, kind=#{kind}" - valid = true case kind when 'image' accept = ['image/png', 'image/gif', 'image/bmp', 'image/tiff', 'image/jpeg', 'image/x-ms-bmp'] when 'code' - accept = ['text/x-pascal', 'text/x-c', 'text/x-c++', 'text/plain', 'text/', 'application/javascript, text/html', - 'text/css', 'text/x-ruby', 'text/coffeescript', 'text/x-scss', 'application/json', 'text/xml', 'application/xml', - 'text/x-yaml', 'application/xml', 'text/x-typescript'] + accept = ['text/x-pascal', 'text/x-c', 'text/x-c++', 'text/plain', 'text/', 'application/javascript', 'text/html', + 'text/css', 'text/x-ruby', 'text/coffeescript', 'text/x-scss', 'application/json', 'text/xml', 'application/xml', + 'text/x-yaml', 'application/xml', 'text/x-typescript','text/x-vhdl','text/x-asm','text/x-jack','application/x-httpd-php', + 'application/tst','text/x-cmp','text/x-vm','application/x-sh','application/x-bat','application/dat'] when 'document' accept = [ # -- one day"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # --"application/msword", 'application/pdf' ] - valid = pdf_valid? file.tempfile.path + valid = pdf_valid? file["tempfile"].path + when 'audio' + accept = ['audio/', 'video/webm', 'application/ogg', 'application/octet-stream'] + when 'comment_attachment' + accept = ['audio/', 'video/webm', 'application/ogg', 'image/', 'application/pdf', 'application/octet-stream'] + when 'video' + accept = ['video/mp4'] else logger.error "Unknown type '#{kind}' provided for '#{name}'" return false end - # result is true when... - mime_in_list?(file.tempfile.path, accept) && valid + mime_in_list?(file["tempfile"].path, accept) && valid end # @@ -46,7 +50,7 @@ def sanitized_path(*paths) path_name.strip.tap do |name| # Finally, replace all non alphanumeric, underscore # or periods with underscore - name.gsub! /[^\w\-]/, '_' + name.gsub! /[^\w\-()]/, '_' end end @@ -77,6 +81,18 @@ def task_file_dir_for_unit(unit, create = true) dst end + def tmp_file_dir() + file_server = Doubtfire::Application.config.student_work_dir + dst = "#{file_server}/tmp/" # trust the server config and passed in type for paths + FileUtils.mkdir_p dst if !Dir.exist? dst + + dst + end + + def tmp_file(filename) + tmp_file_dir << sanitized_filename(filename) + end + def student_group_work_dir(type, group_submission, task = nil, create = false) return nil unless group_submission @@ -108,19 +124,23 @@ def student_group_work_dir(type, group_submission, task = nil, create = false) # type = [:new, :in_process, :done, :pdf, :plagarism] # def student_work_dir(type = nil, task = nil, create = true) - if task && task.group_task? + if task && task.group_task? && type != :comment dst = student_group_work_dir type, task.group_submission, task else file_server = Doubtfire::Application.config.student_work_dir dst = "#{file_server}/" # trust the server config and passed in type for paths if !(type.nil? || task.nil?) - if type == :pdf + if type == :discussion + dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s) << '/' + elsif type == :pdf dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s) << '/' elsif type == :done dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s) << '/' elsif type == :plagarism dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s) << '/' + elsif type == :comment + dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s) << '/' else # new and in_process -- just have task id # Add task id to dst if we want task dst << "#{type}/#{task.id}/" @@ -140,45 +160,63 @@ def student_work_dir(type = nil, task = nil, create = true) dst end - # - # Generates a path for storing student portfolios - # - def student_portfolio_dir(project, create = true) + def unit_dir(unit, create = true) + file_server = Doubtfire::Application.config.student_work_dir + dst = "#{file_server}/" # trust the server config and passed in type for paths + dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' + + FileUtils.mkdir_p dst if create && (!Dir.exist? dst) + + dst + end + + def unit_portfolio_dir(unit, create = true) file_server = Doubtfire::Application.config.student_work_dir dst = "#{file_server}/portfolio/" # trust the server config and passed in type for paths - dst << sanitized_path("#{project.unit.code}-#{project.unit.id}", project.student.username.to_s) + dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' # Create current dst directory should it not exist FileUtils.mkdir_p(dst) if create dst end - def compress_image(path) - return true if File.size?(path) < 1_000_000 + # + # Generates a path for storing student portfolios + # + def student_portfolio_dir(unit, username, create = true) + dst = unit_portfolio_dir(unit, create) + + dst << sanitized_path(username.to_s) - compress_folder = File.join(Dir.tmpdir, 'doubtfire', 'compress') + # Create current dst directory should it not exist + FileUtils.mkdir_p(dst) if create + dst + end - FileUtils.mkdir compress_folder unless File.directory? compress_folder + def student_portfolio_path(unit, username, create = true) + File.join(student_portfolio_dir(unit, username, create), FileHelper.sanitized_filename("#{username}-portfolio.pdf")) + end - tmp_file = File.join(compress_folder, "#{File.dirname(path).split(File::Separator).last}-file#{File.extname(path)}") - logger.debug "File helper has started compressing #{path} to #{tmp_file}..." + def comment_attachment_path(task_comment, attachment_extension) + "#{File.join( student_work_dir(:comment, task_comment.task), "#{task_comment.id.to_s}#{attachment_extension}")}" + end - begin - exec = "convert \ - \"#{path}\" \ - -resize 1024x1024 \ - \"#{tmp_file}\" >>/dev/null 2>>/dev/null" + def comment_prompt_path(task_comment, attachment_extension, count) + "#{File.join( student_work_dir(:discussion, task_comment.task), "#{task_comment.id.to_s}_#{count.to_s}#{attachment_extension}")}" + end - did_compress = system_try_within 40, 'compressing image using convert', exec + def comment_reply_prompt_path(discussion_comment, attachment_extension) + "#{File.join( student_work_dir(:discussion, discussion_comment.task), "#{discussion_comment.id.to_s}_reply#{attachment_extension}")}" + end - FileUtils.mv tmp_file, path if did_compress - ensure - FileUtils.rm tmp_file if File.exist? tmp_file - end + def compress_image_to_dest(source, dest, delete_frames = false) + exec = "convert -quiet \ + \"#{source}\" \ + #{ delete_frames ? '-delete 1--1' : ''} -strip -density 72 -quality 85% -resize 2048x2048\\> -resize 48x48\\< \ + \"#{dest}\" >>/dev/null 2>>/dev/null" - raise 'Failed to compress an image. Ensure all images are smaller than 1MB.' unless did_compress - true + did_compress = system_try_within 40, 'compressing image using convert', exec end def compress_pdf(path, max_size = 2_500_000) @@ -192,9 +230,8 @@ def compress_pdf(path, max_size = 2_500_000) FileUtils.mkdir_p(File.join(Dir.tmpdir, 'doubtfire', 'compress')) exec = "gs -sDEVICE=pdfwrite \ - -dCompatibilityLevel=1.3 \ -dDetectDuplicateImages=true \ - -dPDFSETTINGS=/screen \ + -dPDFSETTINGS=/printer \ -dNOPAUSE \ -dBATCH \ -dQUIET \ @@ -233,7 +270,7 @@ def compress_pdf(path, max_size = 2_500_000) # # Move files between stages - new -> in process -> done # - def move_files(from_path, to_path) + def move_files(from_path, to_path, retain_from = false) # move into the new dir - and mv files to the in_process_dir pwd = FileUtils.pwd begin @@ -243,7 +280,8 @@ def move_files(from_path, to_path) Dir.chdir(to_path) begin # remove from_path as files are now "in process" - FileUtils.rm_r(from_path) + # these can be retained when the old folder wants to be kept + FileUtils.rm_r(from_path) unless retain_from rescue logger.warn "failed to rm #{from_path}" end @@ -265,7 +303,7 @@ def pdf_valid?(filename) # Scan last 1024 bytes for the EOF mark return false unless File.exist? filename File.open(filename) do |f| - f.seek -1024, IO::SEEK_END + f.seek -4096, IO::SEEK_END unless f.size <= 4096 f.read.include? '%%EOF' end end @@ -347,6 +385,10 @@ def zip_file_path_for_done_task(task) zip_file = "#{student_work_dir(:done, task, false)[0..-2]}.zip" end + def zip_file_path_for_discussion_prompts(task) + zip_file = "#{student_work_dir(:discussion, task, false)[0..-2]}.zip" + end + # # Compress the done files for a student - includes cover page and work uploaded # @@ -383,7 +425,7 @@ def write_entries_to_zip(entries, disk_root_path, zip_root_path, path, zip) # puts "subdir: #{subdir}" write_entries_to_zip(subdir, disk_root_path, zip_root_path, file_path, zip) else - # puts "Adding file: #{disk_file_path} -- #{File.exists? disk_file_path}" + # puts "Adding file: #{disk_file_path} -- #{File.exist? disk_file_path}" zip.get_output_stream(zip_file_path) do |f| f.puts(File.open(disk_file_path, 'rb').read) end @@ -423,15 +465,46 @@ def ensure_utf8_code(output_filename, force_ascii) FileUtils.mv(tmp_filename, output_filename) end + def process_audio(input_path, output_path) + logger.info("Trying to process audio in FileHelper") + path = Doubtfire::Application.config.institution[:ffmpeg] + TimeoutHelper.system_try_within 20, "Failed to process audio submission - timeout", "#{path} -loglevel quiet -y -i #{input_path} -ac 1 -ar 16000 -sample_fmt s16 #{output_path}" + end + + def sorted_timestamp_entries_in_dir(path) + Dir.entries(path).reject{|entry| entry !~ /\d+/}.sort_by { |x| File.basename(x) }.reverse + end + + def latest_submission_timestamp_entry_in_dir(path) + sorted_timestamp_entries_in_dir(path)[0] + end + + def task_submission_identifier_path(type, task) + file_server = Doubtfire::Application.config.student_work_dir + "#{file_server}/submission_history/#{sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s)}" + end + + def task_submission_identifier_path_with_timestamp(type, task, timestamp) + "#{task_submission_identifier_path(type, task)}/#{timestamp.to_s}" + end + # Export functions as module functions module_function :accept_file module_function :sanitized_path module_function :sanitized_filename module_function :task_file_dir_for_unit + module_function :tmp_file_dir + module_function :tmp_file module_function :student_group_work_dir module_function :student_work_dir + module_function :unit_dir + module_function :unit_portfolio_dir module_function :student_portfolio_dir - module_function :compress_image + module_function :student_portfolio_path + module_function :comment_attachment_path + module_function :comment_prompt_path + module_function :comment_reply_prompt_path + module_function :compress_image_to_dest module_function :compress_pdf module_function :move_files module_function :pdf_valid? @@ -443,9 +516,15 @@ def ensure_utf8_code(output_filename, force_ascii) module_function :delete_group_submission module_function :zip_file_path_for_group_done_task module_function :zip_file_path_for_done_task + module_function :zip_file_path_for_discussion_prompts module_function :compress_done_files module_function :move_compressed_task_to_new module_function :recursively_add_dir_to_zip module_function :write_entries_to_zip module_function :ensure_utf8_code + module_function :process_audio + module_function :sorted_timestamp_entries_in_dir + module_function :latest_submission_timestamp_entry_in_dir + module_function :task_submission_identifier_path + module_function :task_submission_identifier_path_with_timestamp end diff --git a/app/helpers/grade_helper.rb b/app/helpers/grade_helper.rb new file mode 100644 index 000000000..1bbf1b62c --- /dev/null +++ b/app/helpers/grade_helper.rb @@ -0,0 +1,42 @@ +module GradeHelper + def grade_for value + case value + when 0 + 'Pass' + when 1 + 'Credit' + when 2 + 'Distinction' + when 3 + 'High Distinction' + when nil + nil + else + 'Fail' + end + end + + def short_grade_for value + case value + when 0 + 'P' + when 1 + 'C' + when 2 + 'D' + when 3 + 'HD' + when nil + nil + else + 'F' + end + end + + PASS_VALUE = 0 + HD_VALUE = 3 + RANGE = PASS_VALUE..HD_VALUE + + module_function :grade_for + module_function :short_grade_for +end diff --git a/app/helpers/log_helper.rb b/app/helpers/log_helper.rb index 7b0fd659e..e328347b6 100644 --- a/app/helpers/log_helper.rb +++ b/app/helpers/log_helper.rb @@ -1,5 +1,3 @@ -require 'doubtfire_logger' - # # A universal logger # @@ -8,7 +6,7 @@ module LogHelper # Logger function returns the singleton logger # def logger - DoubtfireLogger.logger + Doubtfire::Application.config.logger end # Export functions as module functions diff --git a/app/helpers/mime-check-helpers.rb b/app/helpers/mime_check_helpers.rb similarity index 57% rename from app/helpers/mime-check-helpers.rb rename to app/helpers/mime_check_helpers.rb index 302c97e44..439074c27 100644 --- a/app/helpers/mime-check-helpers.rb +++ b/app/helpers/mime_check_helpers.rb @@ -2,20 +2,38 @@ require 'filemagic' module MimeCheckHelpers + extend LogHelper + def mime_type(file_path) fm = FileMagic.new(FileMagic::MAGIC_MIME) fm.file(file_path) end + def excel_to_csv(file, extn) + ss = Roo::Spreadsheet.open(file, extension: extn) + + File.unlink(file) + File.write(file, ss.sheet(ss.sheets.first).to_csv) + end + def ensure_csv!(file_path) file_path = file_path.path if file_path.is_a?(Tempfile) type = mime_type(file_path) # check mime is correct before uploading - accept = ['text/', 'text/plain', 'text/csv'] + accept = ['text/', 'text/plain', 'text/csv', 'application/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] unless type.start_with?(*accept) error!({ error: "File given is not a csv file - detected #{type}" }, 403) end + + # Convert xls files to csv... + if type.start_with? 'application/vnd.ms-excel' + excel_to_csv file_path, :xls + elsif type.start_with? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + excel_to_csv file_path, :xlsx + else + FileHelper.ensure_utf8_code file_path, true + end end def mime_in_list?(file, type_list) @@ -27,7 +45,7 @@ def mime_in_list?(file, type_list) def check_mime_against_list!(file, expect, type_list) unless mime_in_list?(file, type_list) - error!({ error: "File given is not a #{expect} file - detected #{mime_type}" }, 403) + error!({ error: "File given is not a #{expect} file - detected #{mime_type(file)}" }, 403) end end diff --git a/app/helpers/timeout_helper.rb b/app/helpers/timeout_helper.rb index 7986be5ba..e620f3002 100644 --- a/app/helpers/timeout_helper.rb +++ b/app/helpers/timeout_helper.rb @@ -1,4 +1,3 @@ -require 'terminator' module TimeoutHelper extend LogHelper @@ -11,12 +10,10 @@ module TimeoutHelper # end # def try_within(sec, timeout_message = 'operation') - Terminator.terminate sec do - begin - yield - rescue - logger.error "Timeout when #{timeout_message} after #{sec}s" - end + begin + Timeout::timeout(sec) { yield } + rescue + logger.error "Timeout when #{timeout_message} after #{sec}s" end end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 610637728..02ada6a4d 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -15,7 +15,7 @@ def weekly_staff_summary(unit_role, summary_stats) @unit_role = unit_role @unit = summary_stats[:unit] @data = summary_stats[:staff][unit_role] - @convenor = @unit.main_convenor + @convenor = @unit.main_convenor_user @summary_stats = summary_stats email_with_name = %("#{@staff.name}" <#{@staff.email}>) @@ -29,10 +29,10 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) return nil if project.nil? add_general - + @student = project.student @project = project - @tutor = project.main_tutor + @tutor = project.main_convenor_user @summary_stats = summary_stats @did_revert_to_pass = did_revert_to_pass @@ -40,9 +40,9 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) @engagements_count = @engagements.count - @student_engagements = @engagements.select { |e| [TaskStatus.not_started.name, TaskStatus.need_help.name, TaskStatus.working_on_it.name, TaskStatus.ready_to_mark.name].include? e.engagement }.count + @student_engagements = @engagements.select { |e| [TaskStatus.not_started.name, TaskStatus.need_help.name, TaskStatus.working_on_it.name, TaskStatus.ready_for_feedback.name].include? e.engagement }.count - @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.do_not_resubmit.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count + @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count @task_states = project.tasks.joins(:task_status).select("count(tasks.id) as number, task_statuses.name as status").group("task_statuses.name") @@ -66,29 +66,29 @@ def top_task_desc(tt) end def were_was(num) - if num == 1 + if num == 1 "was" - else + else "were" end end def are_is(num) - if num == 1 + if num == 1 "is" - else + else "are" end end - + def this_these(num) - if num == 1 + if num == 1 "this" - else + else "these" end end - + helper_method :top_task_desc helper_method :were_was helper_method :are_is diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index eaa62e124..f1e36a540 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -13,8 +13,8 @@ def task_pdf_failed(project, tasks) @student = project.student @project = project @tasks = tasks.sort_by { |t| t.task_definition.abbreviation } - @tutor = project.main_tutor - @convenor = project.main_convenor + @tutor = project.main_convenor_user + @convenor = project.main_convenor_user email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) @@ -29,8 +29,8 @@ def task_pdf_ready_message(project, tasks) @student = project.student @project = project @tasks = tasks.sort_by { |t| t.task_definition.abbreviation } - @tutor = project.main_tutor - @convenor = project.main_convenor + @tutor = project.main_convenor_user + @convenor = project.main_convenor_user email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) @@ -45,7 +45,7 @@ def task_feedback_ready(project, tasks) @student = project.student @project = project @tasks = tasks.sort_by { |t| t.task_definition.abbreviation } - @tutor = project.main_tutor + @tutor = project.main_convenor_user @has_comments = !@tasks.select { |t| t.is_last_comment_by?(@tutor) }.empty? return nil if @tutor.nil? || @student.nil? @@ -62,7 +62,7 @@ def portfolio_ready(project) @student = project.student @project = project - @convenor = project.unit.convenors.first.user + @convenor = project.main_convenor_user email_with_name = %("#{@student.name}" <#{@student.email}>) convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) @@ -77,7 +77,7 @@ def portfolio_failed(project) @student = project.student @project = project - @convenor = project.unit.convenors.first.user + @convenor = project.main_convenor_user email_with_name = %("#{@student.name}" <#{@student.email}>) convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) diff --git a/app/models/activity_type.rb b/app/models/activity_type.rb new file mode 100644 index 000000000..58056e317 --- /dev/null +++ b/app/models/activity_type.rb @@ -0,0 +1,51 @@ +class ActivityType < ApplicationRecord + has_many :tutorial_streams + + # Callbacks - methods called are private + before_destroy :can_destroy? + + # Always add a unique index with uniqueness constraint + # This is to prevent new records from passing the validations when checked at the same time before being written + validates :name, presence: true, uniqueness: true + validates :abbreviation, presence: true, uniqueness: true + + after_destroy :invalidate_cache + after_save :invalidate_cache + + def self.find(id) + Rails.cache.fetch("activity_types/#{id}", expires_in: 12.hours) do + super + end + end + + def self.find_by(*args) + key = args.map { |arg| + if arg.instance_of? Hash + arg.map{|k,v| "#{k}=#{v}"}.join('/') + else + arg + end + }.join('/') + + Rails.cache.fetch("activity_types/#{key}", expires_in: 12.hours) do + super + end + end + + def self.find_by_abbr_or_name(data) + ActivityType.find_by(abbreviation: data) || ActivityType.find_by(name: data) + end + + private + def invalidate_cache + Rails.cache.delete("activity_types/#{id}") + Rails.cache.delete("activity_types/name=#{name}") + Rails.cache.delete("activity_types/abbreviation=#{abbreviation}") + end + + def can_destroy? + return true if tutorial_streams.count == 0 + errors.add :base, "Cannot delete activity type with associated tutorial_streams" + throw :abort + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..10a4cba84 --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/app/models/auth_token.rb b/app/models/auth_token.rb new file mode 100644 index 000000000..81b48a708 --- /dev/null +++ b/app/models/auth_token.rb @@ -0,0 +1,63 @@ +class AuthToken < ApplicationRecord + + belongs_to :user, optional: false + + encrypts :authentication_token + + validates :authentication_token, presence: true + validate :ensure_token_unique_for_user, on: :create + + + def self.generate(user, remember, expiry_time = Time.zone.now + 2.hours) + # Loop until new unique auth token is found + token = loop do + token = Devise.friendly_token + break token unless user.token_for_text?(token) + end + + # Create a new AuthToken with this value + result = AuthToken.new(user_id: user.id) + result.authentication_token = token + result.extend_token(remember, expiry_time, false) + result.save! + result + end + + # Destroy all old tokens + def self.destroy_old_tokens + AuthToken.where("auth_token_expiry < :now", now: Time.zone.now).destroy_all + end + + # + # Extends an existing auth_token if needed + # + def extend_token(remember, expiry_time = Time.zone.now + 2.hours, save = true) + # Extended expiry times only apply to students and convenors + if remember + student_expiry_time = Time.zone.now + 2.weeks + tutor_expiry_time = Time.zone.now + 1.week + role = user.role + expiry_time = + if role == Role.student || role == :student + student_expiry_time + elsif role == Role.tutor || role == :tutor + tutor_expiry_time + else + expiry_time + end + end + + self.auth_token_expiry = expiry_time + + if save + self.save + end + end + + def ensure_token_unique_for_user + if user.token_for_text?(authentication_token) + errors.add(:authentication_token, 'already exists for the selected user') + end + end + +end diff --git a/app/models/badge.rb b/app/models/badge.rb deleted file mode 100644 index ced19b4f6..000000000 --- a/app/models/badge.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Badge < ActiveRecord::Base - belongs_to :sub_task_definitions -end diff --git a/app/models/break.rb b/app/models/break.rb new file mode 100644 index 000000000..d87c1b576 --- /dev/null +++ b/app/models/break.rb @@ -0,0 +1,48 @@ +class Break < ApplicationRecord + belongs_to :teaching_period, optional: false + + validates :start_date, presence: true + validates :number_of_weeks, presence: true + validates :teaching_period_id, presence: true + + validate :ensure_start_date_is_within_teaching_period, :ensure_break_end_is_within_teaching_period, :ensure_break_is_not_colliding + + def ensure_start_date_is_within_teaching_period + if start_date < teaching_period.start_date + errors.add(:start_date, "should be after the Teaching Period start date") + end + end + + def ensure_break_end_is_within_teaching_period + if start_date + number_of_weeks.weeks > teaching_period.end_date + errors.add(:number_of_weeks, "is exceeding Teaching Period end date") + end + end + + def ensure_break_is_not_colliding + for break_in_teaching_period in teaching_period.breaks do + if break_in_teaching_period.id != id && break_in_teaching_period.end_date >= start_date && break_in_teaching_period.start_date <= end_date + errors.add(:base, "overlaps another break") + break + end + end + end + + def duration + number_of_weeks.weeks + end + + def first_monday + return start_date if start_date.wday == 1 + return start_date + 1.day if start_date.wday == 0 + return start_date + (8 - start_date.wday).days + end + + def monday_after_break + first_monday + number_of_weeks.weeks + end + + def end_date + start_date + duration + end +end diff --git a/app/models/campus.rb b/app/models/campus.rb new file mode 100644 index 000000000..81b371d23 --- /dev/null +++ b/app/models/campus.rb @@ -0,0 +1,58 @@ +class Campus < ApplicationRecord + # Relationships + has_many :tutorials + has_many :projects + + # Callbacks - methods called are private + before_destroy :can_destroy? + + # Always add a unique index with uniqueness constraint + # This is to prevent new records from passing the validations when checked at the same time before being written + validates :name, presence: true, uniqueness: true + validates :mode, presence: true + validates :abbreviation, presence: true, uniqueness: true + + validates_inclusion_of :active, :in => [true, false] + + after_destroy :invalidate_cache + after_save :invalidate_cache + + enum mode: { timetable: 0, automatic: 1, manual: 2 } + + def self.find(id) + Rails.cache.fetch("campuses/#{id}", expires_in: 12.hours) do + super + end + end + + def self.find_by(*args) + key = args.map { |arg| + if arg.instance_of? Hash + arg.map{|k,v| "#{k}=#{v}"}.join('/') + else + arg + end + }.join('/') + + Rails.cache.fetch("campuses/#{key}", expires_in: 12.hours) do + super + end + end + + def self.find_by_abbr_or_name(data) + Campus.find_by(abbreviation: data) || Campus.find_by(name: data) + end + + private + def invalidate_cache + Rails.cache.delete("campuses/#{id}") + Rails.cache.delete("campuses/name=#{name}") + Rails.cache.delete("campuses/abbreviation=#{abbreviation}") + end + + def can_destroy? + return true if projects.count == 0 and tutorials.count == 0 + errors.add :base, "Cannot delete campus with projects and tutorials" + throw :abort + end +end diff --git a/app/models/comments/assessment_comment.rb b/app/models/comments/assessment_comment.rb new file mode 100644 index 000000000..a6dea9e6c --- /dev/null +++ b/app/models/comments/assessment_comment.rb @@ -0,0 +1,15 @@ +class AssessmentComment < TaskComment + + belongs_to :overseer_assessment, optional: false + + before_create do + self.content_type = :assessment + end + + def serialize(user) + json = super(user) + json[:overseer_assessment_id] = self.overseer_assessment_id + json + end + +end diff --git a/app/models/comments/comments_read_receipts.rb b/app/models/comments/comments_read_receipts.rb new file mode 100644 index 000000000..ba51d0a69 --- /dev/null +++ b/app/models/comments/comments_read_receipts.rb @@ -0,0 +1,7 @@ +class CommentsReadReceipts < ApplicationRecord + validates :user, presence: true + validates :task_comment, presence: true + + belongs_to :task_comment, optional: false + belongs_to :user, optional: false +end diff --git a/app/models/comments/discussion_comment.rb b/app/models/comments/discussion_comment.rb new file mode 100644 index 000000000..628d80004 --- /dev/null +++ b/app/models/comments/discussion_comment.rb @@ -0,0 +1,75 @@ +class DiscussionComment < TaskComment + include FileHelper + + def status + return "not started" if not started and not completed + return "opened" if started and not completed + return "complete" + end + + def attachment_path(_count = number_of_prompts) + FileHelper.comment_prompt_path(self, ".wav", _count) + end + + def attachment_file_name(number) + "discussion-#{id}-#{number}-#{attachment_extension}" + end + + def reply_attachment_path + FileHelper.comment_reply_prompt_path(self, ".wav") + end + + def serialize(user) + json = super(user) + json[:status] = status + json[:time_discussion_completed] = time_discussion_completed + json[:time_discussion_started] = time_discussion_started + json[:number_of_prompts] = number_of_prompts + json + end + + def dueDate + created_at + 10.days + end + + def started + not self.time_discussion_started.nil? + end + + def completed + not self.time_discussion_completed.nil? + end + + def mark_discussion_started + self.time_discussion_started = Time.zone.now + self.save! + end + + def mark_discussion_completed + self.time_discussion_completed = Time.zone.now + self.save! + end + + def add_prompt(file_upload, _count) + temp = Tempfile.new(['discussion_comment', '.wav']) + return false unless process_audio(file_upload["tempfile"].path, temp.path) + save + logger.info("Saving audio prompt to #{attachment_path(_count)}") + FileUtils.mv temp.path, attachment_path(_count) + + file_upload["tempfile"].unlink + true + end + + def add_reply(reply_attachment) + temp = Tempfile.new(['discussion_comment_reply', '.wav']) + return false unless process_audio(reply_attachment["tempfile"].path, temp.path) + mark_discussion_completed + save + logger.info("Saving discussion comment reply to #{reply_attachment_path()}") + FileUtils.mv temp.path, reply_attachment_path + + reply_attachment["tempfile"].unlink + true + end +end diff --git a/app/models/comments/extension_comment.rb b/app/models/comments/extension_comment.rb new file mode 100644 index 000000000..c2f0dc973 --- /dev/null +++ b/app/models/comments/extension_comment.rb @@ -0,0 +1,59 @@ +class ExtensionComment < TaskComment + + belongs_to :assessor, class_name: 'User', optional: true + + def serialize(user) + json = super(user) + json[:granted] = extension_granted + json[:assessed] = date_extension_assessed.present? + json[:date_assessed] = date_extension_assessed + json[:weeks_requested] = extension_weeks + json[:extension_response] = extension_response + json[:task_status] = task.status + json + end + + def assessed? + self.date_extension_assessed.present? + end + + # Make sure we can access super's version of mark_as_read for assess extension + alias :super_mark_as_read :mark_as_read + + # Allow individual staff and the student to read this... but stop + # the main tutor reading without assessing. As only the main tutor + # propagates reads, this will work as required - other staff cant + # make it read for the main tutor. + def mark_as_read(user, unit = self.unit) + super if assessed? || user == project.student || user != recipient + end + + def assess_extension(user, granted, automatic = false) + if self.assessed? + self.errors[:extension] << 'has already been assessed' + return false + end + + self.assessor = user + self.date_extension_assessed = Time.zone.now + self.extension_granted = granted && self.task.can_apply_for_extension? + + if self.extension_granted + self.task.grant_extension(user, extension_weeks) + if automatic + self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" + else + self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" + end + elsif ! self.task.can_apply_for_extension? && granted + self.extension_response = "Extension cannot be granted as deadline has been reached" + errors[:extension] << 'cannot be granted as deadline has been reached' + else + self.extension_response = "Extension rejected" + end + + # Now make sure to read it by the main tutor - even if assessed by someone else + super_mark_as_read(project.tutor_for(task.task_definition)) + save! + end +end diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb new file mode 100644 index 000000000..1220ef28e --- /dev/null +++ b/app/models/comments/task_comment.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true +require 'tempfile' + +class TaskComment < ApplicationRecord + include MimeCheckHelpers + include TimeoutHelper + include FileHelper + include AuthorisationHelpers + + belongs_to :task, optional: false # Foreign key + belongs_to :user, optional: false + has_one :unit, through: :task + has_one :project, through: :task + + belongs_to :recipient, class_name: 'User', optional: false + + has_one :discussion_comment, class_name: 'DiscussionComment', required: false + + has_many :comments_read_receipts, class_name: 'CommentsReadReceipts', dependent: :destroy, inverse_of: :task_comment + + # Can optionally be a reply to a comment + belongs_to :task_comment, optional: true + + validates :task, presence: true + validates :user, presence: true + validates :recipient, presence: true + validates :comment, length: { minimum: 0, maximum: 4095, allow_blank: true } + validate :valid_reply_to?, on: :create + + # After create, mark as read by user creating + after_create do + mark_as_read(self.user) + end + + # Delete action - before dependent association + before_destroy :delete_associated_files + + def valid_reply_to? + if reply_to_id.present? + originalTaskComment = TaskComment.find(reply_to_id) + replyProject = originalTaskComment.project + errors.add(:task_comment, "Not a reply to a valid task comment") unless originalTaskComment.present? + errors.add(:task_comment, "Original comment is not in this task") unless task.all_comments.find(reply_to_id).present? + errors.add(:task_comment, "Not authorised to reply to comment") unless authorise?(user, originalTaskComment.project, :get) || (task.group_task? && task.group.role_for(user) != nil) + end + end + + def delete_associated_files + FileUtils.rm attachment_path if File.exist? attachment_path + end + + def serialize(user) + { + id: self.id, + comment: self.comment, + has_attachment: ["audio", "image", "pdf"].include?(self.content_type), + type: self.content_type || "text", + is_new: self.new_for?(user), + reply_to_id: self.reply_to_id, + author: { + id: self.user.id, + name: self.user.name, + email: self.user.email + }, + recipient: { + id: self.recipient.id, + name: self.recipient.name, + email: self.recipient.email + }, + created_at: self.created_at, + recipient_read_time: self.time_read_by(self.recipient), + } + end + + def create_comment_read_receipt_entry(user) + comment_read_receipt = CommentsReadReceipts.find_or_create_by(user: user, task_comment: self) + end + + def comment + return 'audio comment' if content_type == 'audio' + return 'image comment' if content_type == 'image' + return 'pdf document' if content_type == 'pdf' + return 'discussion comment' if content_type == 'discussion' + super + end + + def attachment_path + FileHelper.comment_attachment_path(self, attachment_extension) + end + + def attachment_file_name + "comment-#{id}#{attachment_extension}" + end + + def add_attachment(file_upload) + if content_type == 'audio' + # On upload all audio comments are converted to wav + temp = Tempfile.new(['comment', '.wav']) + return false unless process_audio(file_upload["tempfile"].path, temp.path) + self.attachment_extension = '.wav' + save + FileUtils.mv temp.path, attachment_path + elsif content_type == 'image' + self.attachment_extension = if mime_type(file_upload["tempfile"].path).starts_with?('image/gif') + '.gif' + else + '.jpg' + end + save + FileHelper.compress_image_to_dest(file_upload["tempfile"].path, attachment_path) + else + self.attachment_extension = '.pdf' + save + FileHelper.compress_pdf(file_upload["tempfile"].path) + FileUtils.mv file_upload["tempfile"].path, attachment_path + end + + file_upload["tempfile"].unlink + + true + end + + def attachment_mime_type + if attachment_extension == '.wav' + 'audio/wav; charset:binary' + else + mime_type(attachment_path) + end + end + + def remove_comment_read_entry(user) + CommentsReadReceipts.delete_all(user: user, task_comment: self) + end + + def mark_as_read(user, unit = self.unit) + return if read_by?(user) # avoid propagating if not needed + + if user == project.tutor_for(task.task_definition) + unit.staff.each do |staff_member| + create_comment_read_receipt_entry(staff_member.user) + end + else + create_comment_read_receipt_entry(user) + end + end + + def mark_as_unread(user) + remove_comment_read_entry(user) + end + + def new_for?(user) + ! read_by? user + end + + def read_by?(user) + CommentsReadReceipts.find_by(user: user, task_comment: self).present? + end + + def time_read_by(user) + read_reciept = CommentsReadReceipts.find_by(user: user, task_comment: self) + read_reciept&.created_at + end + +end diff --git a/app/models/comments/task_status_comment.rb b/app/models/comments/task_status_comment.rb new file mode 100644 index 000000000..f24c00cf6 --- /dev/null +++ b/app/models/comments/task_status_comment.rb @@ -0,0 +1,21 @@ +class TaskStatusComment < TaskComment + + belongs_to :task_status, optional: false + + before_create do + self.content_type = :status + end + + after_create do + mark_as_read(self.recipient) + end + + def serialize(user) + json = super(user) + json[:recipient_read_time] = nil + json[:date] = self.created_at + json[:status] = task_status.status_key + json + end + +end diff --git a/app/models/comments_read_receipts.rb b/app/models/comments_read_receipts.rb deleted file mode 100644 index 3988b2a9e..000000000 --- a/app/models/comments_read_receipts.rb +++ /dev/null @@ -1,7 +0,0 @@ -class CommentsReadReceipts < ActiveRecord::Base - validates :user, presence: true - validates :task_comment, presence: true - - belongs_to :task_comment - belongs_to :user -end diff --git a/app/models/group.rb b/app/models/group.rb index c7d2f1eae..bcaf07de3 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1,10 +1,10 @@ -class Group < ActiveRecord::Base +class Group < ApplicationRecord include LogHelper - belongs_to :group_set - belongs_to :tutorial + belongs_to :group_set, optional: false + belongs_to :tutorial, optional: false - has_many :group_memberships + has_many :group_memberships, dependent: :destroy has_many :group_submissions has_many :projects, -> { where('group_memberships.active = :value and projects.enrolled = true', value: true) }, through: :group_memberships has_many :past_projects, -> { where('group_memberships.active = :value', value: false) }, through: :group_memberships, source: 'project' @@ -12,18 +12,23 @@ class Group < ActiveRecord::Base has_one :tutor, through: :tutorial validates :name, presence: true, allow_nil: false - validates :number, presence: true, allow_nil: false validates :group_set, presence: true, allow_nil: false validates :tutorial, presence: true, allow_nil: false - validates_associated :group_memberships - validates :number, uniqueness: { scope: :group_set, - message: 'must be unique within the set of groups' } + validates :name, uniqueness: { scope: :group_set, message: 'must be unique within the set of groups' } validate :must_be_in_same_tutorial, if: :limit_members_to_tutorial? before_destroy :ensure_no_submissions + def active_group_members + group_memberships.where(active: true) + end + + def has_active_group_members? + active_group_members.present? + end + # # Permissions around group data # @@ -35,20 +40,33 @@ def self.permissions # What can tutors do with groups? tutor_role_permissions = [ :get_members, - :manage_group + :manage_group, + :lock_group, + :move_tutorial ] # What can convenors do with groups? convenor_role_permissions = [ :get_members, - :manage_group + :manage_group, + :lock_group, + :can_exceed_capacity, + :move_tutorial + ] + # What can admin do with groups? + admin_role_permissions = [ + :get_members, + :manage_group, + :lock_group, + :can_exceed_capacity, + :move_tutorial ] # What can nil users do with groups? nil_role_permissions = [ - ] # Return permissions hash { + admin: admin_role_permissions, convenor: convenor_role_permissions, tutor: tutor_role_permissions, student: student_role_permissions, @@ -59,14 +77,14 @@ def self.permissions def ensure_no_submissions return true if group_submissions.count.zero? errors[:base] << 'Cannot delete group while it has submissions.' - false + throw :abort end def specific_permission_hash(role, perm_hash, _other) result = perm_hash[role] unless perm_hash.nil? if result && role == :student - result << :manage_group if group_set.allow_students_to_manage_groups - end + result << :manage_group if (!locked && !group_set.locked && group_set.allow_students_to_manage_groups) + end result end @@ -83,11 +101,56 @@ def has_user(user) projects.where('user_id = :user_id', user_id: user.id).count == 1 end + def capacity + result = group_set.capacity + if result.present? + result += capacity_adjustment + end + result + end + + def student_count + group_memberships.joins(:project).where(active: true, 'projects.enrolled' => true).count + end + + def at_capacity? + capacity.present? && student_count >= capacity + end + + def beyond_capacity? + capacity.present? && student_count > capacity + end + + def switch_to_tutorial tutorial + return if tutorial_id == tutorial.id + + Group.transaction do + tutorial_id = tutorial.id + self.tutorial = tutorial + + if group_set.keep_groups_in_same_class && has_active_group_members? + projects.each do |proj| + # We need to remove members to break the circular dependency and switch tutorial + remove_member(proj) + + te = proj.enrol_in tutorial + unless te.valid? + raise "Unable to move group as #{proj.student.name} could not switch tutorial." + end + + add_member(proj) + end + end + self.save! + end + end + def add_member(project) gm = project.group_membership_for_groupset(group_set) if gm.nil? gm = GroupMembership.create(group: self, project: project) + group_memberships << gm else gm = GroupMembership.find(gm.id) gm.group = self @@ -174,6 +237,8 @@ def create_submission(submitter_task, notes, contributors) project = contrib[:project] task = project.matching_task submitter_task + next if task.task_submission_closed? + if contrib[:pct].to_i > 0 task.group_submission = gs task.contribution_pct = contrib[:pct] @@ -209,7 +274,7 @@ def must_be_in_same_tutorial # def all_members_in_tutorial? group_memberships.each do |member| - return false unless !member.active || member.in_group_tutorial?(tutorial) + return false if member.project.enrolled && member.active && ! member.in_group_tutorial?(tutorial) end true end diff --git a/app/models/group_membership.rb b/app/models/group_membership.rb index 8dd510558..0ed199fe9 100644 --- a/app/models/group_membership.rb +++ b/app/models/group_membership.rb @@ -1,26 +1,26 @@ # # Records which students are in this group... used to determine the related students on submission # -class GroupMembership < ActiveRecord::Base +class GroupMembership < ApplicationRecord include LogHelper - belongs_to :group - belongs_to :project + belongs_to :group, optional: false + belongs_to :project, optional: false has_one :group_set, through: :group validate :must_be_in_same_tutorial, if: :restricted_to_tutorial? def restricted_to_tutorial? - active && group_set.keep_groups_in_same_class + project.enrolled && active && group_set.keep_groups_in_same_class end def must_be_in_same_tutorial - if active && !in_group_tutorial?(group.tutorial) - errors.add(:group, "requires all students to be in the #{group.tutorial.abbreviation} tutorial") + if project.enrolled && active && !in_group_tutorial?(group.tutorial) + errors.add(:group, "requires all students to be in the #{group.tutorial.abbreviation} tutorial which is not the case for #{project.student.name}.") end end def in_group_tutorial?(tutorial) - project.tutorial == tutorial + project.enrolled_in? tutorial end end diff --git a/app/models/group_set.rb b/app/models/group_set.rb index 485386790..e7005214a 100644 --- a/app/models/group_set.rb +++ b/app/models/group_set.rb @@ -1,7 +1,14 @@ -class GroupSet < ActiveRecord::Base - belongs_to :unit +class GroupSet < ApplicationRecord + belongs_to :unit, optional: false + has_many :task_definitions has_many :groups, dependent: :destroy + validates :name, uniqueness: { + scope: :unit, + message: "should be unique within a unit" + } + validates :capacity, numericality: { greater_than_or_equal_to: 2 }, unless: -> { capacity.nil? } + validates_associated :groups validate :must_be_in_same_tutorial, if: :keep_groups_in_same_class @@ -41,7 +48,7 @@ def self.permissions def specific_permission_hash(role, perm_hash, _other) result = perm_hash[role] unless perm_hash.nil? - if result && role == :student + if result && role == :student && !locked result << :create_group if allow_students_to_create_groups result << :join_group if allow_students_to_manage_groups end diff --git a/app/models/group_submission.rb b/app/models/group_submission.rb index 69e385b3e..52f1b0fb7 100644 --- a/app/models/group_submission.rb +++ b/app/models/group_submission.rb @@ -1,14 +1,15 @@ # # Tracks each group's submissions. # -class GroupSubmission < ActiveRecord::Base +class GroupSubmission < ApplicationRecord include LogHelper - belongs_to :group - belongs_to :task_definition + belongs_to :group, optional: false + belongs_to :task_definition, optional: false + belongs_to :submitted_by_project, class_name: 'Project', foreign_key: 'submitted_by_project_id', optional: false + has_many :tasks, dependent: :nullify has_many :projects, through: :tasks - belongs_to :submitted_by_project, class_name: 'Project', foreign_key: 'submitted_by_project_id' # # Ensure file is also deleted @@ -20,7 +21,7 @@ class GroupSubmission < ActiveRecord::Base # also remove evidence from group members tasks.each do |t| - t.portfolio_evidence = null + t.portfolio_evidence_path = nil t.save end rescue => e @@ -30,7 +31,9 @@ class GroupSubmission < ActiveRecord::Base def propagate_transition(initial_task, trigger, by_user, quality) tasks.each do |task| + next if [TaskStatus.complete.id, TaskStatus.feedback_exceeded.id, TaskStatus.fail.id].include? task.task_status_id if task != initial_task + task.extensions = initial_task.extensions unless initial_task.extensions < task.extensions task.trigger_transition(trigger: trigger, by_user: by_user, group_transition: true, quality: quality) end end @@ -44,6 +47,12 @@ def propagate_grade(initial_task, new_grade, ui) end end + def propogate_alignments_from_submission(alignments) + tasks.each do |task| + task.create_alignments_from_submission(alignments) + end + end + def submitter_task result = tasks.where(project: submitted_by_project).first return result unless result.nil? @@ -51,5 +60,9 @@ def submitter_task tasks.first end + def submitted_by? project + project == submitted_by_project + end + delegate :processing_pdf?, to: :submitter_task end diff --git a/app/models/learning_outcome.rb b/app/models/learning_outcome.rb index a271c5f6c..6532fd56c 100644 --- a/app/models/learning_outcome.rb +++ b/app/models/learning_outcome.rb @@ -1,7 +1,7 @@ -class LearningOutcome < ActiveRecord::Base +class LearningOutcome < ApplicationRecord include ApplicationHelper - belongs_to :unit + belongs_to :unit, optional: false has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes has_many :related_task_definitions, -> { where('learning_outcome_task_links.task_id is NULL') }, through: :learning_outcome_task_links, source: :task_definition # only link staff relations diff --git a/app/models/learning_outcome_task_link.rb b/app/models/learning_outcome_task_link.rb index 676ad7a18..4747c732a 100644 --- a/app/models/learning_outcome_task_link.rb +++ b/app/models/learning_outcome_task_link.rb @@ -1,19 +1,21 @@ -class LearningOutcomeTaskLink < ActiveRecord::Base - belongs_to :task_definition - belongs_to :task - belongs_to :learning_outcome +class LearningOutcomeTaskLink < ApplicationRecord + default_scope { all } + + belongs_to :task_definition, optional: false + belongs_to :task, optional: true + belongs_to :learning_outcome, optional: false validates :task_definition, presence: true validates :learning_outcome, presence: true validate :ensure_relations_unique - validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 5 } + validates :rating, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 5 } def ensure_relations_unique return if learning_outcome.nil? || task_definition.nil? if id.nil? - related_links = LearningOutcomeTaskLink.where('task_definition_id = :task_definition_id AND learning_outcome_id = :learning_outcome_id', my_id: id, task_definition_id: task_definition.id, learning_outcome_id: learning_outcome.id) + related_links = LearningOutcomeTaskLink.where('task_definition_id = :task_definition_id AND learning_outcome_id = :learning_outcome_id', task_definition_id: task_definition.id, learning_outcome_id: learning_outcome.id) else related_links = LearningOutcomeTaskLink.where('id != :my_id AND task_definition_id = :task_definition_id AND learning_outcome_id = :learning_outcome_id', my_id: id, task_definition_id: task_definition.id, learning_outcome_id: learning_outcome.id) end @@ -25,6 +27,23 @@ def ensure_relations_unique end end + def duplicate_to(new_unit) + result = self.dup + + throw "Unable to duplicate project learning outcome task links in unit #{new_unit.code}" if task.present? + + ilo = new_unit.learning_outcomes.find_by(abbreviation: self.learning_outcome.abbreviation) + throw "Unable to find Learning Outcome #{self.learning_outcome.abbreviation} in unit #{new_unit.code}" if ilo.nil? + + task_def = new_unit.task_definitions.find_by(abbreviation: self.task_definition.abbreviation) + throw "Unable to find Task Definition #{self.task_definition.abbreviation} in unit #{new_unit.code}" if task_def.nil? + + result.learning_outcome = ilo + result.task_definition = task_def + result.task = nil + result.save + end + def self.export_task_alignment_to_csv(unit, source) CSV.generate do |row| row << %w(unit_code learning_outcome task_abbr rating description) diff --git a/app/models/login.rb b/app/models/login.rb index 5e461e554..728c38460 100644 --- a/app/models/login.rb +++ b/app/models/login.rb @@ -1,3 +1,3 @@ -class Login < ActiveRecord::Base +class Login < ApplicationRecord belongs_to :user end diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb new file mode 100644 index 000000000..b3fe62952 --- /dev/null +++ b/app/models/overseer_assessment.rb @@ -0,0 +1,268 @@ +class OverseerAssessment < ApplicationRecord + belongs_to :task, optional: false + + has_one :project, through: :task + has_many :assessment_comments, dependent: :destroy + + validates :status, presence: true + validates :task_id, presence: true + validates :submission_timestamp, presence: true + + validates_uniqueness_of :submission_timestamp, scope: :task_id + + enum status: { pre_queued: 0, queued: 1, queue_failed: 2, done: 3 } + + after_destroy :delete_associated_files + + # Creates an OverseerAssessment object for a new submission + def self.create_for(task) + # Create only if: + # unit's assessment is enabled && + # task's assessment is enabled && + # task definition has an assessment resources zip file && + # task has a student submission + + task_definition = task.task_definition + unit = task_definition.unit + + return nil unless unit.assessment_enabled + return nil unless task_definition.assessment_enabled + return nil unless task_definition.has_task_assessment_resources? + return nil unless task.has_new_files? || task.has_done_file? + + docker_image_name_tag = task_definition.docker_image_name_tag || unit.docker_image_name_tag + assessment_resources_path = task_definition.task_assessment_resources + + return nil if docker_image_name_tag.nil? || docker_image_name_tag.strip.empty? + + result = OverseerAssessment.create!( + task: task, + status: :pre_queued, + submission_timestamp: Time.now.utc.to_i + ) + + # Create the submission folder and give access + FileUtils.mkdir_p result.output_path + result.grant_access_to_submission + + result.copy_latest_files_to_submission + + result + end + + def has_submission_files? + File.exist? submission_zip_file_name + end + + def submission_zip_file_name + "#{output_path}/submission.zip" + end + + def grant_access_to_submission + # TODO: Use FACL instead in future. + `chmod o+w #{output_path}` + end + + def copy_latest_files_to_submission + zip_file_path = submission_zip_file_name + + if task.has_new_files? + puts "Copying new files to submission at: #{zip_file_path}" + # Generate a zip file for this particular submission with timestamp value and put it here + task.compress_new_to_done zip_file_path: zip_file_path, rm_task_dir: false + else + puts "Copying done file to submission at: #{zip_file_path}" + task.copy_done_to zip_file_path + end + end + + # Path to where the submission and output are stored - includes the submission when it is to be processed + def output_path + FileHelper.task_submission_identifier_path_with_timestamp(:done, task, submission_timestamp) + end + + def add_assessment_comment(text = 'Automated Assessment Started') + text.strip! + return nil if text.nil? || text.empty? + + tutor = project.tutor_for(task.task_definition) + + # Need to ensure all group members have a task... + task.ensured_group_submission if task.group_task? && task.group + + comment = AssessmentComment.create + comment.task = task + comment.user = tutor + comment.comment = text + comment.recipient = project.student + comment.overseer_assessment = self + comment.save! + + comment + end + + def update_assessment_comment(text) + text.strip! + return nil if text.nil? || text.empty? + + assessment_comment = assessment_comments.last + + # Don't add if there is already a task assessment comment for this task + if assessment_comment.present? + # In case the main tutor changes + assessment_comment.comment = text + assessment_comment.save! + + return assessment_comment + end + + puts "WARN: Unexpected need to create assessment comment for OverseerAssessment: #{self.id}" + add_assessment_comment text + end + + + def send_to_overseer() + return {error: "Your task is already queued for processing. Pleasse wait until you receive a response before queueing your task again."} if self.status == :queued + + #TODO: Check status and do not queue if already queued + puts "********* Sending #{self.id} to overseer" + + sm_instance = Doubtfire::Application.config.sm_instance + if sm_instance.nil? + puts "ERROR: Unable to get service manager to send message to overseer. Unable to send - OverseerAssessment #{id}" + return {error: "Automated feedback is not configured correctly. Please raise an issue with your administrator. ERR:O1" } + end + + unless has_submission_files? + puts "ERROR: Attempting to send submission to Overseer without associated submission files - OverseerAssessment #{id}" + return {error: "Your submission does not include any files to be processed." } + end + + # Proceed only if: + # unit's assessment is enabled && + # task's assessment is enabled && + # task definition has an assessment resources zip file && + # task has a student submission + + task_definition = task.task_definition + unit = task_definition.unit + + assessment_resources_path = task_definition.task_assessment_resources + + unless unit.assessment_enabled && + task_definition.assessment_enabled && + task_definition.has_task_assessment_resources? && + (task.has_new_files? || task.has_done_file?) + + puts "ERROR: Assessment is no longer configured for overseer assessment. Unable to send - OverseerAssessment #{id}" + return { error: "This assessment is no longer setup for automated feedback. Automated feedback is turned off at either the unit or task level, or the task does not have the scripts needed to automate assessment." } + end + + unless File.exist? submission_zip_file_name + puts "ERROR: Student submission history zip file doesn't exist #{submission_zip_file_name}. Unable to send - OverseerAssessment #{id}" + return {error: "We no longer have the files associated with this submission. Please test a later submission, or upload your work again." } + end + + docker_image_name_tag = task_definition.docker_image_name_tag || unit.docker_image_name_tag + if docker_image_name_tag.nil? || docker_image_name_tag.strip.empty? + puts "ERROR: No docker image name. Unable to send - OverseerAssessment #{id}" + return {error: "This task is not configured to use automated feedback. Please ask your tutor to check the configuration for the task for the associated Docker image."} + end + + puts "Sending OverseerAssessment #{id} to message queue" + + message = { + output_path: output_path, + docker_image_name_tag: docker_image_name_tag, + submission: submission_zip_file_name, + assessment: assessment_resources_path, + timestamp: submission_timestamp, + task_id: task.id, + overseer_assessment_id: self.id, + zip_file: 1 + } + + puts message.inspect + + begin + sm_instance.clients[:ontrack].publisher.connect_publisher + puts("Sending message to rabbitmq for Overseer Assessment #{id}") + sm_instance.clients[:ontrack].publisher.publish_message(message) + puts("Sent to rabbitmq for Overseer Assessment #{id}") + self.status = :queued + rescue RuntimeError => e + puts "ERROR: OverseerAssessment #{id} failed to send: #{e.inspect}" + self.status = :queue_failed + return {error: "We are unable to send your submission to the automated feedback service. Please try again later."} + ensure + puts "saving... #{self.status}" + save! + sm_instance.clients[:ontrack].publisher.disconnect_publisher + end + + puts "********* - end perform assessment" + if assessment_comments.count == 0 + result = add_assessment_comment() + else + result = assessment_comments.last + result.update created_at: Time.zone.now + result + end + + { + comment: result, + error: nil + } + end + + def update_from_output() + # Update the overseer assessment status + self.status = :done + + yaml_path = "#{output_path}/output.yaml" + + if File.exist? yaml_path + yaml_file = YAML.load_file(yaml_path).with_indifferent_access + + comment_txt = '' + if !yaml_file['build_message'].nil? && !yaml_file['build_message'].strip.empty? + comment_txt += yaml_file['build_message'] + end + if !yaml_file['run_message'].nil? && !yaml_file['run_message'].strip.empty? + comment_txt += "\n\n" unless comment_txt.empty? + comment_txt += yaml_file['run_message'] + end + + if comment_txt.present? + update_assessment_comment(comment_txt) + else + puts 'YAML file doesn\'t contain field `build_message` or `run_message`' + end + + new_status = nil + if yaml_file['new_status'].present? + new_status = TaskStatus.status_for_name(yaml_file['new_status']) + self.result_task_status = new_status ? new_status.status_key : task.status + else + puts 'YAML file doesn\'t contain field `new_status`' + self.result_task_status = task.status + end + + if task.ready_for_feedback? && new_status.present? + task.update task_status: new_status + end + else + puts "File #{yaml_path} doesn't exist" + self.result_task_status = task.status + end + + rescue StandardError => e + puts ERROR: e + ensure + self.save! + end + + def delete_associated_files + FileUtils.rm_rf output_path + end +end diff --git a/app/models/overseer_image.rb b/app/models/overseer_image.rb new file mode 100644 index 000000000..ce795fd8b --- /dev/null +++ b/app/models/overseer_image.rb @@ -0,0 +1,20 @@ +class OverseerImage < ApplicationRecord + # Callbacks - methods called are private + before_destroy :can_destroy? + + has_many :units + has_many :task_definitions + + # Always add a unique index with uniqueness constraint + # This is to prevent new records from passing the validations when checked at the same time before being written + validates :name, presence: true, uniqueness: true + validates :tag, presence: true, uniqueness: true + + private + + def can_destroy? + return true if units.count == 0 && task_definitions.count == 0 + errors.add :base, "Cannot delete overseer image with associated units and/or task definitions" + throw :abort + end +end diff --git a/app/models/plagiarism_match_link.rb b/app/models/plagiarism_match_link.rb index f8ad0c29a..79dc4f298 100644 --- a/app/models/plagiarism_match_link.rb +++ b/app/models/plagiarism_match_link.rb @@ -1,8 +1,8 @@ -class PlagiarismMatchLink < ActiveRecord::Base +class PlagiarismMatchLink < ApplicationRecord include LogHelper - belongs_to :task - belongs_to :other_task, class_name: 'Task' + belongs_to :task, optional: false + belongs_to :other_task, class_name: 'Task', optional: false # # Ensure file is also deleted @@ -24,21 +24,8 @@ class PlagiarismMatchLink < ActiveRecord::Base after_destroy do |match_link| match_link.other_party.destroy if match_link.other_party - match_link.task.recalculate_max_similar_pct end - # TODO: Remove once max_pct_similar is deleted - # # - # # Update task's cache of pct similar - # # - # after_save do | match_link | - # task = match_link.task - # if (not match_link.dismissed) && task.max_pct_similar < match_link.pct - # task.max_pct_similar = match_link.pct - # task.save - # end - # end - def other_party PlagiarismMatchLink.where(task_id: other_task.id, other_task_id: task.id).first end @@ -48,28 +35,22 @@ def other_student end def other_tutor - other_task.project.main_tutor + other_task.project.tutor_for(other_task.task_definition) end delegate :student, to: :task def tutor - task.project.main_tutor + task.project.tutor_for(task.task_definition) end def tutorial - if task.project.tutorial.nil? - 'None' - else - task.project.tutorial.abbreviation - end + tute = task.project.tutorial_for(task.task_definition) + tute.nil? ? 'None' : tute.abbreviation end def other_tutorial - if other_task.project.tutorial.nil? - 'None' - else - other_task.project.tutorial.abbreviation - end + tute = other_task.project.tutorial_for(other_task.task_definition) + tute.nil? ? 'None' : tute.abbreviation end end diff --git a/app/models/portfolio_evidence.rb b/app/models/portfolio_evidence.rb index 7e26e57d9..4babcdc52 100644 --- a/app/models/portfolio_evidence.rb +++ b/app/models/portfolio_evidence.rb @@ -18,29 +18,49 @@ def self.student_work_dir(type = nil, task = nil, create = true) FileHelper.student_work_dir(type, task, create) end + # Move all tasks to a folder with this process's id in "in_process" + def self.move_to_pid_folder + + # Report old running processes... + Dir.entries(student_work_dir(:in_process)).select {|entry| entry.start_with?("pid_")}.each do |entry| + puts "Existing process still running or not cleaned up - #{entry}" + end + + pid_folder = File.join(student_work_dir(:in_process), "pid_#{Process.pid}") + + # Move everything in "new" to "pid" folder but retain the old "new" folder + FileHelper.move_files(student_work_dir(:new), pid_folder, true) + pid_folder + end + # # Process enqueued pdfs in each folder of the :new directory # into PDF files # - def self.process_new_to_pdf + def self.process_new_to_pdf(my_source) done = {} errors = {} # For each folder in new (i.e., queued folders to process) that matches appropriate name - new_root_dir = Dir.entries(student_work_dir(:new)).select do |f| + new_root_dir = Dir.entries(my_source).select do |f| # rubocop:disable Style/NumericPredicate (f =~ /^\d+$/) == 0 # rubocop:enable Style/NumericPredicate end new_root_dir.each do |folder_id| - task = Task.find(folder_id) + begin + task = Task.find(folder_id) + rescue + logger.error("Failed to find task with id #{folder_id} during PDF generation") + next + end add_error = lambda do |message| logger.error "Failed to process folder_id = #{folder_id}. #{message}" if task - task.add_comment task.project.main_tutor, "**Automated Comment**: Something went wrong with your submission. Check the files and resubmit this task. #{message}" - task.trigger_transition trigger: 'fix', by_user: task.project.main_tutor + task.add_text_comment task.project.tutor_for(task.task_definition), "**Automated Comment**: Something went wrong with your submission. Check the files and resubmit this task. #{message}" + task.trigger_transition trigger: 'fix', by_user: task.project.tutor_for(task.task_definition) errors[task.project] = [] if errors[task.project].nil? errors[task.project] << task @@ -49,7 +69,7 @@ def self.process_new_to_pdf begin logger.info "creating pdf for task #{task.id}" - success = task.convert_submission_to_pdf + success = task.convert_submission_to_pdf(my_source) if success done[task.project] = [] if done[task.project].nil? @@ -62,13 +82,14 @@ def self.process_new_to_pdf end end - done.each do |project, tasks| - logger.info "checking email for project #{project.id}" - if project.student.receive_task_notifications - logger.info "emailing task notification to #{project.student.name}" - PortfolioEvidenceMailer.task_pdf_ready_message(project, tasks).deliver - end - end + # Remove email of task notification success - only email on fail + # done.each do |project, tasks| + # logger.info "checking email for project #{project.id}" + # if project.student.receive_task_notifications + # logger.info "emailing task notification to #{project.student.name}" + # PortfolioEvidenceMailer.task_pdf_ready_message(project, tasks).deliver + # end + # end errors.each do |project, tasks| logger.info "checking email for project #{project.id}" diff --git a/app/models/progress.rb b/app/models/progress.rb deleted file mode 100644 index 69863559e..000000000 --- a/app/models/progress.rb +++ /dev/null @@ -1,39 +0,0 @@ -class Progress - include Comparable - - PROGRESS_TYPES = [ - :ahead, - :on_track, - :behind, - :danger, - :doomed - ].freeze - - attr_reader :progress, :weight - - def <=>(other) - weight <=> other.weight - end - - def initialize(progress_sym) - @progress = progress_sym - @weight = case progress - when :doomed - 0 - when :danger - 1 - when :behind - 2 - when :on_track - 3 - when :ahead - 4 - else - -1 - end - end - - def self.types - PROGRESS_TYPES - end -end diff --git a/app/models/project.rb b/app/models/project.rb index 28a8fa47c..4453d427b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -4,34 +4,42 @@ def signif(signs) end end -class Fixnum +class Integer def signif(signs) - Float("%.#{signs}f" % self) + Float(self) end end -class Project < ActiveRecord::Base +class Project < ApplicationRecord include ApplicationHelper include LogHelper + include DbHelpers - belongs_to :unit - belongs_to :tutorial - belongs_to :user + belongs_to :unit, optional: false + belongs_to :user, optional: false + belongs_to :campus, optional: true # has_one :user, through: :student has_many :tasks, dependent: :destroy # Destroying a project will also nuke all of its tasks has_many :group_memberships, dependent: :destroy has_many :groups, -> { where('group_memberships.active = :value', value: true) }, through: :group_memberships - has_many :past_groups, -> { where('group_memberships.active = :value', value: false) }, through: :group_memberships, source: 'group' has_many :task_engagements, through: :tasks has_many :comments, through: :tasks + has_many :tutorial_enrolments, dependent: :destroy has_many :learning_outcome_task_links, through: :tasks - validate :must_be_in_group_tutorials + # Callbacks - methods called are private + before_destroy :can_destroy? + validates :grade_rationale, length: { maximum: 4095, allow_blank: true } + validate :tutorial_enrolment_same_campus, if: :will_save_change_to_enrolled? + + after_update :check_withdraw_from_groups, if: :saved_change_to_enrolled? + after_update :update_task_stats, if: :saved_change_to_target_grade? #TODO: consider making this an async task! + # # Permissions around project data # @@ -39,7 +47,6 @@ def self.permissions # What can students do with projects? student_role_permissions = [ :get, - :change_tutorial, :make_submission, :get_submission, :change @@ -52,15 +59,14 @@ def self.permissions :make_submission, :get_submission, :change, - :assess + :assess, + :change_campus ] # What can convenors do with projects? convenor_role_permissions = [ - ] # What can nil users do with projects? nil_role_permissions = [ - ] # Return permissions hash @@ -75,40 +81,73 @@ def role_for(user) user_role(user) end - scope :with_progress, lambda { |progress_types| - where(progress: progress_types) unless progress_types.blank? - } - + # Get all of the projects for the indicated user - with or without inactive units def self.for_user(user, include_inactive) - if include_inactive - where('projects.user_id = :user_id', user_id: user.id) - else - active_projects.where('projects.user_id = :user_id', user_id: user.id) + # Limit to enrolled units... for this user + result = where(enrolled: true).where('projects.user_id = :user_id', user_id: user.id) + + # Return the result if we include inactive units... + return result if include_inactive + + # Otherwise link in units and only get active units + result.joins(:unit).where('units.active = TRUE') + end + + # Used to adjust the change tutorial permission in units that do not + # allow students to change tutorials + def specific_permission_hash(role, perm_hash, _other) + result = perm_hash[role] unless perm_hash.nil? + if result && role == :student && unit.allow_student_change_tutorial + result << :change_tutorial end + result end - def self.active_projects - joins(:unit).where(enrolled: true).where('units.active = TRUE') + def enrol_in(tutorial) + # Check if multiple enrolments changing to a single enrolment - due to no stream. + # No need to delete if only 1, as that would be updated as well. + if tutorial_enrolments.count > 1 && tutorial.tutorial_stream.nil? + # So remove current enrolments + tutorial_enrolments.delete_all() + end + + tutorial_enrolment = matching_enrolment(tutorial) + if tutorial_enrolment.nil? + tutorial_enrolment = TutorialEnrolment.new + tutorial_enrolment.tutorial = tutorial + tutorial_enrolment.project = self + + # Add this enrolment to aid and check project validation + tutorial_enrolments << tutorial_enrolment + + tutorial_enrolment.save! + + # add after save to ensure valid tutorial_enrolments + self.tutorial_enrolments << tutorial_enrolment + else # there is an existing enrolment... + tutorial_enrolment.tutorial = tutorial + tutorial_enrolment.update!(tutorial_id: tutorial.id) + end + tutorial_enrolment end - def self.for_unit_role(unit_role) - active_projects.where(unit_id: unit_role.unit_id) if unit_role.is_teacher? + def enrolled_in?(tutorial) + tutorial_enrolments.select{|e| e.tutorial_id == tutorial.id}.count > 0 || tutorial_enrolments.where(tutorial_id: tutorial.id).count > 0 end - # - # Check to see if the student has a valid tutorial - # - def must_be_in_group_tutorials - groups.each do |g| - next unless g.limit_members_to_tutorial? - next unless tutorial != g.tutorial - if g.group_set.allow_students_to_manage_groups - # leave group - g.remove_member(self) - else - errors.add(:groups, "require you to be in tutorial #{g.tutorial.abbreviation}") - break - end + # Find enrolment in same tutorial stream + def matching_enrolment(tutorial) + tutorial_enrolments. + joins(:tutorial). + where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL OR :sid IS NULL', sid: tutorial.tutorial_stream_id). + first + end + + # Check tutorial membership if there is a campus change + def tutorial_enrolment_same_campus + return unless enrolled && campus_id.present? && will_save_change_to_campus_id? + if tutorial_enrolments.joins(:tutorial).where('tutorials.campus_id <> :cid', cid: campus_id).count > 0 + errors.add(:campus, "does not match with tutorial enrolments.") end end @@ -120,18 +159,6 @@ def task_outcome_alignments learning_outcome_task_links end - # - # Returns the email of the tutor, or the convenor if there is no tutor - # - def tutor_email - tutor = main_tutor - if tutor - tutor.email - else - unit.convenor_email - end - end - # # All "discuss" and "demonstrate" become complete # @@ -139,37 +166,56 @@ def trigger_week_end(by_user) discuss_and_demonstrate_tasks.each { |task| task.trigger_transition(trigger: 'complete', by_user: by_user, bulk: true, quality: task.quality_pts) } end - def start - update_attribute(:started, true) - end - def student user end - def main_tutor - if tutorial - result = tutorial.tutor - result = main_convenor if result.nil? - result - else - main_convenor - end + def tutors_and_tutorial + current_tutor = nil + first_tutor = true + + tutorial_enrolments. + joins(tutorial: {unit_role: :user}). + order('tutor'). + select("tutorials.abbreviation as tutorial_abbr, #{db_concat('users.first_name', "' '", 'users.last_name')} as tutor"). + map do |t| + result = "#{t.tutor == current_tutor ? '' : "#{first_tutor ? '' : ') '}#{t.tutor} ("}#{t.tutorial_abbr}" + current_tutor = t.tutor + first_tutor = false + result + end.join(' ') + ( !first_tutor ? ')' : '') + end + + def tutorial_enrolment_for_stream(tutorial_stream) + tutorial_enrolments. + joins(:tutorial). + where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL', sid: (tutorial_stream.present? ? tutorial_stream.id : nil)). + first + end + + def tutorial_for_stream(tutorial_stream) + enrolment = tutorial_enrolment_for_stream(tutorial_stream) + enrolment.tutorial unless enrolment.nil? + end + + def tutorial_for(task_definition) + tutorial_for_stream(task_definition.tutorial_stream) unless task_definition.nil? end - def main_convenor - unit.main_convenor + def tutor_for(task_definition) + tutorial = tutorial_for(task_definition) + (tutorial.present? and tutorial.tutor.present?) ? tutorial.tutor : main_convenor_user end - def tutorial_abbr - tutorial.abbreviation unless tutorial.nil? + def main_convenor_user + unit.main_convenor_user end def user_role(user) if user == student then :student - elsif user == main_tutor then :tutor elsif user.nil? then nil elsif unit.tutors.where(id: user.id).count != 0 then :tutor + else nil end end @@ -204,23 +250,23 @@ def reference_date def task_details_for_shallow_serializer(user) tasks .joins(:task_status) - .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id") + .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}") .select( 'SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread', 'project_id', 'tasks.id as id', - 'task_definition_id', 'task_statuses.name as status_name', - 'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'tasks.grade as grade', 'quality_pts', 'include_in_portfolio', 'grade' + 'task_definition_id', 'task_statuses.id as status_id', + 'completion_date', 'times_assessed', 'submission_date', 'tasks.grade as grade', 'quality_pts', 'include_in_portfolio', 'grade' ) .group( - 'task_statuses.id', 'tasks.project_id', 'tasks.id', 'task_definition_id', 'status_name', - 'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'grade', 'quality_pts', + 'task_statuses.id', 'tasks.project_id', 'tasks.id', 'task_definition_id', 'status_id', + 'completion_date', 'times_assessed', 'submission_date', 'grade', 'quality_pts', 'include_in_portfolio', 'grade' ) .map do |r| t = Task.find(r.id) { id: r.id, - status: TaskStatus.status_key_for_name(r.status_name), + status: TaskStatus.id_to_key(r.status_id), task_definition_id: r.task_definition_id, include_in_portfolio: r.include_in_portfolio, pct_similar: t.pct_similar, @@ -229,7 +275,9 @@ def task_details_for_shallow_serializer(user) times_assessed: r.times_assessed, grade: r.grade, quality_pts: r.quality_pts, - num_new_comments: r.number_unread + num_new_comments: r.number_unread, + extensions: t.extensions, + due_date: t.due_date } end end @@ -261,7 +309,14 @@ def target_grade=(value) def task_definitions_and_status(target) assigned_task_defs_for_grade(target). order("start_date ASC, abbreviation ASC"). - map { |td| {task_definition: td, status: status_for_task_definition(td) } }. + map { |td| + if has_task_for_task_definition? td + task = task_for_task_definition(td) + {task_definition: td, task: task, status: task.status } + else + {task_definition: td, task: nil, status: :not_started } + end + }. select { |r| [:not_started, :redo, :need_help, :working_on_it, :fix_and_resubmit, :demonstrate, :discuss].include? r[:status] } end @@ -272,6 +327,8 @@ def task_definitions_and_status(target) def top_tasks result = [] + to_target = lambda { |ts| ts[:task].nil? ? ts[:task_definition].target_date : ts[:task].due_date } + # # Get list of tasks that could be top tasks... # @@ -280,26 +337,27 @@ def top_tasks # # Start with overdue... # - overdue_tasks = task_states.select { |ts| ts[:task_definition].target_date < Time.zone.today } + overdue_tasks = task_states.select { |ts| to_target.call(ts) < Time.zone.today } grades = [ "Pass", "Credit", "Distinction", "High Distinction" ] - for i in 0..3 + for i in GradeHelper::RANGE graded_tasks = overdue_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| result << { task_definition: ts[:task_definition], status: ts[:status], reason: :overdue } end + # pick the top 5 return result.slice(0..4) if result.count >= 5 end # # Add in soon tasks... # - soon_tasks = task_states.select { |ts| ts[:task_definition].target_date >= Time.zone.today && ts[:task_definition].target_date < Time.zone.today + 7.days } + soon_tasks = task_states.select { |ts| to_target.call(ts) >= Time.zone.today && to_target.call(ts) < Time.zone.today + 7.days } - for i in 0..3 + for i in GradeHelper::RANGE graded_tasks = soon_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -312,9 +370,9 @@ def top_tasks # # Add in ahead tasks... # - ahead_tasks = task_states.select { |ts| ts[:task_definition].target_date >= Time.zone.today + 7.days } + ahead_tasks = task_states.select { |ts| to_target.call(ts) >= Time.zone.today + 7.days } - for i in 0..3 + for i in GradeHelper::RANGE graded_tasks = ahead_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -330,14 +388,16 @@ def top_tasks def should_revert_to_pass return false unless self.target_grade > 0 + to_target = lambda { |ts| ts[:task].nil? ? ts[:task_definition].target_date.to_date : ts[:task].due_date.to_date } + task_states = task_definitions_and_status(0) - overdue_tasks = task_states.select { |ts| ts[:task_definition].target_date < Time.zone.today } + overdue_tasks = task_states.select { |ts| to_target.call(ts) < Time.zone.today } # More than 2 pass tasks overdue - return false unless overdue_tasks.count > 2 + return false unless overdue_tasks.count > 2 # Oldest is more than 2 weeks past target - return false unless (Time.zone.today - overdue_tasks.first[:task_definition].target_date.to_date).to_i >= 14 + return false unless (Time.zone.today - to_target.call(overdue_tasks.first)).to_i >= 14 return true end @@ -382,14 +442,17 @@ def burndown_chart_data if ready_or_complete_tasks.empty? last_target_date = unit.start_date else - last_target_date = ready_or_complete_tasks.sort { |a, b| a.task_definition.target_date <=> b.task_definition.target_date }.last.task_definition.target_date + last_target_date = ready_or_complete_tasks.sort { |a, b| a.due_date <=> b.due_date }.last.due_date end # today is used to determine when to stop adding done tasks today = reference_date + # Actual tasks + my_tasks = tasks + # Get the tasks currently marked as done (or ready to mark) - done_tasks = ready_or_complete_tasks + done_tasks = tasks_in_submitted_status # use weekly completion rate to determine projected progress completion_rate = weekly_completion_rate @@ -404,10 +467,10 @@ def burndown_chart_data dates.each do |date| # get the target values - those from the task definitions target_val = [ date.to_datetime.to_i, - target_tasks.select { |task_def| task_def.target_date > date }.map { |task_def| task_def.weighting.to_f }.inject(:+)] + target_tasks.select { |task_def| (tasks.where(task_definition: task_def).empty? ? task_def.target_date : tasks.where(task_definition: task_def).first.due_date ) > date }.map { |task_def| task_def.weighting.to_f }.inject(:+)] # get the done values - those done up to today, or the end of the unit done_val = [ date.to_datetime.to_i, - done_tasks.select { |task| !task.completion_date.nil? && task.completion_date <= date }.map { |task| task.task_definition.weighting.to_f }.inject(:+)] + done_tasks.select { |task| task.submission_date.present? && task.submission_date <= date }.map { |task| task.task_definition.weighting.to_f }.inject(:+)] # get the completed values - those signed off complete_val = [ date.to_datetime.to_i, completed_tasks.select { |task| task.completion_date <= date }.map { |task| task.task_definition.weighting.to_f }.inject(:+)] @@ -442,11 +505,6 @@ def burndown_chart_data result end - def projected_end_date - return unit.end_date if rate_of_completion == 0.0 - (remaining_tasks_weight / rate_of_completion).ceil.days.since reference_date - end - def weeks_elapsed(date = nil) (days_elapsed(date) / 7.0).ceil end @@ -456,22 +514,6 @@ def days_elapsed(date = nil) (date - unit.start_date).to_i / 1.day end - def rate_of_completion(date = nil) - # Return a completion rate of 0.0 if the project is yet to have commenced - return 0.0 if !commenced? || completed_tasks.empty? - date ||= reference_date - - # TODO: Might make sense to take in the resolution (i.e. days, weeks), rather - # than just assuming days - - # If on the first day (i.e. a day has not yet passed, but the project - # has commenced), force days elapsed to be 1 to avoid divide by zero - days = days_elapsed(date) - days = 1 if days_elapsed(date) < 1 - - completed_tasks_weight / days.to_f - end - def weekly_completion_rate(date = nil) # Return a completion rate of 0.0 if the project is yet to have commenced return 0.0 if ready_or_complete_tasks.empty? @@ -484,51 +526,20 @@ def weekly_completion_rate(date = nil) completed_tasks_weight / weeks.to_f end - def required_task_completion_rate - remaining_tasks_weight / remaining_days - end - - def recommended_completed_tasks - assigned_tasks.select { |task| task.task_definition.target_date < reference_date } - end - def completed_tasks assigned_tasks.select(&:complete?) end - def ready_to_mark_tasks - assigned_tasks.select(&:ready_to_mark?) - end - def ready_or_complete_tasks assigned_tasks.select(&:ready_or_complete?) end - def discuss_and_demonstrate_tasks - tasks.select(&:discuss_or_demonstrate?) - end - - def partially_completed_tasks - # TODO: Should probably have a better definition - # of partially complete than just 'fix' tasks - assigned_tasks.select { |task| task.fix_and_resubmit? || task.do_not_resubmit? } - end - - def completed? - # TODO: Have a status flag on the project instead - assigned_tasks.all?(&:complete?) + def tasks_in_submitted_status + assigned_tasks.select(&:submitted_status?) end - def incomplete_tasks - assigned_tasks.select { |task| !task.complete? } - end - - def percentage_complete - completed_tasks.empty? ? 0.0 : (completed_tasks_weight / total_task_weight) * 100 - end - - def remaining_tasks_weight - incomplete_tasks.empty? ? 0.0 : incomplete_tasks.map { |task| task.task_definition.weighting }.inject(:+) + def discuss_and_demonstrate_tasks + tasks.select(&:discuss_or_demonstrate?) end # @@ -538,17 +549,6 @@ def completed_tasks_weight ready_or_complete_tasks.empty? ? 0.0 : ready_or_complete_tasks.map { |task| task.task_definition.weighting }.inject(:+) end - def partially_completed_tasks_weight - # Award half for partially completed tasks - # TODO: Should probably make this a project-by-project option - partially_complete = partially_completed_tasks - partially_complete.empty? ? 0.0 : partially_complete.map { |task| task.task_definition.weighting / 2.to_f }.inject(:+) - end - - def task_units_completed - completed_tasks_weight + partially_completed_tasks_weight - end - def convert_hash_to_pct(hash, total) hash.each { |key, value| hash[key] = (hash[key] < 0.01 ? 0.0 : (value / total).signif(2)) } @@ -566,38 +566,94 @@ def convert_hash_to_pct(hash, total) end end - def task_stats - task_count = unit.task_definitions.where("target_grade <= #{target_grade}").count + 0.0 - task_count = 1.0 unless task_count > 1.0 + DEFAULT_TASK_STATS = { + red_pct: 0, + grey_pct: 1, + orange_pct: 0, + blue_pct: 0, + green_pct: 0, + order_scale: 0 + }.freeze + + # Calculate the task stats text to send progress data back to the client + # Total task counts must contain an array of the cummulative task counts (with none being 0) + # Project task counts is an object with fail_count, complete_count etc for each status + def self.create_task_stats_from(total_task_counts, project_task_counts, target_grade) + # check there are tasks... + if total_task_counts[target_grade] > 0 + # For each kind of task status... get counts of that status from the passed project stats + (1..TaskStatus.count).each do |status_id| + project_task_counts["#{TaskStatus.id_to_key(status_id)}_count"] = 0 if project_task_counts["#{TaskStatus.id_to_key(status_id)}_count"].nil? + end + + red_pct = ((project_task_counts.fail_count + project_task_counts.feedback_exceeded_count + project_task_counts.time_exceeded_count) / total_task_counts[target_grade]).signif(2) + orange_pct = ((project_task_counts.redo_count + project_task_counts.need_help_count + project_task_counts.fix_and_resubmit_count) / total_task_counts[target_grade]).signif(2) + green_pct = ((project_task_counts.discuss_count + project_task_counts.demonstrate_count + project_task_counts.complete_count) / total_task_counts[target_grade]).signif(2) + blue_pct = (project_task_counts.ready_for_feedback_count / total_task_counts[target_grade]).signif(2) + grey_pct = (1 - red_pct - orange_pct - green_pct - blue_pct).signif(2) + + order_scale = green_pct * 100 + blue_pct * 100 + orange_pct * 10 - red_pct + else + red_pct = 0 + orange_pct = 0 + green_pct = 0 + blue_pct = 0 + grey_pct = 1 + order_scale = 0 + end + + { + red_pct: red_pct, + grey_pct: grey_pct, + orange_pct: orange_pct, + blue_pct: blue_pct, + green_pct: green_pct, + order_scale: order_scale + } + end + + # Recalculate the task stats for the project, and store in the + # task_stats field + def update_task_stats + # generate SQL for columns that count the number of tasks per grade + count_by_grade = (GradeHelper::RANGE).map { |grade_id| "SUM(CASE WHEN target_grade <= #{grade_id} THEN 1 ELSE 0 END) AS count_#{grade_id}" } + + # Get the count of the total number of tasks less than each target grade + task_count = unit + .task_definitions + .select(*count_by_grade) # create columns for each grade + .map do |r| # map to array + [ + r['count_0'].to_f || 0.0, + r['count_1'].to_f || 0.0, + r['count_2'].to_f || 0.0, + r['count_3'].to_f || 0.0 + ] + end + .first # there is only one row returned... + + # Generate SQL to get the count of each task status for the project + sum_by_status = (1..TaskStatus.count).map do |status_id| + "SUM(CASE WHEN tasks.task_status_id = #{status_id} THEN 1 ELSE 0 END) AS #{TaskStatus.id_to_key(status_id)}_count" + end + + # Get the assigned tasks (those where task grade <= target grade) + # sum the task counts by status + # and map to json from tasks stats + # getting first... as there is only one row returned (the row with sums) result = assigned_tasks - .group('project_id') - .select( - 'project_id', - *TaskStatus.all.map { |s| "SUM(CASE WHEN tasks.task_status_id = #{s.id} THEN 1 ELSE 0 END) AS #{s.status_key}_count" } - ) - .map do |t| - # puts "#{t.project_id} #{t.first_name} #{t.fail_count} Grade:#{t.grade} Count:#{task_count[t.grade]}" - fail_pct = (t.fail_count / task_count).signif(2) - do_not_resubmit_pct = (t.do_not_resubmit_count / task_count).signif(2) - redo_pct = (t.redo_count / task_count).signif(2) - need_help_pct = (t.need_help_count / task_count).signif(2) - working_on_it_pct = (t.working_on_it_count / task_count).signif(2) - fix_and_resubmit_pct = (t.fix_and_resubmit_count / task_count).signif(2) - ready_to_mark_pct = (t.ready_to_mark_count / task_count).signif(2) - discuss_pct = (t.discuss_count / task_count).signif(2) - demonstrate_pct = (t.demonstrate_count / task_count).signif(2) - complete_pct = (t.complete_count / task_count).signif(2) - - not_started_pct = (1 - fail_pct - do_not_resubmit_pct - redo_pct - need_help_pct - working_on_it_pct - fix_and_resubmit_pct - ready_to_mark_pct - discuss_pct - demonstrate_pct - complete_pct).signif(2) - - "#{fail_pct}|#{not_started_pct}|#{do_not_resubmit_pct}|#{redo_pct}|#{need_help_pct}|#{working_on_it_pct}|#{fix_and_resubmit_pct}|#{ready_to_mark_pct}|#{discuss_pct}|#{demonstrate_pct}|#{complete_pct}" - end.first + .select( *sum_by_status ) + .map {|t| Project.create_task_stats_from(task_count, t, target_grade) } + .first + # There may be no row however... in which case use the defaults if result.nil? - '0|1|0|0|0|0|0|0|0|0|0' + result = DEFAULT_TASK_STATS else result end + + update(task_stats: result.to_json) end def assigned_task_defs_for_grade(target) @@ -612,24 +668,6 @@ def total_task_weight assigned_task_defs.map(&:weighting).inject(:+) end - # - # Tasks currently due - but not complete - # - def currently_due_tasks - assigned_tasks.select(&:currently_due?) - end - - # - # All tasks currently due - # - def due_tasks - assigned_tasks.select { |task| task.target_date < reference_date } - end - - def overdue_tasks - assigned_tasks.select(&:overdue?) - end - def remaining_days (unit.end_date - reference_date).to_i / 1.day end @@ -650,48 +688,11 @@ def last_task_completed completed_tasks.sort_by(&:completion_date).last end - def task_completion_csv - all_tasks = unit.task_definitions_by_grade - [ - student.username, - student.name, - target_grade_desc, - student.email, - portfolio_status, - tutorial ? tutorial.abbreviation : '', - main_tutor.name - ] + - unit.group_sets.map do |gs| - grp = group_for_groupset(gs) - grp ? grp.name : nil - end + - all_tasks.map do |td| - task = tasks.where(task_definition_id: td.id).first - if task - status = task.task_status.name - grade = task.grade_desc - stars = task.quality_pts - people = task.contribution_pts - else - status = TaskStatus.not_started.name - grade = nil - stars = nil - people = nil - end - - result = [status] - result << grade if td.is_graded? - result << stars if td.has_stars? - result << people if td.is_group_task? - result - end.flatten - end - # # Portfolio production code # def portfolio_temp_path - portfolio_dir = FileHelper.student_portfolio_dir(self, false) + portfolio_dir = FileHelper.student_portfolio_dir(self.unit, self.student.username, false) portfolio_tmp_dir = File.join(portfolio_dir, 'tmp') end @@ -713,13 +714,17 @@ def move_to_portfolio(file, name, kind) FileUtils.mkdir_p(portfolio_tmp_dir) result = { kind: kind, - name: file.filename + name: file[:filename] } # copy up the learning summary report as first -- otherwise use files to determine idx if name == 'LearningSummaryReport' && kind == 'document' result[:idx] = 0 result[:name] = 'LearningSummaryReport.pdf' + + # set uses_draft_learning_summary to false, since we uploaded a new learning summary + self.uses_draft_learning_summary = false + save else Dir.chdir(portfolio_tmp_dir) files = Dir.glob('*') @@ -733,7 +738,7 @@ def move_to_portfolio(file, name, kind) end dest_file = portfolio_tmp_file_name(result) - FileUtils.cp file.tempfile.path, File.join(portfolio_tmp_dir, dest_file) + FileUtils.cp file["tempfile"].path, File.join(portfolio_tmp_dir, dest_file) result end @@ -778,93 +783,12 @@ def remove_portfolio_file(idx, kind, name) end end - # - # Make file coverpage - # - def create_task_cover_page(dest_dir) - # - # check later -- not working at the moment fa not rendering in pdfkit - # @acain: this won't work as we haven't imported font-awesome on the server - # - # status_icons = { - # ready_to_mark: 'fa fa-thumbs-o-up', - # not_started: 'fa fa-times', - # working_on_it: 'fa fa-bolt', - # need_help: 'fa fa-question-circle', - # redo: 'fa fa-refresh', - # do_not_resubmit: 'fa fa-stop', - # fix_and_resubmit: 'fa fa-wrench', - # discuss: 'fa fa-check', - # complete: 'fa fa-check-circle-o' - # } - - grade_descs = [ - 'Pass', - 'Credit', - 'Distinction', - 'High Distinction' - ] - - ordered_tasks = tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation').select { |task| task.task_definition.target_grade <= target_grade } - host = Doubtfire::Application.config.institution[:host] - coverpage_html = < - - - - -

- #{unit.name} #{unit.code} -

#{student.name} #{student.username}

-

-

Tasks for #{student.name}

- - - - - - - - -EOF - - ordered_tasks.each do |task| - task_row_html = < - - - - - - -EOF - coverpage_html << task_row_html - end - - coverpage_html << '
TaskStatusIncludedGrade
#{task.task_definition.name}#{task.task_status.name}#{task.grade.nil? ? 'N/A' : grade_descs[task.grade]}
' - - cover_filename = File.join(dest_dir, 'task.cover.html') - - logger.debug("Generating cover page #{cover_filename} - #{log_details}") - - # - # Create cover page for the submitted file (/file0.cover.html etc.) - # - logger.debug "Generating cover page #{cover_filename} - #{log_details}" - - coverp_file = File.new(cover_filename, 'w') - coverp_file.write(coverpage_html) - coverp_file.close - - cover_filename - end - def portfolio_path - File.join(FileHelper.student_portfolio_dir(self, true), FileHelper.sanitized_filename("#{student.username}-portfolio.pdf")) + FileHelper.student_portfolio_path(self.unit, self.student.username, true) end def has_portfolio - !portfolio_production_date.nil? + (!portfolio_production_date.nil?) && portfolio_available end def portfolio_status @@ -886,11 +810,6 @@ def remove_portfolio FileUtils.mv portfolio, "#{portfolio}.old" if File.exist?(portfolio) end - def recalculate_max_similar_pct - # self.max_pct_similar = tasks.sort { |t1, t2| t1.max_pct_similar <=> t2.max_pct_similar }.last.max_pct_similar - # self.save - end - def matching_task(other_task) task_for_task_definition(other_task.task_definition) end @@ -973,7 +892,7 @@ def init(project, is_retry) end def make_pdf - render_to_string(template: '/portfolio/portfolio_pdf.pdf.erb', layout: true) + render_to_string(template: '/portfolio/portfolio_pdf', layout: true) end end @@ -1044,11 +963,32 @@ def send_weekly_status_email ( summary_stats, middle_of_unit ) did_revert_to_pass = true summary_stats[:revert_count] = summary_stats[:revert_count] + 1 - summary_stats[:revert][main_tutor] << self + summary_stats[:revert][main_convenor_user] << self end return unless student.receive_feedback_notifications return if has_portfolio && ! middle_of_unit NotificationsMailer.weekly_student_summary(self, summary_stats, did_revert_to_pass).deliver_now end + + private + def can_destroy? + return true if tutorial_enrolments.count == 0 + errors.add :base, "Cannot delete project with enrolments" + throw :abort + end + + # If someone withdraws from a unit, make sure they are removed from groups + def check_withdraw_from_groups + # return if enrolled was not changed... or we are now not enrolled + return unless enrolled && ! saved_change_to_enrolled[0] # 0 is the old value of enrolled before update + + group_memberships.each do |gm| + next unless gm.active + + if ! gm.valid? || gm.group.beyond_capacity? + gm.update(active: false) + end + end + end end diff --git a/app/models/role.rb b/app/models/role.rb index 353b9f85f..db0335edf 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -1,4 +1,15 @@ -class Role < ActiveRecord::Base +class Role < ApplicationRecord + + # + # Override find to ensure that role objects are cached - these do not change + # + def self.find(id) + Rails.cache.fetch("roles/#{id}", expires_in: 12.hours) do + super + end + end + + def self.student Role.find(student_id) end diff --git a/app/models/sub_task.rb b/app/models/sub_task.rb deleted file mode 100644 index 9d6015c7b..000000000 --- a/app/models/sub_task.rb +++ /dev/null @@ -1,7 +0,0 @@ -class SubTask < ActiveRecord::Base - include ApplicationHelper - - # Model associations - belongs_to :sub_task_definition - belongs_to :task -end diff --git a/app/models/sub_task_definition.rb b/app/models/sub_task_definition.rb deleted file mode 100644 index ae2f18cdc..000000000 --- a/app/models/sub_task_definition.rb +++ /dev/null @@ -1,5 +0,0 @@ -class SubTaskDefinition < ActiveRecord::Base - # Model associations - has_many :sub_tasks, dependent: :destroy - has_many :badges, dependent: :destroy -end diff --git a/app/models/task.rb b/app/models/task.rb index 876b32f43..c9bc1dc09 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -1,6 +1,9 @@ -class Task < ActiveRecord::Base +require 'date' + +class Task < ApplicationRecord include ApplicationHelper include LogHelper + include GradeHelper # # Permissions around task data @@ -12,7 +15,11 @@ def self.permissions :put, :get_submission, :make_submission, - :delete_own_comment + :delete_own_comment, + :start_discussion, + :get_discussion, + :make_discussion_reply, + # :request_extension -- depends on settings in unit. See specific_permission_hash method ] # What can tutors do with tasks? tutor_role_permissions = [ @@ -23,7 +30,12 @@ def self.permissions :delete_other_comment, :delete_own_comment, :view_plagiarism, - :delete_plagiarism + :delete_plagiarism, + :create_discussion, + :delete_discussion, + :get_discussion, + :assess_extension, + :request_extension ] # What can convenors do with tasks? convenor_role_permissions = [ @@ -33,7 +45,10 @@ def self.permissions :delete_other_comment, :delete_own_comment, :view_plagiarism, - :delete_plagiarism + :delete_plagiarism, + :get_discussion, + :assess_extension, + :request_extension ] # What can nil users do with tasks? nil_role_permissions = [ @@ -64,35 +79,74 @@ def role_for(user) end end + # Used to adjust the request extension permission in units that do not + # allow students to request extensions + def specific_permission_hash(role, perm_hash, _other) + result = perm_hash[role] unless perm_hash.nil? + if result && role == :student && unit.allow_student_extension_requests + result << :request_extension + end + result + end + + # Delete action - before dependent association + before_destroy :delete_associated_files + # Model associations - belongs_to :task_definition # Foreign key - belongs_to :project # Foreign key - belongs_to :task_status # Foreign key - belongs_to :group_submission + belongs_to :task_definition, optional: false # Foreign key + belongs_to :project, optional: false # Foreign key + belongs_to :task_status, optional: false # Foreign key + belongs_to :group_submission, optional: true + + has_one :unit, through: :project - has_many :sub_tasks, dependent: :destroy has_many :comments, class_name: 'TaskComment', dependent: :destroy, inverse_of: :task has_many :plagiarism_match_links, class_name: 'PlagiarismMatchLink', dependent: :destroy, inverse_of: :task has_many :reverse_plagiarism_match_links, class_name: 'PlagiarismMatchLink', dependent: :destroy, inverse_of: :other_task, foreign_key: 'other_task_id' has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes has_many :learning_outcomes, through: :learning_outcome_task_links - has_many :task_engagements + has_many :task_engagements, dependent: :destroy + has_many :task_submissions, dependent: :destroy + has_many :overseer_assessments, dependent: :destroy + + delegate :unit, to: :project + delegate :student, to: :project + delegate :upload_requirements, to: :task_definition + delegate :name, to: :task_definition + delegate :target_date, to: :task_definition + delegate :update_task_stats, to: :project + + after_update :update_task_stats, if: :saved_change_to_task_status_id? #TODO: consider moving to async task validates :task_definition_id, uniqueness: { scope: :project, message: 'must be unique within the project' } - validate :must_have_quality_pts, if: :for_task_with_quality? + validate :must_have_quality_pts, if: :for_definition_with_quality? - def for_task_with_quality? - task_definition.max_quality_pts.positive? + validate :extensions_must_end_with_due_date, if: :has_requested_extension? + + def for_definition_with_quality? + task_definition.has_stars? + end + + def has_requested_extension? + extensions > 0 && will_save_change_to_extensions? && extensions > extensions_in_database end def must_have_quality_pts - if quality_pts.nil? || quality_pts.negative? || quality_pts > task_definition.max_quality_pts + if quality_pts.nil? || quality_pts < -1 || quality_pts > task_definition.max_quality_pts errors.add(:quality_pts, "must be between 0 and #{task_definition.max_quality_pts}") end end + # Ensure that extensions do not exceed the defined due date + def extensions_must_end_with_due_date + # First check the raw extension date - but allow it to be up to a week later in case due date and target date are on different days + if raw_extension_date.to_date - 7.days >= task_definition.due_date.to_date + errors.add(:extensions, "have exceeded deadline for task. Work must be submitted within current timeframe. Work submitted after current due date will be assessed in the portfolio") + end + end + def all_comments if group_submission.nil? comments @@ -113,6 +167,33 @@ def mark_comments_as_unread(user, comments) end end + def comments_for_user(user) + TaskComment. + joins('JOIN users AS authors ON authors.id = task_comments.user_id'). + joins('JOIN users AS recipients ON recipients.id = task_comments.recipient_id'). + joins("LEFT JOIN comments_read_receipts u_crr ON u_crr.task_comment_id = task_comments.id AND u_crr.user_id = #{user.id}"). + joins("LEFT JOIN comments_read_receipts r_crr ON r_crr.task_comment_id = task_comments.id AND r_crr.user_id = recipients.id"). + where('task_comments.task_id = :task_id', task_id: self.id). + order('created_at ASC'). + select( + 'task_comments.id AS id', + 'task_comments.comment AS comment', + 'task_comments.content_type AS content_type', + "case when u_crr.created_at IS NULL then 1 else 0 end AS is_new", + 'r_crr.created_at AS recipient_read_time', + 'task_comments.created_at AS created_at', + 'authors.id AS author_id', + 'authors.first_name AS author_first_name', + 'authors.last_name AS author_last_name', + 'authors.email AS author_email', + 'recipients.id AS recipient_id', + 'recipients.first_name AS recipient_first_name', + 'recipients.last_name AS recipient_last_name', + 'recipients.email AS recipient_email', + 'task_comments.reply_to_id AS reply_to_id' + ) + end + def current_plagiarism_match_links plagiarism_match_links.where(dismissed: false) end @@ -125,94 +206,90 @@ def self.for_user(user) Task.joins(:project).where('projects.user_id = ?', user.id) end - delegate :unit, to: :project - - delegate :student, to: :project - - delegate :upload_requirements, to: :task_definition - def processing_pdf? if group_task? && group_submission File.exist? File.join(FileHelper.student_work_dir(:new), group_submission.submitter_task.id.to_s) else File.exist? File.join(FileHelper.student_work_dir(:new), id.to_s) end - # portfolio_evidence == nil && ready_to_mark? end - def overdue? - # A task cannot be overdue if it is marked complete - return false if complete? - - # Compare the recommended date with the date given to determine - # if the task is overdue - recommended_date = task_definition.target_date - project.reference_date > recommended_date && weeks_overdue >= 1 + # Get the raw extension date - with extensions representing weeks + def raw_extension_date + target_date + extensions.weeks end - def long_overdue? - # A task cannot be overdue if it is marked complete - return false if complete? - - # Compare the recommended date with the date given to determine - # if the task is overdue - recommended_date = task_definition.target_date - project.reference_date > recommended_date && weeks_overdue > 2 + # Get the adjusted extension date, which ensures it is never past the due date + def extension_date + result = raw_extension_date + return task_definition.due_date if result > task_definition.due_date + return result end - def currently_due? - # A task is currently due if it is not complete and over/under the due date by less than - # 7 days - !complete? && days_overdue.between?(-7, 7) + # The student can apply for an extension if the current extension date is + # before the task's due date + def can_apply_for_extension? + raw_extension_date.to_date < task_definition.due_date.to_date end - def weeks_until_due - days_until_due / 7 - end + # Applying for an extension will create an extension comment + def apply_for_extension(user, text, weeks) + extension = ExtensionComment.create + extension.task = self + extension.extension_weeks = weeks + extension.user = user + extension.content_type = :extension + extension.comment = text + if weeks <= weeks_can_extend + extension.recipient = project.tutor_for(task_definition) + else + extension.recipient = unit.main_convenor_user + end + extension.save! - def days_until_due - (task_definition.target_date - project.reference_date).to_i / 1.day - end + # Check and apply either auto extensions, or those requested by staff + if unit.auto_apply_extension_before_deadline && weeks <= weeks_can_extend || role_for(user) == :tutor + if role_for(user) == :tutor + extension.assess_extension user, true, true + else + extension.assess_extension unit.main_convenor_user, true, true + end + end - def weeks_overdue - days_overdue / 7 + extension end - def days_since_completion - (project.reference_date - completion_date.to_datetime).to_i / 1.day - end + def weeks_can_extend + deadline = task_definition.due_date.to_date + current_due = raw_extension_date.to_date - def weeks_since_completion - days_since_completion / 7 + diff = deadline - current_due + (diff.to_f / 7).ceil end - def days_overdue - (project.reference_date - task_definition.target_date).to_i / 1.day - end + # Add an extension to the task + def grant_extension(by_user, weeks) + weeks_to_extend = weeks <= weeks_can_extend ? weeks : weeks_can_extend + return false unless weeks_to_extend > 0 - # Action date defines the last time a task has been "actioned", either the - # submission date or latest student comment -- whichever is newer - def action_date - return nil if last_student_comment.nil? || submission_date.nil? - return last_student_comment.created_at if !last_student_comment.nil? && submission_date.nil? - return submission_date.created_at if !submission_date.nil? && last_student_comment.nil? - last_student_comment.created_at > submission_date ? last_student_comment.created_at : submission_date - end + if update(extensions: self.extensions + weeks_to_extend) + # Was the task previously assessed as time exceeded? ... with the extension should this change? + if self.task_status == TaskStatus.time_exceeded && submitted_before_due? + update(task_status: TaskStatus.ready_for_feedback) + add_status_comment(by_user, self.task_status) + end - # Returns the last student comment for this task - def last_student_comment - comments.where(user: project.user).order(:created_at).last + return true + else + return false + end end - # Returns the last tutor comment for this task - def last_tutor_comment - comments.where(user: project.tutorial.tutor).order(:created_at).last + def due_date + return target_date if extensions == 0 + return extension_date end - delegate :due_date, to: :task_definition - - delegate :target_date, to: :task_definition - def complete? status == :complete end @@ -234,27 +311,27 @@ def fail? end def task_submission_closed? - complete? || discuss_or_demonstrate? || do_not_resubmit? || fail? + complete? || discuss_or_demonstrate? || feedback_exceeded? || fail? end - def ok_to_submit? - status != :complete && status != :discuss && status != :demonstrate + def ready_for_feedback? + status == :ready_for_feedback end - def ready_to_mark? - status == :ready_to_mark + def ready_or_complete? + [:complete, :discuss, :demonstrate, :ready_for_feedback].include? status end - def ready_or_complete? - status == :complete || status == :discuss || status == :demonstrate || status == :ready_to_mark + def submitted_status? + ! [:working_on_it, :not_started, :fix_and_resubmit, :redo, :need_help].include? status end def fix_and_resubmit? status == :fix_and_resubmit end - def do_not_resubmit? - status == :do_not_resubmit + def feedback_exceeded? + status == :feedback_exceeded end def redo? @@ -270,7 +347,7 @@ def working_on_it? end def reviewable? - has_pdf && (ready_to_mark? || need_help?) + has_pdf && (ready_for_feedback? || need_help?) end def status @@ -278,25 +355,11 @@ def status end def has_pdf - !portfolio_evidence.nil? && File.exist?(portfolio_evidence) && !processing_pdf? + !portfolio_evidence_path.nil? && File.exist?(portfolio_evidence_path) && !processing_pdf? end def log_details - "#{id} - #{project.student.username}, #{project.unit.code}" - end - - def assign_evidence_path(final_pdf_path, propagate = true) - if group_task? && propagate - group_submission.tasks.each do |task| - task.assign_evidence_path(final_pdf_path, false) - end - reload - else - logger.debug "Assigning task #{id} to final PDF evidence path #{final_pdf_path}" - self.portfolio_evidence = final_pdf_path - logger.debug "PDF evidence path for task #{id} is now #{portfolio_evidence}" - save - end + "#{id} - #{project.student.username}, #{project.unit.code}, #{task_definition.abbreviation}" end def group_task? @@ -340,47 +403,32 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: # State transitions based upon the trigger # - # - # Tutor and student can trigger these actions... - # - case trigger - when 'ready_to_mark', 'rtm' - submit - when 'not_started' - engage TaskStatus.not_started - when 'not_ready_to_mark' - engage TaskStatus.not_started - when 'need_help' - engage TaskStatus.need_help - when 'working_on_it' - engage TaskStatus.working_on_it + status = TaskStatus.status_for_name(trigger) + + case status + when nil + return nil + when TaskStatus.ready_for_feedback + submit by_user + when TaskStatus.not_started, TaskStatus.need_help, TaskStatus.working_on_it + add_status_comment(by_user, status) + engage status else - # # Only tutors can perform these actions - # if role == :tutor if task_definition.max_quality_pts > 0 - if %w(complete discuss demonstrate de demo d).include? trigger + case status + when TaskStatus.complete, TaskStatus.discuss, TaskStatus.demonstrate update(quality_pts: quality) end end + assess status, by_user - case trigger - when 'fail', 'f' - assess TaskStatus.fail, by_user - when 'redo' - assess TaskStatus.redo, by_user - when 'complete' - assess TaskStatus.complete, by_user - when 'fix_and_resubmit', 'fix' - assess TaskStatus.fix_and_resubmit, by_user - when 'do_not_resubmit', 'dnr', 'fix_and_include', 'fixinc' - assess TaskStatus.do_not_resubmit, by_user - when 'demonstrate', 'de', 'demo' - assess TaskStatus.demonstrate, by_user - when 'discuss', 'd' - assess TaskStatus.discuss, by_user - end + # Add a status comment for new assessments - only recorded on submitter's task in groups + add_status_comment(by_user, status) + else + # Attempt to move to tutor state by non-tutor + return nil end end @@ -396,16 +444,7 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: end def grade_desc - case grade - when 0 - 'Pass' - when 1 - 'Credit' - when 2 - 'Distinction' - when 3 - 'High Distinction' - end + grade_for(grade) end # @@ -418,6 +457,7 @@ def grade_task(new_grade, ui = nil, grading_group = false) end grade_map = { + 'f' => -1, 'p' => 0, 'c' => 1, 'd' => 2, @@ -436,11 +476,11 @@ def grade_task(new_grade, ui = nil, grading_group = false) # convert string representation to integer representation new_grade = grade_map[new_grade] else - raise_error.call("New grade supplied to task is not an invalid string - expects one of {p|c|d|hd} (task id #{id})") + raise_error.call("New grade supplied to task is not a valid string - expects one of {f|p|c|d|hd} (task id #{id})") end end unless new_grade.is_a?(Integer) && grade_map.values.include?(new_grade.to_i) - raise_error.call("New grade supplied to task is not an invalid integer - expects one of {0|1|2|3} (task id #{id})") + raise_error.call("New grade supplied to task is not a valid integer - expects one of {-1|0|1|2|3} (task id #{id})") end # propagate new grade to all OTHER group members if group_task? && !grading_group @@ -477,14 +517,18 @@ def assess(task_status, assessor, assess_date = Time.zone.now) self.completion_date = assess_date if completion_date.nil? else self.completion_date = nil + + # Grant an extension on fix if due date is within 1 week + case task_status + when TaskStatus.redo, TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.demonstrate + if to_same_day_anywhere_on_earth(due_date) < Time.zone.now + 7.days && can_apply_for_extension? && unit.extension_weeks_on_resubmit_request > 0 + grant_extension(assessor, unit.extension_weeks_on_resubmit_request) + end + end end # Save the task if save! - # If a task has been completed, that means the project - # has definitely started - project.start - TaskEngagement.create!(task: self, engagement_time: Time.zone.now, engagement: task_status.name) # Grab the submission for the task if the user made one @@ -497,8 +541,16 @@ def assess(task_status, assessor, assess_date = Time.zone.now) submission_attributes[:submission_time] = assess_date submission = TaskSubmission.create! submission_attributes else - submission.update_attributes submission_attributes - submission.save + # we have an existing submission + if submission.assessment_time.nil? + # and it hasn't been assessed yet... + submission.update submission_attributes + submission.save + else + # it was assessed... so lets create a new assessment + submission_attributes[:submission_time] = submission.submission_time + submission = TaskSubmission.create! submission_attributes + end end end end @@ -511,12 +563,30 @@ def engage(engagement_status) end end - def submit(submit_date = Time.zone.now) - self.task_status = TaskStatus.ready_to_mark + def submitted_before_due? + return true unless due_date.present? + to_same_day_anywhere_on_earth(due_date) >= self.submission_date + end + + # + # A task has been submitted - update the status and record the submission + # Default submission time to current time. + # + def submit(by_user, submit_date = Time.zone.now) self.submission_date = submit_date + add_status_comment(by_user, TaskStatus.ready_for_feedback) + + # If it is submitted before the due date... + if submitted_before_due? + self.task_status = TaskStatus.ready_for_feedback + else + assess TaskStatus.time_exceeded, by_user + add_status_comment(project.tutor_for(task_definition), self.task_status) + grade_task -1 if task_definition.is_graded? && self.grade.nil? + end + if save! - project.start TaskEngagement.create!(task: self, engagement_time: Time.zone.now, engagement: task_status.name) submission = TaskSubmission.where(task_id: id).order(:submission_time).reverse_order.first @@ -537,7 +607,7 @@ def submit(submit_date = Time.zone.now) def assessed? redo? || fix_and_resubmit? || - do_not_resubmit? || + feedback_exceeded? || fail? || complete? end @@ -546,20 +616,92 @@ def weight task_definition.weighting.to_f end - def add_comment(user, text) + def add_text_comment(user, text, reply_to_id = nil) text.strip! return nil if user.nil? || text.nil? || text.empty? lc = comments.last + + # don't add if duplicate comment return if lc && lc.user == user && lc.comment == text - ensured_group_submission if group_task? + ensured_group_submission if group_task? && group comment = TaskComment.create comment.task = self comment.user = user comment.comment = text - comment.recipient = user == project.student ? project.main_tutor : project.student + comment.content_type = :text + comment.recipient = user == project.student ? project.tutor_for(task_definition) : project.student + comment.reply_to_id = reply_to_id + comment.save! + + comment + end + + def individual_task_or_submitter_of_group_task? + return true if !group_task? # its individual + return true unless group.present? # no group yet... so individual + + ensured_group_submission.submitted_by? self.project # return true if submitted by this project + end + + def add_status_comment(current_user, status) + return nil unless individual_task_or_submitter_of_group_task? # only record status comments on submitter task + + comment = TaskStatusComment.create + comment.task = self + comment.user = current_user + comment.comment = status.name + comment.task_status = status + comment.recipient = current_user == project.student ? project.tutor_for(task_definition) : project.student + comment.save! + + comment + end + + def add_discussion_comment(user, prompts) + # don't allow if group task. + discussion = DiscussionComment.create + discussion.task = self + discussion.user = user + discussion.content_type = :discussion + discussion.recipient = project.student + discussion.number_of_prompts = prompts.count + discussion.save! + + prompts.each_with_index do |prompt, index | + raise "Unknown comment attachment type" unless FileHelper.accept_file(prompt, "comment attachment discussion audio", "audio") + raise "Error attaching uploaded file." unless discussion.add_prompt(prompt, index) + end + + discussion.mark_as_read(user, unit) + + logger.info(discussion) + return discussion + end + + # TODO: Refactor to attachment comment (with inheritance on model) + def add_comment_with_attachment(user, tempfile, reply_to_id = nil) + ensured_group_submission if group_task? && group + + comment = TaskComment.create + comment.task = self + comment.user = user + comment.reply_to_id = reply_to_id + if FileHelper.accept_file(tempfile, "comment attachment audio test", "audio") + comment.content_type = :audio + elsif FileHelper.accept_file(tempfile, "comment attachment image test", "image") + comment.content_type = :image + elsif FileHelper.accept_file(tempfile, "comment attachment pdf", "document") + comment.content_type = :pdf + else + raise "Unknown comment attachment type" + end + + comment.recipient = user == project.student ? project.tutor_for(task_definition) : project.student + raise "Error attaching uploaded file." unless comment.add_attachment(tempfile) + comment.save! comment end @@ -610,16 +752,6 @@ def similar_to_dismissed_count plagiarism_match_links.where('dismissed = TRUE').count end - def recalculate_max_similar_pct - # TODO: Remove once max_pct_similar is deleted - # self.max_pct_similar = pct_similar() - # self.save - # - # project.recalculate_max_similar_pct() - end - - delegate :name, to: :task_definition - def student_work_dir(type, create = true) if group_task? # New submissions need to use the path of this task @@ -636,7 +768,8 @@ def student_work_dir(type, create = true) def zip_file_path_for_done_task if group_task? if group_submission.nil? - nil + logger.warn("Missing group submission from task identified for task #{id}!") + "#{Doubtfire::Application.config.student_work_dir}/#{FileHelper.sanitized_path("#{project.unit.code}-#{project.unit.id}", project.student.username.to_s, 'done', id.to_s)[0..-1]}.zip" else "#{FileHelper.student_group_work_dir(:done, group_submission)[0..-2]}.zip" end @@ -664,17 +797,24 @@ def extract_file_from_done(to_path, pattern, name_fn) end end + def has_new_files? + File.directory? student_work_dir(:new, false) + end + + def has_done_file? + File.exist? zip_file_path_for_done_task + end + # # Compress the done files for a student - includes cover page and work uploaded # - def compress_new_to_done - task_dir = student_work_dir(:new, false) + def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: nil, rm_task_dir: true) begin # Ensure that this task is the submitter task for a group_task... otherwise # remove this submission raise "Multiple team member submissions received at the same time. Please ensure that only one member submits the task." if group_task? && self != group_submission.submitter_task - - zip_file = zip_file_path_for_done_task + + zip_file = zip_file_path || zip_file_path_for_done_task return false if zip_file.nil? || (!Dir.exist? task_dir) FileUtils.rm(zip_file) if File.exist? zip_file @@ -682,7 +822,11 @@ def compress_new_to_done # compress image files image_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(image)/) == 0 } image_files.each do |img| - return false unless FileHelper.compress_image("#{task_dir}#{img}") + # Ensure all images in submissions are not jpg + dest_file = "#{task_dir}#{File.basename(img, ".*")}.jpg" + raise 'Failed to compress an image. Ensure all images are valid.' unless FileHelper.compress_image_to_dest("#{task_dir}#{img}", dest_file, true) + # Cleanup unless the output was the same as the input + FileUtils.rm("#{task_dir}#{img}") unless dest_file == "#{task_dir}#{img}" end # copy all files into zip @@ -698,12 +842,16 @@ def compress_new_to_done end end ensure - FileUtils.rm_rf(task_dir) + FileUtils.rm_rf(task_dir) if rm_task_dir end true end + def copy_done_to(path) + FileUtils.cp zip_file_path_for_done_task, path + end + def clear_in_process in_process_dir = student_work_dir(:in_process, false) if Dir.exist? in_process_dir @@ -734,7 +882,7 @@ def move_done_to_new # # Move folder over from new or done -> in_process returns true on success # - def move_files_to_in_process + def move_files_to_in_process(source_folder = FileHelper.student_work_dir(:new)) # find and clear out old dir in_process_dir = student_work_dir(:in_process, false) @@ -748,25 +896,24 @@ def move_files_to_in_process Dir.chdir(pwd) end - from_dir = student_work_dir(:new, false) + # Zip new submission and store in done files (will remove from_dir) - ensure trailing / + from_dir = File.join(source_folder, id.to_s) + "/" if Dir.exist?(from_dir) # save new files in done folder - return false unless compress_new_to_done + return false unless compress_new_to_done(task_dir: from_dir) end + # Get the zip file path... zip_file = zip_file_path_for_done_task if zip_file && File.exist?(zip_file) - extract_file_from_done FileHelper.student_work_dir(:new), '*', lambda { |_task, to_path, name| + # extract to root in process dir - as it contains the folder in the zip file + extract_file_from_done FileHelper.student_work_dir(:in_process), '*', lambda { |_task, to_path, name| "#{to_path}#{name}" } - return false unless Dir.exist?(from_dir) + return Dir.exist?(in_process_dir) else return false end - - # Move files from new to in process - FileHelper.move_files(from_dir, in_process_dir) - true end def __output_filename__(in_dir, idx, type) @@ -837,7 +984,7 @@ def init(task, is_retry) end def make_pdf - render_to_string(template: '/task/task_pdf.pdf.erb', layout: true) + render_to_string(template: '/task/task_pdf', layout: true) end end @@ -848,24 +995,33 @@ def self.pygments_lang(extn) elsif %w(c h idc).include?(extn) then 'c' elsif ['cpp', 'hpp', 'c++', 'h++', 'cc', 'cxx', 'cp'].include?(extn) then 'cpp' elsif ['java'].include?(extn) then 'java' - elsif ['js'].include?(extn) then 'js' - elsif ['html'].include?(extn) then 'html' - elsif ['css'].include?(extn) then 'css' + elsif %w(js json ts).include?(extn) then 'js' + elsif ['html', 'rhtml'].include?(extn) then 'html' + elsif %w(css scss).include?(extn) then 'css' elsif ['rb'].include?(extn) then 'ruby' elsif ['coffee'].include?(extn) then 'coffeescript' elsif %w(yaml yml).include?(extn) then 'yaml' elsif ['xml'].include?(extn) then 'xml' - elsif ['scss'].include?(extn) then 'scss' - elsif ['json'].include?(extn) then 'json' - elsif ['ts'].include?(extn) then 'ts' elsif ['sql'].include?(extn) then 'sql' elsif ['vb'].include?(extn) then 'vbnet' - elsif ['txt'].include?(extn) then 'text' + elsif ['txt', 'md', 'rmd', 'rpres','hdl','asm','jack','hack','tst','cmp','vm','sh','bat','dat'].include?(extn) then 'text' + elsif ['tex', 'rnw'].include?(extn) then 'tex' elsif ['py'].include?(extn) then 'python' + elsif ['r'].include?(extn) then 'r' else extn end end + def portfolio_evidence_path + # Add the student work dir to the start of the portfolio evidence + File.join(FileHelper.student_work_dir, self.portfolio_evidence) if self.portfolio_evidence.present? + end + + def portfolio_evidence_path=(value) + # Strip the student work directory to store in database as relative path + self.portfolio_evidence = value.present? ? value.sub(FileHelper.student_work_dir,'') : nil + end + def final_pdf_path if group_task? return nil if group_submission.nil? || group_submission.task_definition.nil? @@ -879,8 +1035,9 @@ def final_pdf_path end end - def convert_submission_to_pdf - return false unless move_files_to_in_process + # Convert a submission to pdf - the source folder is the root folder in which the submission folder will be found (not the submission folder itself) + def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) + return false unless move_files_to_in_process(source_folder) begin tac = TaskAppController.new @@ -890,7 +1047,8 @@ def convert_submission_to_pdf pdf_text = tac.make_pdf rescue => e - # Try again... with convert to ascii + # Try again... with convert to ascic + # tac2 = TaskAppController.new tac2.init(self, true) @@ -915,21 +1073,42 @@ def convert_submission_to_pdf end end + # save the final pdf path to portfolio evidence - relative to student work folder if group_task? group_submission.tasks.each do |t| - t.portfolio_evidence = final_pdf_path + t.portfolio_evidence_path = final_pdf_path t.save end reload else - self.portfolio_evidence = final_pdf_path + self.portfolio_evidence_path = final_pdf_path end - File.open(portfolio_evidence, 'w') do |fout| + # Save the file... now using the full path! + File.open(portfolio_evidence_path, 'w') do |fout| fout.puts pdf_text end - FileHelper.compress_pdf(portfolio_evidence) + FileHelper.compress_pdf(portfolio_evidence_path) + + # if the task is the draft learning summary task + if task_definition_id == unit.draft_task_definition_id + # if there is a learning summary, execute, if there isn't and a learning summary exists, don't execute + if project.uses_draft_learning_summary || project.portfolio_files.select {|f| f[:name] == "LearningSummaryReport.pdf"}.empty? + file_name = { + kind: 'document', + name: 'LearningSummaryReport.pdf', + idx: 0 + } + # Creates tmp portfolio path (if it doesn't exist) + portfolio_tmp_dir = project.portfolio_temp_path + FileUtils.mkdir_p(portfolio_tmp_dir) + + FileUtils.cp portfolio_evidence_path, project.portfolio_tmp_file_path(file_name) + project.uses_draft_learning_summary = true + project.save + end + end save @@ -938,7 +1117,7 @@ def convert_submission_to_pdf rescue => e clear_in_process - trigger_transition trigger: 'fix', by_user: project.main_tutor + trigger_transition trigger: 'fix', by_user: project.tutor_for(task_definition) raise e end end @@ -946,7 +1125,7 @@ def convert_submission_to_pdf # # The student has uploaded new work... # - def create_submission_and_trigger_state_change(user, propagate = true, contributions = nil, trigger = 'ready_to_mark') + def create_submission_and_trigger_state_change(user, propagate = true, contributions = nil, trigger = 'ready_for_feedback', initial_task = nil) if group_task? && propagate if contributions.nil? # even distribution contribs = group.projects.map { |proj| { project: proj, pct: 100 / group.projects.count, pts: 3 } } @@ -954,19 +1133,21 @@ def create_submission_and_trigger_state_change(user, propagate = true, contribut contribs = contributions.map { |data| { project: Project.find(data[:project_id]), pct: data[:pct].to_i, pts: data[:pts].to_i } } end group_submission = group.create_submission self, "#{user.name} has submitted work", contribs - group_submission.tasks.each { |t| t.create_submission_and_trigger_state_change(user, propagate = false) } + group_submission.tasks.each { |t| t.create_submission_and_trigger_state_change(user, false, contributions, trigger, self) } reload else self.file_uploaded_at = Time.zone.now self.submission_date = Time.zone.now - # This task is now ready to submit - unless discuss_or_demonstrate? || complete? || do_not_resubmit? || fail? - trigger_transition trigger: trigger, by_user: user, group_transition: false - - plagiarism_match_links.each(&:destroy) - reverse_plagiarism_match_links(&:destroy) + # This task is now ready to submit - trigger a transition if not in final state + unless discuss_or_demonstrate? || complete? || feedback_exceeded? || fail? + trigger_transition trigger: trigger, by_user: user, group_transition: group_task? && initial_task != self end + + # Destroy the links to ensure we test new files + plagiarism_match_links.each(&:destroy) + reverse_plagiarism_match_links(&:destroy) + save end end @@ -974,7 +1155,7 @@ def create_submission_and_trigger_state_change(user, propagate = true, contribut # # Create alignments on submission # - def create_alignments_from_submission(current_user, alignments) + def create_alignments_from_submission(alignments) # Remove existing alignments no longer applicable LearningOutcomeTaskLink.where(task_id: id).delete_all() alignments.each do |alignment| @@ -991,6 +1172,10 @@ def create_alignments_from_submission(current_user, alignments) # # Moves submission into place + # - from -- tmp upload files + # - to "in_process" folder + # + # Checks to make sure that the files match what we expect # def accept_submission(current_user, files, _student, ui, contributions, trigger, alignments) # @@ -998,7 +1183,7 @@ def accept_submission(current_user, files, _student, ui, contributions, trigger, # id, name, filename, type, tempfile # files.each do |file| - ui.error!({ 'error' => "Missing file data for '#{file.name}'" }, 403) if file.id.nil? || file.name.nil? || file.filename.nil? || file.type.nil? || file.tempfile.nil? + ui.error!({ 'error' => "Missing file data for '#{file[:name]}'" }, 403) if file[:id].nil? || file[:name].nil? || file[:filename].nil? || file[:type].nil? || file["tempfile"].nil? end # Ensure group if group task @@ -1010,64 +1195,101 @@ def accept_submission(current_user, files, _student, ui, contributions, trigger, if group_task? && group_submission && group_submission.processing_pdf? && group_submission.submitter_task != self ui.error!({ 'error' => "#{group_submission.submitter_task.project.student.name} has just submitted this task. Only one team member needs to submit this task, so check back soon to see what was uploaded." }, 403) end - # file.key = "file0" - # file.name = front end name for file - # file.tempfile.path = actual file dir - # file.filename = their name for the file + # file[:key] = "file0" + # file[:name] = front end name for file + # file["tempfile"].path = actual file dir + # file[:filename] = their name for the file # # Confirm subtype categories using filemagic # files.each_with_index do |file, index| - logger.debug "Accepting submission (file #{index + 1} of #{files.length}) - checking file type for #{file.tempfile.path}" - unless FileHelper.accept_file(file, file.name, file.type) - ui.error!({ 'error' => "'#{file.name}' is not a valid #{file.type} file" }, 403) + logger.debug "Accepting submission (file #{index + 1} of #{files.length}) - checking file type for #{file["tempfile"].path}" + unless FileHelper.accept_file(file, file[:name], file[:type]) + ui.error!({ 'error' => "'#{file[:name]}' is not a valid #{file[:type]} file" }, 403) end - if File.size(file.tempfile.path) > 5_000_000 - ui.error!({ 'error' => "'#{file.name}' exceeds the 5MB file limit. Try compressing or reformat and submit again." }, 403) + if File.size(file["tempfile"].path) > 10_000_000 + ui.error!({ 'error' => "'#{file[:name]}' exceeds the 10MB file limit. Try compressing or reformat and submit again." }, 403) end end - create_alignments_from_submission(current_user, alignments) unless alignments.nil? - create_submission_and_trigger_state_change(current_user, propagate = true, contributions = contributions, trigger = trigger) + # Ready to accept... so create the submission and update the task status + create_submission_and_trigger_state_change(current_user, true, contributions, trigger, self) + + # Update the alignments - across groups if needed + unless alignments.nil? + if group_task? + ensured_group_submission.propogate_alignments_from_submission(alignments) + else + create_alignments_from_submission(alignments) + end + end # # Create student submission folder (/doubtfire/new/) # tmp_dir = File.join(Dir.tmpdir, 'doubtfire', 'new', id.to_s) - logger.debug "Creating temporary directory for new dubmission at #{tmp_dir}" + logger.debug "Creating temporary directory for new submission at #{tmp_dir}" # ensure the dir exists FileUtils.mkdir_p(tmp_dir) # - # Set portfolio_evidence to nil while it gets processed + # Set portfolio_evidence_path to nil while it gets processed # - portfolio_evidence = nil + self.portfolio_evidence_path = nil files.each_with_index.map do |file, idx| - output_filename = File.join(tmp_dir, "#{idx.to_s.rjust(3, '0')}-#{file.type}#{File.extname(file.filename).downcase}") - FileUtils.cp file.tempfile.path, output_filename + output_filename = File.join(tmp_dir, "#{idx.to_s.rjust(3, '0')}-#{file[:type]}#{File.extname(file[:filename]).downcase}") + FileUtils.cp file["tempfile"].path, output_filename end # - # Now copy over the temp directory over to the enqueued directory + # Now copy over the temp directory over to the enqueued directory (in process) # - enqueued_dir = student_work_dir(:new, self)[0..-2] + enqueued_dir = File.join(FileHelper.student_work_dir(:new, nil, true), id.to_s) - logger.debug "Moving submission evidence from #{tmp_dir} to #{enqueued_dir}" + # Move files into place, deleting existing files if present. + if not File.exist? enqueued_dir + logger.debug "Creating student work new dir #{enqueued_dir}" + FileUtils.mkdir_p enqueued_dir + end - pwd = FileUtils.pwd - # move to tmp dir - Dir.chdir(tmp_dir) - # move all files to the enq dir - FileUtils.mv Dir.glob('*'), enqueued_dir - # FileUtils.rm Dir.glob("*") - # remove the directory - Dir.chdir(pwd) - Dir.rmdir(tmp_dir) + # Move files into place + logger.debug "Moving source files from #{tmp_dir} into #{enqueued_dir}" + FileUtils.mv Dir.glob(File.join(tmp_dir,'*.*')), enqueued_dir, force: true - logger.debug "Submission accepted! Status for task #{id} is now #{trigger}" + # Delete the tmp dir + logger.debug "Deleting student work dir: #{tmp_dir}" + FileUtils.rm_rf tmp_dir if File.exist? tmp_dir + + logger.info "Submission accepted! Status for task #{id} is now #{trigger}" end + + private + def delete_associated_files + if group_submission && group_submission.tasks.count <= 1 + group_submission.destroy + else + zip_file = zip_file_path_for_done_task() + if zip_file && File.exist?(zip_file) + FileUtils.rm zip_file + end + if portfolio_evidence_path.present? && File.exist?(portfolio_evidence_path) + FileUtils.rm portfolio_evidence_path + end + + new_path = FileHelper.student_work_dir(:new, self, false) + if new_path.present? && File.directory?(new_path) + FileUtils.rm_rf new_path + end + end + end + + # Use the current DateTime to calculate a new DateTime for the last moment of the same + # day anywhere on earth + def to_same_day_anywhere_on_earth(date) + DateTime.new(date.year, date.month, date.day, 23, 59, 59, '-12:00') + end end diff --git a/app/models/task_comment.rb b/app/models/task_comment.rb deleted file mode 100644 index 3c858b78a..000000000 --- a/app/models/task_comment.rb +++ /dev/null @@ -1,47 +0,0 @@ -class TaskComment < ActiveRecord::Base - belongs_to :task # Foreign key - belongs_to :user - - belongs_to :recipient, class_name: 'User' - - has_many :comments_read_receipts, class_name: 'CommentsReadReceipts', dependent: :destroy, inverse_of: :task_comment - - validates :task, presence: true - validates :user, presence: true - validates :recipient, presence: true - validates :comment, length: { minimum: 1, maximum: 4095, allow_blank: false } - - def new_for?(user) - CommentsReadReceipts.where(user: user, task_comment_id: self).empty? - end - - def create_comment_read_receipt_entry(user) - comment_read_receipt = CommentsReadReceipts.find_or_create_by(user: user, task_comment: self) - comment_read_receipt.user = user - comment_read_receipt.task_comment = self - comment_read_receipt.save! - end - - def remove_comment_read_entry(user) - CommentsReadReceipts.delete_all(user: user, task_comment: self) - end - - def mark_as_read(user, unit) - if user == task.project.main_tutor - unit.staff.each do |staff_member| - create_comment_read_receipt_entry(staff_member.user) - end - else - create_comment_read_receipt_entry(user) - end - end - - def mark_as_unread(user) - remove_comment_read_entry(user) - end - - def time_read_by(user) - read_reciept = CommentsReadReceipts.find_by(user: user, task_comment: self) - read_reciept.created_at unless read_reciept.nil? - end -end diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7c251f0d7..32a419bf2 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -1,12 +1,26 @@ require 'json' -class TaskDefinition < ActiveRecord::Base +class TaskDefinition < ApplicationRecord + # Record triggers - before associations + after_update do |_td| + clear_related_plagiarism if plagiarism_checks.empty? && has_plagiarism? + end + + before_destroy :delete_associated_files + + after_update :move_files_on_abbreviation_change, if: :saved_change_to_abbreviation? + after_update :remove_old_group_submissions, if: :has_removed_group? + # Model associations - belongs_to :unit # Foreign key - belongs_to :group_set + belongs_to :unit, optional: false # Foreign key + belongs_to :group_set, optional: true + belongs_to :tutorial_stream, optional: true + belongs_to :overseer_image, optional: true + + has_one :draft_task_definition_unit, foreign_key: 'draft_task_definition_id', class_name: 'Unit', dependent: :nullify + has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances has_many :group_submissions, dependent: :destroy # Destroying a task definition will also nuke any group submissions - has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes has_many :learning_outcomes, -> { where('learning_outcome_task_links.task_id is NULL') }, through: :learning_outcome_task_links # only link staff relations @@ -14,34 +28,130 @@ class TaskDefinition < ActiveRecord::Base validates :name, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique validates :abbreviation, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique - validates :target_grade, inclusion: { in: 0..3, message: '%{value} is not a valid target grade' } + validates :target_grade, inclusion: { in: GradeHelper::RANGE, message: '%{value} is not a valid target grade' } validates :max_quality_pts, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10, message: 'must be between 0 and 10' } validates :upload_requirements, length: { maximum: 4095, allow_blank: true } + validate :upload_requirements, :check_upload_requirements_format validates :plagiarism_checks, length: { maximum: 4095, allow_blank: true } + validate :plagiarism_checks, :check_plagiarism_format validates :description, length: { maximum: 4095, allow_blank: true } - after_update do |_td| - clear_related_plagiarism if plagiarism_checks.empty? && has_plagiarism? + validate :ensure_no_submissions, if: :will_save_change_to_group_set_id? + validate :unit_must_be_same + validate :tutorial_stream_present? + + validates :weighting, presence: true + + def unit_must_be_same + if unit.present? and tutorial_stream.present? and not unit.eql? tutorial_stream.unit + errors.add(:unit, "should be same as the unit in the associated tutorial stream") + end + end + + def tutorial_stream_present? + if tutorial_stream.nil? and unit.tutorial_streams.exists? + errors.add(:tutorial_stream, "must be one of the tutorial streams in the unit") + end + end + + # In the rollover process, copy this definition into another unit + # Copy this task into the other unit + def copy_to(other_unit) + new_td = self.dup + + # change the unit... + new_td.unit_id = other_unit.id # for database + new_td.unit = other_unit # for other operations + other_unit.task_definitions << new_td # so we can see it in unit elsewhere + + # Change tutorial stream + new_td.tutorial_stream = other_unit.tutorial_streams.find_by(abbreviation: tutorial_stream.abbreviation) unless tutorial_stream.nil? + + # change group set + if is_group_task? + # Find based upon the group set in the new unit + new_td.group_set = other_unit.group_sets.find_by(name: self.group_set.name) + end + + # Adjust dates + new_td.start_week_and_day = start_week, start_day + new_td.target_week_and_day = target_week, target_day + + if self['due_date'].present? + new_td.due_week_and_day = due_week, due_day + end + + # Ensure we have the dir for the destination task sheet + FileHelper.task_file_dir_for_unit(other_unit, create = true) + + if has_task_sheet? + FileUtils.cp(task_sheet, new_td.task_sheet()) + end + + if has_task_resources? + FileUtils.cp(task_resources, new_td.task_resources) + end + + new_td.save! + + new_td + end + + def has_removed_group? + saved_change_to_group_set_id? && group_set_id.nil? + end + + def ensure_no_submissions + if tasks.where("submission_date IS NOT NULL").count() > 0 + errors.add( :group_set, "Unable to change group status of task as submissions exist" ) + end + end + + def remove_old_group_submissions + if group_set_id.nil? && group_submissions.count > 0 + group_submissions.destroy_all + end + end + + def move_files_on_abbreviation_change + old_abbr = saved_change_to_abbreviation[0] # 0 is original abbreviation + if File.exist? task_sheet_with_abbreviation(old_abbr) + FileUtils.mv(task_sheet_with_abbreviation(old_abbr), task_sheet()) + end + + if File.exist? task_resources_with_abbreviation(old_abbr) + FileUtils.mv(task_resources_with_abbreviation(old_abbr), task_resources()) + end + + if File.exist? task_assessment_resources_with_abbreviation(old_abbr) + FileUtils.mv(task_assessment_resources_with_abbreviation(old_abbr), task_assessment_resources()) + end + end + + def docker_image_name_tag + return nil if overseer_image.nil? + overseer_image.tag end def plagiarism_checks # Read the JSON string in upload_requirements and convert into ruby objects if self['plagiarism_checks'] - JSON.parse(self['plagiarism_checks']) + begin + # Parse into ruby objects + JSON.parse(self['plagiarism_checks']) + rescue + # If this fails return the string - validation should then invalidate this object + self['plagiarism_checks'] + end else + # If it was empty then return an empty array JSON.parse('[]') end end - def plagiarism_checks=(req) - json_data = if req.class == String - # get the ruby objects from the json data - JSON.parse(req) - else - # use the passed in objects - req - end + def check_plagiarism_format() + json_data = self.plagiarism_checks # ensure we have a structure that is : [ { "key": "...", "type": "...", "pattern": "..."}, { ... } ] unless json_data.class == Array @@ -49,25 +159,79 @@ def plagiarism_checks=(req) return end + # Loop through checks in the array i = 0 for req in json_data do + # They must be json objects unless req.class == Hash errors.add(:plagiarism_checks, "is not in a valid format! Should be [ { \"key\": \"...\", \"type\": \"...\", \"pattern\": \"...\"}, { ... } ]. Array did not contain hashes for item #{i + 1}..") return end - req.delete_if { |key, _value| !%w(key type pattern).include? key } - - req['key'] = "check#{i}" - + # They must have these keys... if (!req.key? 'key') || (!req.key? 'type') || (!req.key? 'pattern') errors.add(:plagiarism_checks, "is not in a valid format! Should be [ { \"key\": \"...\", \"type\": \"...\", \"pattern\": \"...\"}, { ... } ]. Missing a key for item #{i + 1}.") return end + # Validate the type (MOSS now, Turnitin later) + if (!req['type'].match(/^moss /)) + errors.add(:plagiarism_checks, "does not have a valid type.") + return + end + + # Check patter to exclude any path separators + if (req['pattern'].match(/(\/)|([.][.])/)) + errors.add(:plagiarism_checks, " pattern contains invalid characters.") + return + end + + # Move to the next check + i += 1 + end + end + + def plagiarism_checks=(req) + begin + json_data = if req.class == String + # get the ruby objects from the json data + JSON.parse(req) + else + # use the passed in objects + req + end + rescue + # Not valid json! + # Save what we have - validation should raise an error + self['plagiarism_checks'] = req + return + end + + # Cant process unless it is an array... + unless json_data.class == Array + # Save what we have - validation should raise an error + self['plagiarism_checks'] = req + return + end + + # Loop through all items in json array + i = 0 + for req in json_data do + unless req.class == Hash + # Cant process if it is not an object - leave for validation to check + next + end + + # Delete any other keys + req.delete_if { |key, _value| !%w(key type pattern).include? key } + + # Add in check key + req['key'] = "check#{i}" + i += 1 end + # Save self['plagiarism_checks'] = JSON.unparse(json_data) self['plagiarism_checks'] = '[]' if self['plagiarism_checks'].nil? end @@ -75,20 +239,22 @@ def plagiarism_checks=(req) def upload_requirements # Read the JSON string in upload_requirements and convert into ruby objects if self['upload_requirements'] - JSON.parse(self['upload_requirements']) + begin + # convert to ruby objects + JSON.parse(self['upload_requirements']) + rescue + # Its not valid json - so return the string and validation should fail this object + self['upload_requirements'] + end else + # Return an empty array as no requirements JSON.parse('[]') end end - def upload_requirements=(req) - json_data = if req.class == String - # get the ruby objects from the json data - JSON.parse(req) - else - # use the passed in objects - req - end + # Validate the format of the upload requirements + def check_upload_requirements_format() + json_data = self.upload_requirements # ensure we have a structure that is : [ { "key": "...", "name": "...", "type": "..."}, { ... } ] unless json_data.class == Array @@ -96,17 +262,16 @@ def upload_requirements=(req) return end + # Checking each upload requirement - i used to index files and for user errors i = 0 for req in json_data do + # Each requirement is a json object unless req.class == Hash errors.add(:upload_requirements, "is not in a valid format! Should be [ { \"key\": \"...\", \"name\": \"...\", \"type\": \"...\"}, { ... } ]. Array did not contain hashes for item #{i + 1}..") return end - req.delete_if { |key, _value| !%w(key name type).include? key } - - req['key'] = "file#{i}" - + # Check we have the keys we need if (!req.key? 'key') || (!req.key? 'name') || (!req.key? 'type') errors.add(:upload_requirements, "is not in a valid format! Should be [ { \"key\": \"...\", \"name\": \"...\", \"type\": \"...\"}, { ... } ]. Missing a key for item #{i + 1}.") return @@ -114,7 +279,48 @@ def upload_requirements=(req) i += 1 end + end + + def upload_requirements=(req) + begin + json_data = if req.class == String + # get the ruby objects from the json data + JSON.parse(req) + else + # use the passed in objects + req + end + rescue + # Not valid json + # Save what we have - validation should raise an error + self['upload_requirements'] = req + return + end + + # cant process unless it is an array + unless json_data.class == Array + self['upload_requirements'] = req + return + end + + # Checking each upload requirement - i used to index files and for user errors + i = 0 + for req in json_data do + # Cant process unless it is a hash + unless req.class == Hash + next + end + + # Delete all other keys... + req.delete_if { |key, _value| !%w(key name type).include? key } + + # Set the 'key' to be the matching file + req['key'] = "file#{i}" + + i += 1 + end + # Save self['upload_requirements'] = JSON.unparse(json_data) self['upload_requirements'] = '[]' if self['upload_requirements'].nil? end @@ -132,18 +338,10 @@ def clear_related_plagiarism rescue end end - - # TODO: Remove once max_pct_similar is deleted - # # Reset the tasks % similar - # logger.debug "Clearing old task percent similar" - # tasks.where("tasks.max_pct_similar > 0").each do |t| - # t.max_pct_similar = 0 - # t.save - # end end - def self.to_csv(task_definitions, options = {}) - CSV.generate(options) do |csv| + def self.to_csv(task_definitions) + CSV.generate() do |csv| csv << csv_columns task_definitions.each do |task_definition| csv << task_definition.to_csv_row @@ -152,29 +350,50 @@ def self.to_csv(task_definitions, options = {}) end def start_week - ((start_date - unit.start_date) / 1.week).floor + unit.week_number(start_date) end def start_day Date::ABBR_DAYNAMES[start_date.wday] end + def start_week_and_day= value + week, day = value + self.start_date = unit.date_for_week_and_day(week, day) + end + def target_week - ((target_date - unit.start_date) / 1.week).floor + unit.week_number(target_date) end def target_day Date::ABBR_DAYNAMES[target_date.wday] end + def target_week_and_day= value + week, day = value + self.target_date = unit.date_for_week_and_day(week, day) + end + + # Override due date to return either the final date of the unit, or the set due date + def due_date + return self['due_date'] if self['due_date'].present? + return unit.end_date #TODO: use nil as default to improve performance + end + def due_week - if due_date - ((due_date - unit.start_date) / 1.week).floor + if due_date.present? + unit.week_number(due_date) else '' end end + def due_week_and_day= value + week, day = value + self.due_date = unit.date_for_week_and_day(week, day) + end + def due_day if due_date Date::ABBR_DAYNAMES[due_date.wday] @@ -183,18 +402,36 @@ def due_day end end + # Update all task dates by date_diff + def propogate_date_changes date_diff + self.start_date += date_diff + self.target_date += date_diff + self.due_date += date_diff unless self.due_date.nil? + self.save! + end + def to_csv_row TaskDefinition.csv_columns - .reject { |col| [:start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :upload_requirements].include? col } + .reject { |col| [:start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :upload_requirements, :plagiarism_checks, :group_set, :tutorial_stream].include? col } .map { |column| attributes[column.to_s] } + - [ upload_requirements.to_json ] + - [ start_week, start_day, target_week, target_day, due_week, due_day ] + [ + plagiarism_checks.to_json, + group_set.nil? ? "" : group_set.name, + upload_requirements.to_json, + start_week, + start_day, + target_week, + target_day, + due_week, + due_day, + tutorial_stream.present? ? tutorial_stream.abbreviation : nil + ] # [target_date.strftime('%d-%m-%Y')] + # [ self['due_date'].nil? ? '' : due_date.strftime('%d-%m-%Y')] end def self.csv_columns - [:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts, :is_graded, :upload_requirements, :start_week, :start_day, :target_week, :target_day, :due_week, :due_day] + [:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts, :is_graded, :plagiarism_warn_pct, :plagiarism_checks, :group_set, :upload_requirements, :start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :tutorial_stream] end def self.task_def_for_csv_row(unit, row) @@ -203,13 +440,14 @@ def self.task_def_for_csv_row(unit, row) new_task = false abbreviation = row[:abbreviation].strip name = row[:name].strip - target_date = unit.date_for_week_and_day row[:target_week].to_i, row[:target_day] + tutorial_stream = unit.tutorial_streams.find_by_abbr_or_name("#{row[:tutorial_stream]}".strip) + target_date = unit.date_for_week_and_day row[:target_week].to_i, "#{row[:target_day]}".strip return [nil, false, "Unable to determine target date for #{abbreviation} -- need week number, and day short text eg. 'Wed'"] if target_date.nil? - start_date = unit.date_for_week_and_day row[:start_week].to_i, row[:start_day] + start_date = unit.date_for_week_and_day row[:start_week].to_i, "#{row[:start_day]}".strip return [nil, false, "Unable to determine start date for #{abbreviation} -- need week number, and day short text eg. 'Wed'"] if start_date.nil? - due_date = unit.date_for_week_and_day row[:due_week].to_i, row[:due_day] + due_date = unit.date_for_week_and_day row[:due_week].to_i, "#{row[:due_day]}".strip result = TaskDefinition.find_by(unit_id: unit.id, abbreviation: abbreviation) @@ -217,7 +455,7 @@ def self.task_def_for_csv_row(unit, row) if result.nil? # Remember creation triggers project task updates... so need correct weight - result = TaskDefinition.find_or_create_by(unit_id: unit.id, name: name, abbreviation: abbreviation) do |td| + result = TaskDefinition.find_or_create_by(unit_id: unit.id, tutorial_stream: tutorial_stream, name: name, abbreviation: abbreviation) do |td| td.target_date = target_date td.start_date = start_date td.weighting = row[:weighting].to_i @@ -228,18 +466,25 @@ def self.task_def_for_csv_row(unit, row) result.name = name result.unit_id = unit.id result.abbreviation = abbreviation - result.description = row[:description] + result.description = "#{row[:description]}".strip result.weighting = row[:weighting].to_i result.target_grade = row[:target_grade].to_i - result.restrict_status_updates = %w(Yes y Y yes true TRUE 1).include? row[:restrict_status_updates] + result.restrict_status_updates = %w(Yes y Y yes true TRUE 1).include? "#{row[:restrict_status_updates]}".strip result.max_quality_pts = row[:max_quality_pts].to_i - result.is_graded = %w(Yes y Y yes true TRUE 1).include? row[:is_graded] + result.is_graded = %w(Yes y Y yes true TRUE 1).include? "#{row[:is_graded]}".strip result.start_date = start_date result.target_date = target_date result.upload_requirements = row[:upload_requirements] result.due_date = due_date - if result.valid? + result.plagiarism_warn_pct = row[:plagiarism_warn_pct].to_i + result.plagiarism_checks = row[:plagiarism_checks] + + if row[:group_set].present? + result.group_set = unit.group_sets.where(name: row[:group_set]).first + end + + if result.valid? && (row[:group_set].blank? || result.group_set.present?) begin result.save rescue @@ -247,8 +492,15 @@ def self.task_def_for_csv_row(unit, row) return [nil, false, 'Failed to save definition due to data error.'] end else - return [nil, false, result.errors.join('. ')] + # delete the task if it was new + result.destroy if new_task + if result.group_set.nil? && row[:group_set].present? + return [nil, false, "Unable to find groupset with name #{row[:group_set]} in unit."] + else + return [nil, false, result.errors.full_messages.join('. ')] + end end + [result, new_task, new_task ? "Added new task definition #{result.abbreviation}." : "Updated existing task #{result.abbreviation}" ] end @@ -257,11 +509,15 @@ def is_group_task? end def has_task_resources? - File.exist? unit.path_to_task_resources(self) + File.exist? task_resources + end + + def has_task_assessment_resources? + File.exist? task_assessment_resources end - def has_task_pdf? - File.exist? unit.path_to_task_pdf(self) + def has_task_sheet? + File.exist? task_sheet end def is_graded? @@ -273,19 +529,48 @@ def has_stars? end def add_task_sheet(file) - FileUtils.mv file, unit.path_to_task_pdf(self) + FileUtils.mv file, task_sheet + end + + def remove_task_sheet() + if has_task_sheet? + FileUtils.rm task_sheet + end end def add_task_resources(file) - FileUtils.mv file, unit.path_to_task_resources(self) + FileUtils.mv file, task_resources + end + + def remove_task_resources() + if has_task_resources? + FileUtils.rm task_resources + end + end + + def add_task_assessment_resources(file) + FileUtils.mv file, task_assessment_resources + # TODO: Use FACL instead in future. + `chmod 755 #{task_assessment_resources}` + end + + def remove_task_assessment_resources() + if has_task_assessment_resources? + FileUtils.rm task_assessment_resources + end end + # Get the path to the task sheet - using the current abbreviation def task_sheet - unit.path_to_task_pdf(self) + task_sheet_with_abbreviation(abbreviation) end def task_resources - unit.path_to_task_resources(self) + task_resources_with_abbreviation(abbreviation) + end + + def task_assessment_resources + task_assessment_resources_with_abbreviation(abbreviation) end def related_tasks_with_files(consolidate_groups = true) @@ -308,4 +593,57 @@ def related_tasks_with_files(consolidate_groups = true) tasks_with_files end + + private + + def delete_associated_files() + remove_task_sheet() + remove_task_resources() + remove_task_assessment_resources() + end + + # Calculate the path to the task sheet using the provided abbreviation + # This allows the path to be calculated on abbreviation change to allow files to + # be moved + def task_sheet_with_abbreviation(abbr) + task_path = FileHelper.task_file_dir_for_unit unit, create = true + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.pdf" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.pdf" + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end + + # Calculate the path to the task sheet using the provided abbreviation + # This allows the path to be calculated on abbreviation change to allow files to + # be moved + def task_resources_with_abbreviation(abbr) + task_path = FileHelper.task_file_dir_for_unit unit, create = true + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.zip" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.zip" + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end + + def task_assessment_resources_with_abbreviation(abbr) + task_path = FileHelper.task_file_dir_for_unit unit, create = true + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}-assessment.zip" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}-assessment.zip" + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end end diff --git a/app/models/task_engagement.rb b/app/models/task_engagement.rb index 162f9da30..9ff726afd 100644 --- a/app/models/task_engagement.rb +++ b/app/models/task_engagement.rb @@ -1,3 +1,3 @@ -class TaskEngagement < ActiveRecord::Base - belongs_to :task +class TaskEngagement < ApplicationRecord + belongs_to :task, optional: false end diff --git a/app/models/task_pin.rb b/app/models/task_pin.rb new file mode 100644 index 000000000..f2f51173c --- /dev/null +++ b/app/models/task_pin.rb @@ -0,0 +1,4 @@ +class TaskPin < ApplicationRecord + belongs_to :task, optional: false + belongs_to :user, optional: false +end diff --git a/app/models/task_status.rb b/app/models/task_status.rb index f88e7d22c..5ef188d73 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -1,40 +1,116 @@ # # The status # - has a name and a description -class TaskStatus < ActiveRecord::Base +class TaskStatus < ApplicationRecord + #TODO: Consider refactoring this class. Is there any point to having this in the database? Could this become an enum? + # Model associations has_many :tasks - scope :not_started, -> { TaskStatus.find(1) } - scope :complete, -> { TaskStatus.find(2) } - scope :need_help, -> { TaskStatus.find(3) } - scope :working_on_it, -> { TaskStatus.find(4) } - scope :fix_and_resubmit, -> { TaskStatus.find(5) } - scope :do_not_resubmit, -> { TaskStatus.find(6) } - scope :redo, -> { TaskStatus.find(7) } - scope :discuss, -> { TaskStatus.find(8) } - scope :ready_to_mark, -> { TaskStatus.find(9) } - scope :demonstrate, -> { TaskStatus.find(10) } - scope :fail, -> { TaskStatus.find(11) } + # + # Override find to ensure that task status objects are cached - these do not change + # + def self.find(id) + Rails.cache.fetch("task_statuses/#{id}", expires_in: 12.hours) do + super + end + end + + def self.not_started + TaskStatus.find(1) + end + + def self.complete + TaskStatus.find(2) + end + + def self.need_help + TaskStatus.find(3) + end + + def self.working_on_it + TaskStatus.find(4) + end + + def self.fix_and_resubmit + TaskStatus.find(5) + end + + def self.feedback_exceeded + TaskStatus.find(6) + end + + def self.redo + TaskStatus.find(7) + end + + def self.discuss + TaskStatus.find(8) + end + + def self.ready_for_feedback + TaskStatus.find(9) + end + + def self.demonstrate + TaskStatus.find(10) + end + + def self.fail + TaskStatus.find(11) + end + + def self.time_exceeded + TaskStatus.find(12) + end + + class << self + # Provide access to the count from the database via a new db_count method + alias_method :db_count, :count + end + + # Return the count (which equals the largest id) - so that other code can loop thought all statuses without database lookup + # + # Make sure to update this if/when you add another status! + # + # Keep this hard coded! Saves cache load time. + # Important: count must equal the largest id in the database + def self.count + 12 + end def self.status_for_name(name) case name.downcase.strip when 'complete' then TaskStatus.complete when 'fix_and_resubmit' then TaskStatus.fix_and_resubmit when 'fix and resubmit' then TaskStatus.fix_and_resubmit - when 'do_not_resubmit' then TaskStatus.do_not_resubmit - when 'do not resubmit' then TaskStatus.do_not_resubmit + when 'fix' then TaskStatus.fix_and_resubmit + when 'f' then TaskStatus.fix + when 'do_not_resubmit' then TaskStatus.feedback_exceeded + when 'do not resubmit' then TaskStatus.feedback_exceeded + when 'feedback_exceeded' then TaskStatus.feedback_exceeded + when 'feedback exceeded' then TaskStatus.feedback_exceeded when 'redo' then TaskStatus.redo when 'need_help' then TaskStatus.need_help when 'need help' then TaskStatus.need_help when 'working_on_it' then TaskStatus.working_on_it when 'working on it' then TaskStatus.working_on_it - when 'discuss' then TaskStatus.discuss - when 'ready to mark' then TaskStatus.ready_to_mark - when 'ready_to_mark' then TaskStatus.ready_to_mark + when 'discuss', 'd' then TaskStatus.discuss + when 'demonstrate' then TaskStatus.demonstrate + when 'demo' then TaskStatus.demonstrate + when 'ready for feedback' then TaskStatus.ready_for_feedback + when 'ready_for_feedback' then TaskStatus.ready_for_feedback + when 'ready to mark' then TaskStatus.ready_for_feedback + when 'ready_to_mark' then TaskStatus.ready_for_feedback + when 'rtm' then TaskStatus.ready_for_feedback + when 'rff' then TaskStatus.ready_for_feedback when 'fail' then TaskStatus.fail - when 'f' then TaskStatus.fail - else TaskStatus.not_started + when 'not_started' then TaskStatus.not_started + when 'not started' then TaskStatus.not_started + when 'ns' then TaskStatus.not_started + when 'time exceeded' then TaskStatus.time_exceeded + when 'time_exceeded' then TaskStatus.time_exceeded + else nil end end @@ -42,24 +118,37 @@ def self.staff_assigned_statuses TaskStatus.where('id > 4') end - def self.status_key_for_name(name) - case name.downcase - when 'complete' then :complete - when 'not started' then :not_started - when 'fix and resubmit' then :fix_and_resubmit - when 'do not resubmit' then :do_not_resubmit - when 'redo' then :redo - when 'need help' then :need_help - when 'working on it' then :working_on_it - when 'discuss' then :discuss - when 'ready to mark' then :ready_to_mark - when 'demonstrate' then :demonstrate - when 'fail' then :fail - else :not_started + def self.id_to_key(id) + case id + when 1 then :not_started + when 2 then :complete + when 3 then :need_help + when 4 then :working_on_it + when 5 then :fix_and_resubmit + when 6 then :feedback_exceeded + when 7 then :redo + when 8 then :discuss + when 9 then :ready_for_feedback + when 10 then :demonstrate + when 11 then :fail + when 12 then :time_exceeded + else :not_started end end def status_key - TaskStatus.status_key_for_name(name) + return :complete if self == TaskStatus.complete + return :not_started if self == TaskStatus.not_started + return :fix_and_resubmit if self == TaskStatus.fix_and_resubmit + return :redo if self == TaskStatus.redo + return :need_help if self == TaskStatus.need_help + return :working_on_it if self == TaskStatus.working_on_it + return :discuss if self == TaskStatus.discuss + return :ready_for_feedback if self == TaskStatus.ready_for_feedback + return :demonstrate if self == TaskStatus.demonstrate + return :fail if self == TaskStatus.fail + return :feedback_exceeded if self == TaskStatus.feedback_exceeded + return :time_exceeded if self == TaskStatus.time_exceeded + return :not_started end end diff --git a/app/models/task_submission.rb b/app/models/task_submission.rb index 8cd7207e7..178f565c4 100644 --- a/app/models/task_submission.rb +++ b/app/models/task_submission.rb @@ -1,4 +1,4 @@ -class TaskSubmission < ActiveRecord::Base - belongs_to :task - belongs_to :assessor, class_name: 'User', foreign_key: 'assessor_id' +class TaskSubmission < ApplicationRecord + belongs_to :task, optional: false + belongs_to :assessor, class_name: 'User', foreign_key: 'assessor_id', optional: true end diff --git a/app/models/teaching_period.rb b/app/models/teaching_period.rb new file mode 100644 index 000000000..334329a24 --- /dev/null +++ b/app/models/teaching_period.rb @@ -0,0 +1,189 @@ +class TeachingPeriod < ApplicationRecord + # Relationships + has_many :units + has_many :breaks, dependent: :delete_all + + # Callbacks - methods called are private + before_destroy :can_destroy? + + # Validations - methods called are private + validates :period, length: { minimum: 1, maximum: 20, allow_blank: false }, uniqueness: { scope: :year, + message: "%{value} already exists in this year" } + validates :year, length: { is: 4, allow_blank: false }, presence: true, numericality: { only_integer: true }, + inclusion: { in: 2000..2999, message: "%{value} is not a valid year" } + validates :start_date, presence: true + validates :end_date, presence: true + validates :active_until, presence: true + + validate :validate_end_date_after_start_date, :validate_active_until_after_end_date + + after_update :propogate_date_changes + + # Public methods + + def add_break(start_date, number_of_weeks) + break_in_teaching_period = Break.new + break_in_teaching_period.start_date = start_date + break_in_teaching_period.number_of_weeks = number_of_weeks + break_in_teaching_period.teaching_period = self + + break_in_teaching_period.save! + # add after save to ensure valid break + self.breaks << break_in_teaching_period + + break_in_teaching_period + end + + def update_break(id, start_date, number_of_weeks) + break_in_teaching_period = breaks.find(id) + + if start_date.present? + break_in_teaching_period.start_date = start_date + end + + if number_of_weeks.present? + break_in_teaching_period.number_of_weeks = number_of_weeks + end + + break_in_teaching_period.save! + break_in_teaching_period + end + + def week_number(date) + # Calcualte date offset, add 2 so 0-week offset is week 1 not week 0 + result = ((date - start_date) / 1.week).floor + 1 + + for a_break in breaks.all do + if date >= a_break.start_date + # we are in or after the break, so calculated week needs to + # be reduced by this break + + if date >= a_break.end_date + # past the end of the break... + result -= a_break.number_of_weeks + elsif date == a_break.start_date + # cant use standard calculation as this give 0 for this exact moment... + result -= 1 if date >= a_break.first_monday + elsif date >= a_break.first_monday + # in break so partial reduction + result -= ((date - a_break.first_monday) / 1.week).ceil + end + + # for times just past the break but before start of next week... + if date >= a_break.end_date && date < a_break.monday_after_break + # Need to add 1 as we are now in a new week! + result += 1 + end + end + end + + result + end + + def date_for_week(num) + num = num.floor + + # start by switching from 1 based to 0 based + # week 1 is offset 0 weeks from the start + num -= 1 + + result = start_date + num.weeks + + # check breaks + for a_break in breaks do + if result >= a_break.start_date + # we are in or after the break, so calculated date is + # extended by the break period + result += a_break.number_of_weeks.weeks + end + end + + result + end + + def date_for_week_and_day(week, day) + return nil if week.nil? || day.nil? + + week_start = date_for_week(week) + + day_num = Date::ABBR_DAYNAMES.index day.titlecase + return nil if day_num.nil? + + start_day_num = start_date.wday + + result = week_start + (day_num - start_day_num).days + + for a_break in breaks do + if result >= a_break.start_date && result < a_break.end_date + # we are in or after the break, so calculated date is + # extended by the break period + result += a_break.number_of_weeks.weeks + end + end + + result + end + + def future_teaching_periods + TeachingPeriod.where("start_date > :end_date", end_date: end_date) + end + + def rollover(rollover_to, search_forward=true,rollover_inactive=false) + if rollover_to.start_date < Time.zone.now || rollover_to.start_date <= start_date + self.errors.add(:base, "Units can only be rolled over to future teaching periods") + + false + else + units_to_rollover = units + + unless rollover_inactive + units_to_rollover = units_to_rollover.where(active: true) + end + + if search_forward + ftp = future_teaching_periods.where("start_date < :date", date: rollover_to.start_date).order(start_date: "desc") + + units_to_rollover = units_to_rollover.map do |u| + ftp.map{|tp| tp.units.where(code: u.code).first }.select{|u| u.present?}.first || u + end + end + + for unit in units_to_rollover do + #skip if the unit already exists in the teaching period + next if rollover_to.units.where(code: unit.code).count > 0 + + unit.rollover(rollover_to, nil, nil) + end + + true + end + end + + private + + def can_destroy? + return true if units.count == 0 + errors.add :base, "Cannot delete teaching period with units" + throw :abort + end + + def validate_active_until_after_end_date + if end_date.present? && active_until.present? && active_until < end_date + errors.add(:active_until, "date should be after the End date") + end + end + + def validate_end_date_after_start_date + if end_date.present? && start_date.present? && end_date < start_date + errors.add(:end_date, "should be after the Start date") + end + end + + def propogate_date_changes + return unless saved_change_to_start_date? || saved_change_to_end_date? + + units.each do |u| + u.update(start_date: self.start_date, end_date: self.end_date) + end + end +end diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 5a04496b1..4a9635fe7 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -1,15 +1,31 @@ -class Tutorial < ActiveRecord::Base +class Tutorial < ApplicationRecord # Model associations - belongs_to :unit # Foreign key - belongs_to :unit_role # Foreign key + belongs_to :unit, optional: false # Foreign key + belongs_to :unit_role, optional: true # Foreign key + belongs_to :campus, optional: true + belongs_to :tutorial_stream, optional: true + has_one :tutor, through: :unit_role, source: :user - has_many :projects, dependent: :nullify # Students - has_many :groups, dependent: :nullify + has_many :groups + has_many :tutorial_enrolments, dependent: :destroy + has_many :projects, through: :tutorial_enrolments + + # Callbacks - methods called are private + before_destroy :can_destroy? validates :abbreviation, uniqueness: { scope: :unit, message: 'must be unique within the unit' } + # Make sure that unit in tutorial and tutorial stream are consistent + validate :unit_must_be_same + + def unit_must_be_same + if unit.present? and tutorial_stream.present? and ! unit.eql? tutorial_stream.unit + errors.add(:unit, "should be same as the unit in the associated tutorial stream") + end + end + def self.default tutorial = new @@ -26,13 +42,10 @@ def self.find_by_user(user) end def tutor - result = UnitRole.find_by(id: unit_role_id) - result.user unless result.nil? + unit_role.user unless unit_role.nil? end def name - # TODO: Will probably need to make this more flexible when - # a tutorial is representing something other than a tutorial "#{meeting_day} #{meeting_time} (#{meeting_location})" end @@ -59,4 +72,13 @@ def assign_tutor(tutor_user) def num_students projects.where('enrolled = true').count end + + private + def can_destroy? + active_enrolment_count = num_students + return true if active_enrolment_count == 0 && groups.count == 0 + errors.add :base, "Cannot delete tutorial with enrolments" if active_enrolment_count > 0 + errors.add :base, "Cannot delete tutorial with groups" if groups.count > 0 + throw :abort + end end diff --git a/app/models/tutorial_enrolment.rb b/app/models/tutorial_enrolment.rb new file mode 100644 index 000000000..353bcd8ad --- /dev/null +++ b/app/models/tutorial_enrolment.rb @@ -0,0 +1,101 @@ +class TutorialEnrolment < ApplicationRecord + belongs_to :tutorial, optional: false + belongs_to :project, optional: false + + has_one :tutorial_stream, through: :tutorial + + validates :tutorial, presence: true + validates :project, presence: true + + # Always add a unique index to the DB to prevent new records from passing the validations when checked at the same time before being written + validates_uniqueness_of :tutorial, :scope => :project, message: 'already exists for the selected student' + + # Ensure only one tutorial stream per stream + validate :ensure_only_one_tutorial_per_stream, on: :create + + # Ensure that student cannot enrol in tutorial of different units + validate :unit_must_be_same + + # Ensure that student cannot enrol in tutorial of different campus + validate :campus_must_be_same + + # If enrolled in tutorial with no stream, cannot enrol again + validate :ensure_cannot_have_more_than_one_enrolment_when_tutorial_stream_is_null, on: :create + + # Only one tutorial enrolment per stream for each project + validate :ensure_max_one_tutorial_enrolment_per_stream + + # Switch from stream to no stream is not allowed + validate :ensure_cannot_enrol_in_tutorial_with_no_stream_when_enrolled_in_stream + + # Ensure in the same tutorial as a group if needed + validate :must_be_in_group_tutorials + + def unit_must_be_same + if project.unit.present? and tutorial.unit.present? and not project.unit.eql? tutorial.unit + errors.add(:project, 'and tutorial belong to different unit') + end + end + + def campus_must_be_same + if project.campus.present? and tutorial.campus.present? and ! project.campus.eql? tutorial.campus + errors.add(:project, 'and tutorial belong to different campus') + end + end + + def ensure_cannot_have_more_than_one_enrolment_when_tutorial_stream_is_null + if project.tutorial_enrolments + .joins(:tutorial) + .where("tutorials.tutorial_stream_id is null") + .count > 0 + errors.add(:project, 'cannot have more than one enrolment when it is enrolled in tutorial with no stream') + end + end + + def ensure_max_one_tutorial_enrolment_per_stream + # It is valid, unless there is a tutorial enrolment record in the DB that is for the same tutorial stream + if project.tutorial_enrolments + .joins(:tutorial) + .where("(tutorials.tutorial_stream_id = :sid #{ self.id.present? ? 'AND (tutorial_enrolments.id <> :id)' : ''})", sid: tutorial.tutorial_stream_id, id: self.id ) + .count > 0 + errors.add(:project, 'already enrolled in a tutorial with same tutorial stream') + end + end + + def ensure_cannot_enrol_in_tutorial_with_no_stream_when_enrolled_in_stream + if project.tutorial_enrolments + .joins(:tutorial) + .where("tutorials.tutorial_stream_id is not null AND :tutorial_stream_id is null AND tutorial_enrolments.id <> :id", tutorial_stream_id: tutorial.tutorial_stream_id, id: id) + .count > 0 + errors.add(:project, 'cannot enrol in tutorial with no stream when enrolled in stream') + end + end + + def ensure_only_one_tutorial_per_stream + if project.tutorial_enrolments. + joins(:tutorial). + where("tutorials.tutorial_stream_id = :sid OR (tutorials.tutorial_stream_id IS NULL AND :sid IS NULL)", sid: tutorial.tutorial_stream_id) + .count > 0 + errors.add(:tutorial_stream, 'already exists for the selected student') + end + end + + # + # Check to see if the student has a valid tutorial + # + def must_be_in_group_tutorials + project.groups.each do |g| + next unless g.limit_members_to_tutorial? + next if tutorial_id == g.tutorial_id + + if g.group_set.allow_students_to_manage_groups + # leave group + g.remove_member(project) + else + errors.add(:groups, "require #{project.student.name} to be in tutorial #{g.tutorial.abbreviation}") + break + end + end + end + +end diff --git a/app/models/tutorial_stream.rb b/app/models/tutorial_stream.rb new file mode 100644 index 000000000..81de5ff40 --- /dev/null +++ b/app/models/tutorial_stream.rb @@ -0,0 +1,48 @@ +class TutorialStream < ApplicationRecord + belongs_to :activity_type, optional: false + belongs_to :unit, optional: false + + # Callbacks - methods called are private + after_create :handle_associated_task_defs + before_destroy :can_destroy?, prepend: true + + has_many :tutorials, dependent: :destroy + has_many :task_definitions, -> { order 'start_date ASC, abbreviation ASC' } + + validates :unit, presence: true + validates :activity_type, presence: true + + # Always add a unique index with uniqueness constraint + # This is to prevent new records from passing the validations when checked at the same time before being written + validates :name, presence: true, uniqueness: { scope: :unit, message: "%{value} already exists in this unit"} + validates :abbreviation, presence: true, uniqueness: { scope: :unit, message: "%{value} already exists in this unit"} + + def self.find_by_abbr_or_name(data) + TutorialStream.find_by(abbreviation: data) || TutorialStream.find_by(name: data) + end + + private + def can_destroy? + return true if task_definitions.empty? + if unit.tutorial_streams.count > 2 + errors.add :base, "cannot be deleted as it has task definitions associated with it, and it is not the last (or second last) tutorial stream" + throw :abort + elsif unit.tutorial_streams.count.eql? 2 + other_tutorial_stream = (self.eql? unit.tutorial_streams.first) ? unit.tutorial_streams.second : unit.tutorial_streams.first + task_definitions.update_all(tutorial_stream_id: other_tutorial_stream.id) + task_definitions.clear + true + elsif unit.tutorial_streams.count.eql? 1 + # Removes all objects from the collection by removing their associations from the join table + task_definitions.clear + true + end + end + + def handle_associated_task_defs + return if unit.task_definitions.empty? or unit.tutorial_streams.count > 1 + if unit.task_definitions.exists? and unit.tutorial_streams.count.eql? 1 + unit.task_definitions.update_all(tutorial_stream_id: id) + end + end +end diff --git a/app/models/unit.rb b/app/models/unit.rb index a66efe99c..05529fb16 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -2,14 +2,16 @@ require 'bcrypt' require 'json' require 'moss_ruby' +require 'csv_helper' +require 'grade_helper' -class Unit < ActiveRecord::Base +class Unit < ApplicationRecord include ApplicationHelper include FileHelper include LogHelper include MimeCheckHelpers + include CsvHelper - validates :description, length: { maximum: 4095, allow_blank: true } # # Permissions around unit data # @@ -26,7 +28,8 @@ def self.permissions :provide_feedback, :download_stats, :download_unit_csv, - :download_grades + :download_grades, + :exceed_capacity ] # What can convenors do with units? @@ -43,7 +46,28 @@ def self.permissions :provide_feedback, :change_project_enrolment, :download_stats, - :download_grades + :download_grades, + :rollover_unit, + :exceed_capacity, + :perform_overseer_assessment_test + ] + + # What can admin do with units? + admin_role_permissions = [ + :get_unit, + :get_students, + :enrol_student, + :upload_csv, + :rollover_unit, + :change_project_enrolment, + :update, + :employ_staff, + :add_tutorial, + :add_task_def, + :download_stats, + :download_unit_csv, + :download_grades, + :exceed_capacity ] # What can other users do with units? @@ -56,6 +80,7 @@ def self.permissions student: student_role_permissions, tutor: tutor_role_permissions, convenor: convenor_role_permissions, + admin: admin_role_permissions, nil: nil_role_permissions } end @@ -67,28 +92,68 @@ def role_for(user) Role.tutor elsif active_projects.where('projects.user_id=:id', id: user.id).count == 1 Role.student + elsif user.has_admin_capability? + Role.admin + else + nil end end - validates :name, :description, :start_date, :end_date, presence: true + # Ensure before destroy is above relations - as this needs to clear main convenor before unit roles are deleted + before_destroy do + update(main_convenor_id: nil) + delete_associated_files + end + + after_update :propogate_date_changes_to_tasks, if: :saved_change_to_start_date? # Model associations. # When a Unit is destroyed, any TaskDefinitions, Tutorials, and ProjectConvenor instances will also be destroyed. + has_many :projects, dependent: :destroy # projects first to remove tasks + has_many :active_projects, -> { where enrolled: true }, class_name: 'Project' + has_many :group_sets, dependent: :destroy # group sets next to remove groups has_many :task_definitions, -> { order 'start_date ASC, abbreviation ASC' }, dependent: :destroy - has_many :projects, dependent: :destroy - has_many :tutorials, dependent: :destroy + has_many :tutorials, dependent: :destroy # tutorials need groups and tasks deleted before it... + has_many :tutorial_streams, dependent: :destroy has_many :unit_roles, dependent: :destroy has_many :learning_outcomes, dependent: :destroy - has_many :tasks, through: :projects - has_many :group_sets, dependent: :destroy - has_many :task_engagements, through: :projects has_many :comments, through: :projects + has_many :tasks, through: :projects + has_many :groups, through: :group_sets + has_many :tutorial_enrolments, through: :tutorials + has_many :group_memberships, through: :groups + has_many :teaching_staff, through: :unit_roles, class_name: 'User', source: 'user' has_many :learning_outcome_task_links, through: :task_definitions + has_many :task_engagements, through: :projects has_many :convenors, -> { joins(:role).where('roles.name = :role', role: 'Convenor') }, class_name: 'UnitRole' has_many :staff, -> { joins(:role).where('roles.name = :role_convenor or roles.name = :role_tutor', role_convenor: 'Convenor', role_tutor: 'Tutor') }, class_name: 'UnitRole' + # Unit has a teaching period + belongs_to :teaching_period, optional: true + + belongs_to :main_convenor, class_name: 'UnitRole', optional: true + + belongs_to :draft_task_definition, class_name: 'TaskDefinition', optional: true + + belongs_to :overseer_image, optional: true + + validates :name, :description, :start_date, :end_date, presence: true + + validates :description, length: { maximum: 4095, allow_blank: true } + + validates :start_date, presence: true + validates :end_date, presence: true + + validates :code, uniqueness: { scope: :teaching_period, message: "%{value} already exists in this teaching period" }, if: :has_teaching_period? + validates :extension_weeks_on_resubmit_request, :numericality => { :greater_than_or_equal_to => 0 } + + validate :validate_end_date_after_start_date + validate :ensure_teaching_period_dates_match, if: :has_teaching_period? + + validate :ensure_main_convenor_is_appropriate + scope :current, -> { current_for_date(Time.zone.now) } scope :current_for_date, ->(date) { where('start_date <= ? AND end_date >= ?', date, date) } scope :not_current, -> { not_current_for_date(Time.zone.now) } @@ -96,6 +161,120 @@ def role_for(user) scope :set_active, -> { where('active = ?', true) } scope :set_inactive, -> { where('active = ?', false) } + def docker_image_name_tag + return nil if overseer_image.nil? + overseer_image.tag + end + + def add_tutorial_stream(name, abbreviation, activity_type) + tutorial_stream = TutorialStream.new + tutorial_stream.name = name + tutorial_stream.abbreviation = abbreviation + tutorial_stream.unit = self + tutorial_stream.activity_type = activity_type + tutorial_stream.save! + + # add after save to ensure valid tutorial stream + self.tutorial_streams << tutorial_stream + + tutorial_stream + end + + def update_tutorial_stream(existing_tutorial_stream, name, abbreviation, activity_type) + existing_tutorial_stream.name = name if name.present? + existing_tutorial_stream.abbreviation = abbreviation if abbreviation.present? + existing_tutorial_stream.activity_type = activity_type if activity_type.present? + existing_tutorial_stream.save! + existing_tutorial_stream + end + + def teaching_period_id=(tp_id) + self.teaching_period = TeachingPeriod.find(tp_id) + super(tp_id) + end + + def teaching_period=(tp) + if tp.present? + write_attribute(:start_date, tp.start_date) + write_attribute(:end_date, tp.end_date) + write_attribute(:teaching_period_id, tp.id) + end + super(tp) + end + + def has_teaching_period? + self.teaching_period.present? + end + + def ensure_teaching_period_dates_match + if read_attribute(:start_date) != teaching_period.start_date + errors.add(:start_date, "should match teaching period date") + end + if read_attribute(:end_date) != teaching_period.end_date + errors.add(:end_date, "should match teaching period date") + end + end + + def ensure_main_convenor_is_appropriate + return if main_convenor_id.nil? + + errors.add(:main_convenor, "must be a staff member from unit") unless id == main_convenor.unit_id + errors.add(:main_convenor, "must be configured to administer unit") unless main_convenor.is_convenor? + errors.add(:main_convenor, "must be capable of administering units - ensure user has appropriate permissions (contact admin staff to update)") unless main_convenor_user.has_convenor_capability? + end + + def validate_end_date_after_start_date + if end_date.present? && start_date.present? && end_date < start_date + errors.add(:end_date, "should be after the Start date") + end + end + + def rollover(teaching_period, start_date, end_date) + new_unit = self.dup + + if teaching_period.present? + new_unit.teaching_period = teaching_period + else + new_unit.start_date = start_date + new_unit.end_date = end_date + end + + # Clear main convenor - do not use old role id + new_unit.main_convenor_id = nil + + new_unit.save! + + # Only employ the main convenor... they can add additional staff + new_unit.employ_staff main_convenor_user, Role.convenor + + # Duplicate tutorial streams + tutorial_streams.each do |tutorial_stream| + new_unit.tutorial_streams << tutorial_stream.dup + end + + # Duplicate group sets - before tasks as some tasks are group tasks + group_sets.each do |group_set| + new_unit.group_sets << group_set.dup + end + + # Duplicate task definitions + task_definitions.each do |td| + td.copy_to(new_unit) + end + + # Duplicate unit learning outcomes + learning_outcomes.each do |learning_outcome| + new_unit.learning_outcomes << learning_outcome.dup + end + + # Duplicate alignments + task_outcome_alignments.each do |align| + align.duplicate_to(new_unit) + end + + new_unit + end + def ordered_ilos learning_outcomes.order(:ilo_number) end @@ -105,7 +284,7 @@ def task_outcome_alignments end def student_tasks - tasks.joins(:task_definition).where('projects.enrolled = TRUE AND projects.target_grade >= task_definitions.target_grade') + tasks.joins(:task_definition).where('projects.enrolled = TRUE') end def self.for_user_admin(user) @@ -134,8 +313,8 @@ def tutors User.teaching(self) end - def main_convenor - convenors.first.user + def main_convenor_user + main_convenor.user end def students @@ -143,85 +322,87 @@ def students end def student_query(limit_to_enrolled) - # Get the number of tasks for each grade... with 1 as minimum to avoid / 0 - task_count = [0, 1, 2, 3].map do |e| - task_definitions.where("target_grade <= #{e}").count + 0.0 - end. map { |e| e == 0 ? 1 : e } - q = projects .joins(:user) .joins('LEFT OUTER JOIN tasks ON projects.id = tasks.project_id') .joins('LEFT JOIN task_definitions ON tasks.task_definition_id = task_definitions.id') .joins('LEFT OUTER JOIN plagiarism_match_links ON tasks.id = plagiarism_match_links.task_id') + .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id') + .joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id') + .joins('LEFT OUTER JOIN tutorial_streams ON tutorials.tutorial_stream_id = tutorial_streams.id') .group( 'projects.id', 'projects.target_grade', + 'projects.task_stats', + 'projects.submitted_grade', 'projects.enrolled', + 'projects.campus_id', 'users.first_name', 'users.last_name', 'users.username', 'users.email', - 'projects.tutorial_id', 'projects.portfolio_production_date', 'projects.compile_portfolio', 'projects.grade', - 'projects.grade_rationale' + 'projects.grade_rationale', ) .select( 'projects.id AS project_id', 'projects.enrolled AS enrolled', + 'projects.task_stats AS task_stats', + 'projects.campus_id AS campus_id', 'users.first_name AS first_name', 'users.last_name AS last_name', 'users.username AS student_id', 'users.email AS student_email', 'projects.target_grade AS target_grade', - 'projects.tutorial_id AS tutorial_id', + 'projects.submitted_grade AS submitted_grade', 'projects.compile_portfolio AS compile_portfolio', 'projects.grade AS grade', 'projects.grade_rationale AS grade_rationale', 'projects.portfolio_production_date AS portfolio_production_date', 'MAX(CASE WHEN plagiarism_match_links.dismissed = FALSE THEN plagiarism_match_links.pct ELSE 0 END) AS plagiarism_match_links_max_pct', - *TaskStatus.all.map { |s| "SUM(CASE WHEN tasks.task_status_id = #{s.id} THEN 1 ELSE 0 END) AS #{s.status_key}_count" } - ) - .where( - 'projects.target_grade >= task_definitions.target_grade OR (task_definitions.target_grade IS NULL)' + # Get tutorial for each stream in unit + *tutorial_streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.id ELSE NULL END) AS tutorial_#{s.id}" }, + # Get tutorial for case when no stream + "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.id ELSE NULL END) AS tutorial" ) .order('users.first_name') q = q.where('projects.enrolled = TRUE') if limit_to_enrolled - q.map do |t| - # puts "#{t.project_id} #{t.first_name} #{t.fail_count} Grade:#{t.grade} Count:#{task_count[t.grade]}" - fail_pct = (t.fail_count / task_count[t.target_grade]).signif(2) - do_not_resubmit_pct = (t.do_not_resubmit_count / task_count[t.target_grade]).signif(2) - redo_pct = (t.redo_count / task_count[t.target_grade]).signif(2) - need_help_pct = (t.need_help_count / task_count[t.target_grade]).signif(2) - working_on_it_pct = (t.working_on_it_count / task_count[t.target_grade]).signif(2) - fix_and_resubmit_pct = (t.fix_and_resubmit_count / task_count[t.target_grade]).signif(2) - ready_to_mark_pct = (t.ready_to_mark_count / task_count[t.target_grade]).signif(2) - discuss_pct = (t.discuss_count / task_count[t.target_grade]).signif(2) - demonstrate_pct = (t.demonstrate_count / task_count[t.target_grade]).signif(2) - complete_pct = (t.complete_count / task_count[t.target_grade]).signif(2) - - not_started_pct = (1 - fail_pct - do_not_resubmit_pct - redo_pct - need_help_pct - working_on_it_pct - fix_and_resubmit_pct - ready_to_mark_pct - discuss_pct - demonstrate_pct - complete_pct).signif(2) + map_stats = lambda {|t| begin t.task_stats.present? ? JSON.parse(t.task_stats) : {} rescue {} end} - { + q.map do |t| + result = { project_id: t.project_id, enrolled: t.enrolled, + campus_id: t.campus_id, first_name: t.first_name, last_name: t.last_name, student_id: t.student_id, student_email: t.student_email, student_name: "#{t.first_name} #{t.last_name}", target_grade: t.target_grade, - tutorial_id: t.tutorial_id, + submitted_grade: t.submitted_grade, compile_portfolio: t.compile_portfolio, grade: t.grade, grade_rationale: t.grade_rationale, max_pct_copy: t.plagiarism_match_links_max_pct, has_portfolio: !t.portfolio_production_date.nil?, - stats: "#{fail_pct}|#{not_started_pct}|#{do_not_resubmit_pct}|#{redo_pct}|#{need_help_pct}|#{working_on_it_pct}|#{fix_and_resubmit_pct}|#{ready_to_mark_pct}|#{discuss_pct}|#{demonstrate_pct}|#{complete_pct}" + stats: map_stats.call(t), + tutorial_enrolments: tutorial_streams.map do |s| + { + stream_abbr: s.abbreviation, + tutorial_id: t["tutorial_#{s.id}"] + } + end } + + if tutorial_streams.empty? + result[:tutorial_enrolments] = [{tutorial_id: t['tutorial']}] + end + result end end @@ -249,10 +430,6 @@ def convenor_email end end - def active_projects - projects.where('enrolled = true') - end - # Adds a staff member for a role in a unit def employ_staff(user, role) old_role = unit_roles.where('user_id=:user_id', user_id: user.id).first @@ -264,111 +441,310 @@ def employ_staff(user, role) new_staff.unit_id = id new_staff.role_id = role.id new_staff.save! + + if main_convenor.nil? + update!(main_convenor_id: new_staff.id) + end + new_staff end end # Adds a user to this project. - def enrol_student(user, tutorial = nil) - tutorial_id = if tutorial.is_a?(Tutorial) - tutorial.id - else - tutorial - end - + def enrol_student(user, campus) # Validates that a student is not already assigned to the unit existing_project = projects.where('user_id=:user_id', user_id: user.id).first if existing_project if existing_project.enrolled == false existing_project.enrolled = true - # If they are part of the unit, update their tutorial if supplied - existing_project.tutorial_id = tutorial_id unless tutorial_id.nil? - existing_project.save + existing_project.campus = campus + existing_project.save! end return existing_project end - # Validates that the tutorial exists for the unit - if !tutorial_id.nil? && tutorials.where('id=:id', id: tutorial_id).count == 0 - return nil - end - - project = Project.create!( + Project.create!( user_id: user.id, unit_id: id, - task_stats: '0.0|1.0|0.0|0.0|0.0|0.0|0.0|0.0|0.0|0.0|0.0' + task_stats: Project::DEFAULT_TASK_STATS, + campus: campus ) - - project.tutorial_id = tutorial_id unless tutorial_id.nil? - project.save - project end def tutorial_with_abbr(abbr) tutorials.where(abbreviation: abbr).first end + # Use institution settings to sync student enrolments + def sync_enrolments + result = Doubtfire::Application.config.institution_settings.sync_enrolments(self) + + return if result.nil? + + puts "#{code} - Import success for #{result[:success].count} students" unless result[:success].count == 0 + puts "#{code} - Skipped #{result[:ignored].count} students" unless result[:ignored].count == 0 + puts "#{code} - Errors #{result[:errors].count} students" unless result[:errors].count == 0 + result[:errors].each do |err| + puts "#{err[:message]} --> #{err[:row]}" + end + puts "---" unless result[:errors].count == 0 + + result + end + # # Imports users into a project from CSV file. - # Format: Unit Code, Student ID,First Name, Surname, email, tutorial - # Expected columns: unit_code, username, first_name, last_name, email, tutorial + # Format: Unit Code, Student ID,First Name, Surname, email, tutorial, campus + # Expected columns: unit_code, username, first_name, last_name, email, tutorial, campus + # def import_users_from_csv(file) - tutorial_cache = {} success = [] errors = [] ignored = [] + result = { + success: success, + ignored: ignored, + errors: errors + } + + csv = CSV.new(File.read(file), headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }] + ) + + # Read the header row to determine what kind of file it is + if csv.header_row? + csv.shift + else + errors << {row: [], message: "Header row missing" } + return + end + + # Check if these headers should be processed by institution file or from DF format + if Doubtfire::Application.config.institution_settings.are_headers_institution_users? csv.headers + import_settings = Doubtfire::Application.config.institution_settings.user_import_settings_for(csv.headers) + else + if tutorial_streams.count > 0 + stream_names = tutorial_stream_abbr.map{|abbr| abbr.downcase } + else + stream_names = ['tutorial'] + end + + # Settings include: + # missing_headers_lambda - lambda to check if row is missing key data + # fetch_row_data_lambda - lambda to convert row from csv to required import data + # replace_existing_tutorial - boolean to indicate if tutorials in csv override ones in doubtfire + import_settings = { + missing_headers_lambda: ->(row) { + missing_headers(row, %w(unit_code username student_id first_name last_name email campus)) + missing_headers(row, stream_names) + }, + fetch_row_data_lambda: ->(row, unit) { + tutorials = [] + + stream_names.each do |stream| + tutorials << row[stream] if row[stream].present? + end + + { + unit_code: row['unit_code'], + username: row['username'], + student_id: row['student_id'], + first_name: row['first_name'], + nickname: nil, + last_name: row['last_name'], + email: row['email'], + enrolled: true, + tutorials: tutorials, + campus: row['campus'] + } + }, + replace_existing_tutorial: true + } + end + + student_list = [] + + # Loop over csv rows converting to hash values CSV.foreach(file, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], converters: [->(i) { i.nil? ? '' : i }, ->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]) do |row| - # Make sure we're not looking at the header or an empty line - next if row['unit_code'] =~ /unit_code/ - - missing = missing_headers(row, %w(unit_code username student_id first_name last_name email tutorial)) + # Check data has headers + missing = import_settings[:missing_headers_lambda].call(row) if missing.count > 0 errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } next end begin - if row['username'].nil? + # Convert to hash... + row_data = import_settings[:fetch_row_data_lambda].call(row, self) + row_data[:row] = row + # Store in list... + student_list << row_data + rescue Exception => e + errors << { row: row, message: e.message } + end + end # for each csv row + + # Now process the listt + sync_enrolment_with(student_list, import_settings, result) + end + + # Sync the unit enrolment details eith the list of enrolment data. The enrolment data + # is a list to hashes. Each hash contains the following: + # -:row the string data associated with the change + # -:username the student username + # -:student_id + # -:first_name + # -:last_name + # -:nickname + # -:email + # -:tutorial_code + # -:enrolled (boolean) + # This will ensure that there is only one listing per student in the data that + # is then used to update the student enrolments. + def sync_enrolment_with(enrolment_data, import_settings, result) + # Get lists for reporting results + success = result[:success] + errors = result[:errors] + ignored = result[:ignored] + + # Record changes ready to process - map on username to ensure only one option per user + # enrol will override withdraw + changes = {} + + # For each row + enrolment_data.each do |row_data| + begin + if row_data[:username].nil? ignored << { row: row, message: "Skipping row with missing username" } next end - unit_code = row['unit_code'] - username = row['username'].downcase - student_id = row['student_id'] - first_name = row['first_name'].nil? ? row['first_name'] : row['first_name'].titleize - last_name = row['last_name'].nil? ? row['last_name'] : row['last_name'].titleize - email = row['email'] - tutorial_code = row['tutorial'] + unit_code = row_data[:unit_code] - if unit_code != code - ignored << { row: row, message: "Invalid unit code. #{unit_code} does not match #{code}" } + # Check it is one of the unit codes + unless code == unit_code || code.split('/').include?(unit_code) + ignored << { row: row_data[:row], message: "Invalid unit code. #{unit_code} does not match #{code}" } next end - if !email =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i + # now record changes... + username = row_data[:username].downcase + + # do we already have this user? + if changes.key? username + if row_data[:enrolled] # they should be enrolled - record that... overriding anything else + # record previous row as ignored + ignored << { row: changes[username][:row], message: "Skipping duplicate role - ensuring enrolled" } + changes[username] = row_data + else + # record this row as skipped + ignored << { row: row_data[:row], message: "Skipping duplicate role" } + end + else #dont have the user so record them - will add to result when processed + changes[username] = row_data + end + rescue Exception => e + errors << { row: row_data[:row], message: e.message } + end + end # for each csv row + + update_student_enrolments(changes, import_settings, result) + end # csv import + + # Apply enrolment changes. The changes parameter should be: + # - A hash + # - Key = student username + # - Value = hash of + # -:row the string data associated with the change + # -:username the student username (also the key to this data) + # -:student_id + # -:first_name + # -:last_name + # -:nickname + # -:email + # -:tutorials array of [tutorial_code, ...] + # -:enrolled + # Import settings is: + # - A hash + # - :replace_existing_tutorial boolean + # + # Returns hash with :success, :ignored, :errors + def update_student_enrolments(changes, import_settings, result) + tutorial_cache = {} + # Get lists for reporting results + success = result[:success] + errors = result[:errors] + ignored = result[:ignored] + + # now apply the changes... + changes.each do |key, row_data| + begin + row = row_data[:row] + username = row_data[:username].downcase + unit_code = row_data[:unit_code] + student_id = row_data[:student_id] + first_name = row_data[:first_name].nil? ? nil : row_data[:first_name].titleize + last_name = row_data[:last_name].nil? ? nil : row_data[:last_name].titleize + nickname = row_data[:nickname].nil? ? nil : row_data[:nickname].titleize + email = row_data[:email] + tutorials = row_data[:tutorials] + campus_data = row_data[:campus] + + # If either first or last name is nil... copy over the other component + first_name = first_name || last_name + last_name = last_name || first_name + nickname = nickname || first_name + + if email !~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i errors << { row: row, message: "Invalid email address (#{email})" } next end - username = username.downcase + # Perform withdraw if needed... + unless row_data[:enrolled] + # Find the user + project_participant = User.where(username: username) + + # If they dont exist... ignore + if project_participant.nil? || project_participant.count == 0 + ignored << { row: row, message: "Ignoring student to withdraw, as not enrolled" } + else + # Get the user's project + user_project = projects.where(user_id: project_participant.first.id).first + # If no project... then not enrolled + if user_project.nil? || !user_project.enrolled + ignored << { row: row, message: "Ignoring student to withdraw, as not enrolled" } + else + # Withdraw... + user_project.enrolled = false + user_project.save + success << { row: row, message: "Student was withdrawn" } + end + end + + # Move to next row as this was a withdraw... + next + end + + # It is an enrolment... so first find the user project_participant = User.find_or_create_by(username: username) do |new_user| new_user.first_name = first_name new_user.last_name = last_name new_user.student_id = student_id - new_user.nickname = first_name + new_user.nickname = nickname new_user.role_id = Role.student_id new_user.email = email new_user.encrypted_password = BCrypt::Password.create('password') end + # If new user then make sure they are saved unless project_participant.persisted? - project_participant.password = 'password' project_participant.save end @@ -376,60 +752,76 @@ def import_users_from_csv(file) # Only import if a valid user - or if save worked # if project_participant.persisted? - if (project_participant.student_id.nil? || project_participant.student_id.empty?) && student_id + # Add in the student id if it was supplied... + if (project_participant.student_id.nil? || project_participant.student_id.empty? || project_participant.student_id != student_id) && student_id.present? project_participant.student_id = student_id project_participant.save! end - user_project = projects.where(user_id: project_participant.id).first + # Clear success message... + success_message = '' - tutorial = tutorial_cache[tutorial_code] || tutorial_with_abbr(tutorial_code) - tutorial_cache[tutorial_code] ||= tutorial + # Now find the project for the user + user_project = projects.where(user_id: project_participant.id).first + campus = Campus.find_by_abbr_or_name(campus_data) # Add the user to the project (if not already in there) if user_project.nil? - if !tutorial.nil? - enrol_student(project_participant, tutorial) - success << { row: row, message: 'Enrolled student with tutorial.' } - else - enrol_student(project_participant) - success << { row: row, message: 'Enrolled student without tutorial.' } - end + # Enrol user... + user_project = enrol_student(project_participant, campus) + success_message = 'Enrolled student' + new_project = true else - # update tutorial - changes = '' - - if user_project.tutorial != tutorial - user_project.tutorial = tutorial - user_project.save - changes << 'Changed tutorial. ' - end - + new_project = false # We are updating existing project + # update enrolment... if currently not enrolled unless user_project.enrolled user_project.enrolled = true user_project.save - changes << 'Changed enrolment.' + success_message << 'Changed enrolment.' + end + # update campus if not provided and available + if user_project.campus_id.nil? && campus.present? + user_project.campus_id = campus.id + user_project.save + success_message << 'Campus updated.' end + end - if changes.empty? - ignored << { row: row, message: 'No change.' } - else - success << { row: row, message: changes } + # Only update if we will change tutorial enrolments... or no enrolment + if import_settings[:replace_existing_tutorial] || new_project || user_project.tutorial_enrolments.count == 0 + + # Now loop through the tutorials and enrol the student... + tutorials.each do |tutorial_code| + # find the tutorial for the user + tutorial = tutorial_cache[tutorial_code] || tutorial_with_abbr(tutorial_code) + tutorial_cache[tutorial_code] ||= tutorial + + if tutorial.present? + # Use tutorial as we have it :) + begin + user_project.enrol_in tutorial + success_message << ' Enrolled in ' << tutorial.abbreviation + rescue Exception => e + success_message << " UNABLE TO enroll in #{tutorial.abbreviation} #{e.message}" + end + end end end + + if ! success_message.empty? + success << { row: row, message: success_message } + else + ignored << { row: row, message: 'No change.' } + end else - errors << { row: row, message: 'Student record is invalid.' } + errors << { row: row, message: "Student record is invalid. #{project_participant.errors.full_messages.first}" } end rescue Exception => e errors << { row: row, message: e.message } end end - { - success: success, - ignored: ignored, - errors: errors - } + result end # Use the values in the CSV to set the enrolment of these @@ -442,9 +834,12 @@ def unenrol_users_from_csv(file) errors = [] ignored = [] - CSV.parse(file, headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| # Make sure we're not looking at the header or an empty line next if row[0] =~ /(username)|(((unit)|(subject))_code)/ # next if row[5] !~ /^LA\d/ @@ -498,11 +893,48 @@ def unenrol_users_from_csv(file) end def export_users_to_csv - CSV.generate do |row| - row << %w(unit_code username student_id first_name last_name email tutorial) - active_projects.each do |project| - row << [project.unit.code, project.student.username, project.student.student_id, project.student.first_name, project.student.last_name, project.student.email, project.tutorial_abbr] - end + streams = tutorial_streams + grp_sets = group_sets + + CSV.generate do |csv| + csv << %w(unit_code campus username student_id preferred_name first_name last_name email) + + (streams.count > 0 ? streams.map{ |t| t.abbreviation } : ['tutorial']) + + active_projects. + joins( + :unit, + :campus, + 'INNER JOIN users ON projects.user_id = users.id', + 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', + 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', + 'LEFT OUTER JOIN tutorials ON tutorial_enrolments.tutorial_id = tutorials.id' + ).select( + 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', + 'users.last_name as last_name', 'users.email as email', 'users.nickname as nickname', 'campuses.abbreviation as campus_abbreviation', + # Get tutorial for each stream in unit + *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, + # Get tutorial for case when no stream + "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial" + ).group( + 'projects.id', 'student_id', 'username', 'first_name', 'nickname', 'last_name', 'email', 'campus_abbreviation' + ).each do |row| + csv << [ + code, + row['campus_abbreviation'], + row['username'], + row['student_id'], + row['nickname'], + row['first_name'], + row['last_name'], + row['email'] + ] + [1].map do + if streams.empty? + [ row['tutorial'] ] + else + streams.map { |ts| row["tutorial_#{ts.id}"] } + end + end.flatten + end end end @@ -522,9 +954,12 @@ def import_outcomes_from_csv(file) ignored: [] } - CSV.parse(file, headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| # Make sure we're not looking at the header or an empty line next if row[0] =~ /unit_code/ @@ -548,9 +983,12 @@ def import_task_alignment_from_csv(file, for_project) errors = [] ignored = [] - CSV.parse(file, headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| # Make sure we're not looking at the header or an empty line next if row[0] =~ /unit_code/ @@ -616,38 +1054,116 @@ def import_task_alignment_from_csv(file, for_project) } end - def missing_headers(row, headers) - headers - row.to_hash.keys + # Import the actual groups from a csv - no students... + def import_groups_from_csv(group_set, file) + success = [] + errors = [] + ignored = [] + + logger.info "Starting import of group for #{group_set.name} for #{code}" + + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + next if row[0] =~ /^(group_name)|(name)/ # Skip header + + begin + missing = missing_headers(row, %w(group_name tutorial capacity_adjustment)) + if missing.count > 0 + errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } + next + end + + change = '' + + # Get name from csv + group_name = row['group_name'].strip unless row['group_name'].nil? + + # Find or create the group object + grp = group_set.groups.find_or_create_by(name: group_name) + + # Find the tutorial + tutorial_abbr = row['tutorial'].strip unless row['tutorial'].nil? + tutorial = tutorial_with_abbr(tutorial_abbr) + + if tutorial.nil? + change += ' Created new tutorial.' + + campus_data = row['campus'].strip unless row['campus'].nil? + campus = Campus.find_by_abbr_or_name(campus_data) + + tutorial = add_tutorial( + 'Monday', + '8:00am', + 'TBA', + main_convenor_user, + campus, + nil, #capacity + tutorial_abbr + ) + end + + # If it is new we need to load details from the csv + if grp.new_record? + # Get group details + change += ' Created new group.' + end + + # Update group details + grp.tutorial = tutorial + grp.capacity_adjustment = row['capacity_adjustment'].strip.to_i unless row['capacity_adjustment'].nil? + grp.save! + + success << { row: row, message: "Setup #{grp.name}.#{change}" } + rescue Exception => e + errors << { row: row, message: e.message } + end + end + + { + success: success, + ignored: ignored, + errors: errors + } end - def import_groups_from_csv(group_set, file) + def import_student_groups_from_csv(group_set, file) success = [] errors = [] ignored = [] logger.info "Starting import of group for #{group_set.name} for #{code}" - CSV.parse(file, headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| next if row[0] =~ /^(group_name)|(name)/ # Skip header begin - missing = missing_headers(row, %w(group_name group_number username tutorial)) + missing = missing_headers(row, %w(group_name username)) if missing.count > 0 errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } next end + # Get name from csv + group_name = row['group_name'].strip unless row['group_name'].nil? + + # Find or create the group object + grp = group_set.groups.find_by(name: group_name) + if row['username'].nil? - ignored << { row: row, message: "Skipping row with missing username" } + ignored << { row: row, message: "#{change}Skipping row with missing username" } next end username = row['username'].downcase.strip unless row['username'].nil? - group_name = row['group_name'].strip unless row['group_name'].nil? - group_number = row['group_number'].strip unless row['group_number'].nil? - tutorial = row['tutorial'].strip unless row['tutorial'].nil? user = User.where(username: username).first @@ -659,34 +1175,17 @@ def import_groups_from_csv(group_set, file) project = students.where('user_id = :id', id: user.id).first if project.nil? - errors << { row: row, message: "Student #{username} not found in unit" } + errors << { row: row, message: "Student #{username} is not enrolled in #{code}" } next end - grp = group_set.groups.find_or_create_by(name: group_name) - - change = '' - - if grp.new_record? - tutorial = tutorial_with_abbr(tutorial) - if tutorial.nil? - errors << { row: row, message: "Tutorial #{tutorial} not found" } - next - end - - change = 'Created new group. ' - grp.tutorial = tutorial - grp.number = group_number - grp.save! + if group_set.keep_groups_in_same_class && !project.enrolled_in?(grp.tutorial) + project.enrol_in(grp.tutorial) end - begin - grp.add_member(project) - rescue Exception => e - errors << { row: row, message: e.message } - next - end - success << { row: row, message: "#{change}Added #{username} to #{grp.name}." } + grp.add_member(project) + + success << { row: row, message: "Added #{username} to #{grp.name}." } rescue Exception => e errors << { row: row, message: e.message } end @@ -699,17 +1198,29 @@ def import_groups_from_csv(group_set, file) } end + # Export all groups in the group set def export_groups_to_csv(group_set) CSV.generate do |row| - row << %w(group_name username tutorial) + row << %w(group_name capacity_adjustment tutorial campus) + group_set.groups.each do |grp| + row << [grp.name, grp.capacity_adjustment, grp.tutorial.abbreviation, grp.tutorial.campus.present? ? grp.tutorial.campus.abbreviation : ''] + end + end + end + + # Export all students in groups + def export_student_groups_to_csv(group_set) + CSV.generate do |row| + row << %w(group_name username) group_set.groups.each do |grp| grp.projects.each do |project| - row << [grp.name, grp.number, project.student.username, grp.tutorial.abbreviation] + row << [grp.name, project.student.username] end end end end + # def import_tutorials_from_csv(file) # CSV.foreach(file) do |row| # next if row[0] =~ /Subject Code/ # Skip header @@ -721,25 +1232,39 @@ def export_groups_to_csv(group_set) # end # end - def add_tutorial(day, time, location, tutor, abbrev) + def add_tutorial(day, time, location, tutor, campus, capacity, abbrev, tutorial_stream=nil) tutor_role = unit_roles.where('user_id=:user_id', user_id: tutor.id).first return nil if tutor_role.nil? || tutor_role.role == Role.student - - Tutorial.create!(unit_id: id, abbreviation: abbrev) do |tutorial| + Tutorial.create!(unit_id: id, campus: campus, capacity: capacity, abbreviation: abbrev) do |tutorial| tutorial.meeting_day = day tutorial.meeting_time = time tutorial.meeting_location = location tutorial.unit_role_id = tutor_role.id + tutorial.tutorial_stream = tutorial_stream unless tutorial_stream.nil? end end + # First day of the week is sunday... def date_for_week_and_day(week, day) return nil if week.nil? || day.nil? - day_num = Date::ABBR_DAYNAMES.index day.titlecase - return nil if day_num.nil? - start_day_num = start_date.wday - start_date + week.weeks + (day_num - start_day_num).days + if teaching_period.present? + teaching_period.date_for_week_and_day(week, day) + else + day_num = Date::ABBR_DAYNAMES.index day.titlecase + return nil if day_num.nil? + start_day_num = start_date.wday + + start_date + week.weeks + (day_num - start_day_num).days + end + end + + def week_number(date) + if teaching_period.present? + teaching_period.week_number(date) + else + ((date - start_date) / 1.week).floor + 1 + end end def import_tasks_from_csv(file) @@ -747,9 +1272,13 @@ def import_tasks_from_csv(file) errors = [] ignored = [] - CSV.parse(file, headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_').to_sym unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_').to_sym unless hdr.nil? }], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }] + ).each do |row| next if row[0] =~ /^(Task Name)|(name)/ # Skip header begin @@ -788,28 +1317,98 @@ def task_definitions_by_grade TaskDefinition.where(unit_id: id).order('target_grade ASC, start_date ASC, abbreviation ASC') end - def task_completion_csv(options = {}) - CSV.generate(options) do |csv| + def tutorial_stream_abbr + tutorial_streams.map{|ts| ts.abbreviation } + end + + def task_completion_csv + task_def_by_grade = task_definitions_by_grade + streams = tutorial_streams + grp_sets = group_sets + + CSV.generate() do |csv| + # Add header row csv << [ 'Student ID', + 'Username', 'Student Name', 'Target Grade', 'Email', 'Portfolio', - 'Tutorial', - 'Tutor' + 'Grade', + 'Rationale', ] + - group_sets.map(&:name) + - task_definitions_by_grade.map do |task_definition| - result = [ task_definition.abbreviation ] - result << "#{task_definition.abbreviation} grade" if task_definition.is_graded? - result << "#{task_definition.abbreviation} stars" if task_definition.has_stars? - result << "#{task_definition.abbreviation} contribution" if task_definition.is_group_task? - result - end.flatten - active_projects.each do |project| - csv << project.task_completion_csv - end + (streams.count > 0 ? streams.map{ |t| t.abbreviation } : ['Tutorial']) + + grp_sets.map(&:name) + + task_def_by_grade.map do |task_definition| + result = [ task_definition.abbreviation ] + result << "#{task_definition.abbreviation} grade" if task_definition.is_graded? + result << "#{task_definition.abbreviation} stars" if task_definition.has_stars? + result << "#{task_definition.abbreviation} contribution" if task_definition.is_group_task? + result + end.flatten + + # Add projects data + # Get the details to fetch for each task definition... + td_select = task_def_by_grade.map do |td| + result = [] + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? + result + end.flatten + + # Query across all projects, joined to task's via definitions to ensure all definitions are covered + active_projects. + joins( + :unit, + 'INNER JOIN users ON projects.user_id = users.id', + 'INNER JOIN task_definitions ON task_definitions.unit_id = units.id', + 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', + 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', + 'LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = tutorial_streams.id OR tutorials.tutorial_stream_id IS NULL)', + 'LEFT OUTER JOIN tasks ON tasks.task_definition_id = task_definitions.id AND projects.id = tasks.project_id', + 'LEFT OUTER JOIN task_statuses ON tasks.task_status_id = task_statuses.id', + 'LEFT OUTER JOIN group_memberships ON group_memberships.project_id = projects.id AND group_memberships.active = TRUE', + 'LEFT OUTER JOIN groups ON groups.id = group_memberships.group_id' + ).select( + 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', + 'users.last_name as last_name', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', + *td_select, + # Get tutorial for each stream in unit + *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, + # Get tutorial for case when no stream + "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial", + *grp_sets.map { |gs| "MAX(CASE WHEN groups.group_set_id = #{gs.id} THEN groups.name ELSE NULL END) AS grp_#{gs.id}" } + ).group( + 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' + ).each do |row| + csv << [ + row['student_id'], + row['username'], + "#{row['first_name']} #{row['last_name']}", + GradeHelper.grade_for(row['target_grade']), + row['email'], + row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], true)), + row['grade'] > 0 ? row['grade'] : nil, + row['grade_rationale'] + ] + [1].map do + if streams.empty? + [ row['tutorial'] ] + else + streams.map { |ts| row["tutorial_#{ts.id}"] } + end + end.flatten + grp_sets.map do |gs| + row["grp_#{gs.id}"] + end + task_def_by_grade.map do |td| + result = [ row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"] ] + result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? + result << row["stars_#{td.id}"] if td.has_stars? + result << row["people_#{td.id}"] if td.is_group_task? + result + end.flatten + end end end @@ -818,21 +1417,19 @@ def task_completion_csv(options = {}) # def get_portfolio_zip(current_user) # Get a temp file path - filename = FileHelper.sanitized_filename("portfolios-#{code}-#{current_user.username}.zip") - result = Tempfile.new(filename) + filename = FileHelper.sanitized_filename("portfolios-#{code}-#{current_user.username}") + result = "#{FileHelper.tmp_file(filename)}.zip" + + return result if File.exist?(result) # Create a new zip - Zip::File.open(result.path, Zip::File::CREATE) do |zip| + Zip::File.open(result, Zip::File::CREATE) do |zip| active_projects.each do |project| # Skip if no portfolio at this time... next unless project.portfolio_available # Add file to zip in grade folder src_path = project.portfolio_path - if project.main_tutor - dst_path = FileHelper.sanitized_path(project.target_grade_desc.to_s, "#{project.student.username}-portfolio (#{project.main_tutor.name})") + '.pdf' - else - dst_path = FileHelper.sanitized_path(project.target_grade_desc.to_s, "#{project.student.username}-portfolio (no tutor)") + '.pdf' - end + dst_path = FileHelper.sanitized_path(project.target_grade_desc.to_s, "#{project.student.username}-portfolio (#{project.tutors_and_tutorial})") + '.pdf' # copy into zip zip.add(dst_path, src_path) @@ -846,12 +1443,14 @@ def get_portfolio_zip(current_user) # def get_task_resources_zip # Get a temp file path - filename = FileHelper.sanitized_filename("task-resources-#{code}.zip") - result = Tempfile.new(filename) + result = FileHelper.tmp_file("task-resources-#{code}.zip") + + return result if File.exist?(result) + # Create a new zip - Zip::File.open(result.path, Zip::File::CREATE) do |zip| + Zip::File.open(result, Zip::File::CREATE) do |zip| task_definitions.each do |td| - if td.has_task_pdf? + if td.has_task_sheet? dst_path = FileHelper.sanitized_filename(td.abbreviation.to_s) + '.pdf' zip.add(dst_path, td.task_sheet) end @@ -865,18 +1464,52 @@ def get_task_resources_zip result end + # + # Create a temp zip file with all submission PDFs for a task + # + def get_task_submissions_pdf_zip(current_user, td) + # Get a temp file path + result = FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-pdfs.zip") + + tasks_with_files = td.related_tasks_with_files + + return result if File.exist?(result) + + # Create a new zip + Zip::File.open(result, Zip::File::CREATE) do |zip| + Dir.mktmpdir do |dir| + # Extract all of the files... + tasks_with_files.each do |task| + path_part = if td.is_group_task? && task.group + task.group.name.to_s + else + task.student.username.to_s + end + + FileUtils.cp task.portfolio_evidence_path, File.join(dir, path_part.to_s) + '.pdf' + end # each task + + # Copy files into zip + zip_root_path = "#{td.abbreviation}-pdfs" + FileHelper.recursively_add_dir_to_zip(zip, dir, zip_root_path) + end # mktmpdir + end # zip + result + end + # # Create a temp zip file with all submissions for a task # def get_task_submissions_zip(current_user, td) # Get a temp file path - filename = FileHelper.sanitized_filename("submissions-#{code}-#{td.abbreviation}-#{current_user.username}.zip") - result = Tempfile.new(filename) + result = FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-files.zip") tasks_with_files = td.related_tasks_with_files + return result if File.exist?(result) + # Create a new zip - Zip::File.open(result.path, Zip::File::CREATE) do |zip| + Zip::File.open(result, Zip::File::CREATE) do |zip| Dir.mktmpdir do |dir| # Extract all of the files... tasks_with_files.each do |task| @@ -890,7 +1523,7 @@ def get_task_submissions_zip(current_user, td) ->(_task, to_path, name) { File.join(to_path.to_s, path_part, name.to_s) }) # call FileUtils.mv Dir.glob("#{dir}/#{path_part}/#{task.id}/*"), File.join(dir, path_part.to_s) - FileUtils.rm_r "#{dir}/#{path_part}/#{task.id}" + FileUtils.rm_r "#{dir}/#{path_part}/#{task.id}" if File.directory?("#{dir}/#{path_part}/#{task.id}") end # each task # Copy files into zip @@ -937,17 +1570,6 @@ def tasks_for_definition(task_def) tasks.where(task_definition_id: task_def.id) end - # - # Update the student's max_pct_similar for all of their tasks - # - def update_student_max_pct_similar - # TODO: Remove once max_pct_similar is deleted - # projects.each do | p | - # p.max_pct_similar = p.tasks.maximum(:max_pct_similar) - # p.save - # end - end - def create_plagiarism_link(t1, t2, match) plk1 = PlagiarismMatchLink.where(task_id: t1.id, other_task_id: t2.id).first plk2 = PlagiarismMatchLink.where(task_id: t2.id, other_task_id: t1.id).first @@ -1039,9 +1661,6 @@ def update_plagiarism_stats end # end of each result end # for each task definition where it needs to be updated - # TODO: Remove once max_pct_similar is deleted - # update_student_max_pct_similar() - self.last_plagarism_scan = Time.zone.now save! @@ -1190,32 +1809,6 @@ def import_task_files_from_zip(zip_file) result end - def path_to_task_resources(task_def) - task_path = FileHelper.task_file_dir_for_unit self, create = true - - result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(task_def.abbreviation)}.zip" - result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(task_def.abbreviation)}.zip" - - if File.exist? result_with_sanitised_path - result_with_sanitised_path - else - result_with_sanitised_file - end - end - - def path_to_task_pdf(task_def) - task_path = FileHelper.task_file_dir_for_unit self, create = true - - result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(task_def.abbreviation)}.pdf" - result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(task_def.abbreviation)}.pdf" - - if File.exist? result_with_sanitised_path - result_with_sanitised_path - else - result_with_sanitised_file - end - end - # # Returns the task ids provided mapped to the number of unresolved # plagiarism detections @@ -1232,41 +1825,74 @@ def map_task_ids_to_similarity_count(task_ids) def tasks_as_hash(data) task_ids = data.map(&:task_id).uniq plagiarism_counts = map_task_ids_to_similarity_count(task_ids) - puts plagiarism_counts.inspect data.map do |t| { id: t.task_id, project_id: t.project_id, task_definition_id: t.task_definition_id, tutorial_id: t.tutorial_id, - status: TaskStatus.status_key_for_name(t.status_name), + status: TaskStatus.id_to_key(t.status_id), completion_date: t.completion_date, submission_date: t.submission_date, times_assessed: t.times_assessed, grade: t.grade, quality_pts: t.quality_pts, num_new_comments: t.number_unread, - similar_to_count: plagiarism_counts[t.task_id] + similar_to_count: plagiarism_counts[t.task_id], + pinned: t.pinned, + has_extensions: t.has_extensions } end end + def tutorial_enrolment_subquery + tutorial_enrolments. + joins(:tutorial). + select('tutorials.tutorial_stream_id as tutorial_stream_id', 'tutorials.id as tutorial_id', 'project_id').to_sql + end + # # Return all tasks from the database for this unit and given user # def get_all_tasks_for(user) - student_tasks - .joins(:task_status) - .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id") - .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}") - .select( - 'SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread', 'project_id', 'tasks.id as task_id', - 'task_definition_id', 'task_definitions.start_date as start_date', 'projects.tutorial_id as tutorial_id', 'task_statuses.name as status_name', 'task_statuses.id', - 'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'tasks.grade as grade', 'quality_pts' - ) - .group( - 'task_statuses.id', 'project_id', 'tutorial_id', 'tasks.id', 'task_definition_id', 'task_definitions.start_date', 'status_name', - 'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'grade', 'quality_pts' + student_tasks. + joins(:task_status). + joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)"). + joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')"). + joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}"). + joins("LEFT JOIN task_pins ON task_pins.task_id = tasks.id AND task_pins.user_id = #{user.id}"). + select( + 'sq.tutorial_id AS tutorial_id', + 'sq.tutorial_stream_id AS tutorial_stream_id', + 'tasks.id', + "SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread", + 'COUNT(distinct task_pins.task_id) != 0 as pinned', + "SUM(case when task_comments.date_extension_assessed IS NULL AND task_comments.type = 'ExtensionComment' AND NOT task_comments.id IS NULL THEN 1 ELSE 0 END) > 0 as has_extensions", + 'project_id', + 'tasks.id as task_id', + 'task_definition_id', + 'task_definitions.start_date as start_date', + 'task_statuses.id as status_id', + 'completion_date', + 'times_assessed', + 'submission_date', + 'tasks.grade as grade', + 'quality_pts' + ). + group( + 'sq.tutorial_id', + 'sq.tutorial_stream_id', + 'task_statuses.id', + 'project_id', + 'tasks.id', + 'task_definition_id', + 'task_definitions.start_date', + 'status_id', + 'completion_date', + 'times_assessed', + 'submission_date', + 'grade', + 'quality_pts' ) end @@ -1283,7 +1909,7 @@ def tasks_awaiting_feedback(user) # Return the tasks that should be listed under a tutor's task inbox. # # Thses tasks are: - # - those that have the ready for feedback (rtm) state, or + # - those that have the ready for feedback (rff) state, or # - where new student comments are > 0 # # They are sorted by a task's "action_date". This defines the last @@ -1292,8 +1918,8 @@ def tasks_awaiting_feedback(user) # def tasks_for_task_inbox(user) get_all_tasks_for(user) - .having('task_statuses.id IN (:ids) OR SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) > 0', ids: [ TaskStatus.ready_to_mark, TaskStatus.need_help ]) - .order('start_date ASC, task_definition_id ASC, submission_date ASC, MAX(task_comments.created_at) ASC') + .having('task_statuses.id IN (:ids) OR COUNT(task_pins.task_id) > 0 OR SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) > 0', ids: [ TaskStatus.ready_for_feedback, TaskStatus.need_help ]) + .order('pinned DESC, submission_date ASC, MAX(task_comments.created_at) ASC, task_definition_id ASC') end # @@ -1307,16 +1933,19 @@ def tasks_for_task_inbox(user) # task_def_id => { ... } # def task_status_stats - data = student_tasks - .joins(:task_status) - .select('projects.tutorial_id as tutorial_id', 'task_definition_id', 'task_statuses.name as status_name', 'COUNT(tasks.id) as num_tasks') - .where('task_status_id > 1') - .group('projects.tutorial_id', 'tasks.task_definition_id', 'task_statuses.name') - .map do |r| + data = student_tasks. + joins(:task_status). + joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id'). + joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = task_definitions.tutorial_stream_id OR tutorials.tutorial_stream_id IS NULL)'). + select('tutorials.tutorial_stream_id AS stream_id', 'tutorial_enrolments.tutorial_id AS tutorial_id', 'task_definition_id', 'task_statuses.id as status_id', 'COUNT(tasks.id) as num_tasks'). + where('task_status_id > 1'). + group('stream_id', 'tutorial_id', 'tasks.task_definition_id', 'status_id'). + map do |r| { + tutorial_stream_id: r.stream_id, tutorial_id: r.tutorial_id, task_definition_id: r.task_definition_id, - status: TaskStatus.status_key_for_name(r.status_name), + status: TaskStatus.id_to_key(r.status_id), num: r.num_tasks } end @@ -1332,6 +1961,7 @@ def task_status_stats next unless num - count > 0 data << { + tutorial_stream_id: t.tutorial_stream_id, tutorial_id: t.id, task_definition_id: td.id, status: :not_started, @@ -1351,7 +1981,7 @@ def task_status_stats result[e[:task_definition_id] ] [e[:tutorial_id]] = [] end - result[e[:task_definition_id]][e[:tutorial_id]] << { status: e[:status], num: e[:num] } + result[e[:task_definition_id]][e[:tutorial_id]] << { tutorial_stream_id: e[:tutorial_stream_id], status: e[:status], num: e[:num] } end result @@ -1362,7 +1992,12 @@ def task_status_stats # aiming for a grade in this indicated unit. # def student_target_grade_stats - data = active_projects.select('projects.tutorial_id, projects.target_grade, COUNT(projects.id) as num').group('projects.tutorial_id, projects.target_grade').order('projects.tutorial_id, projects.target_grade').map { |r| { tutorial_id: r.tutorial_id, grade: r.target_grade, num: r.num } } + data = active_projects. + joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id'). + joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id'). + select('tutorials.tutorial_stream_id as tutorial_stream_id, tutorial_enrolments.tutorial_id as tutorial_id, projects.target_grade, COUNT(projects.id) as num').group('tutorial_enrolments.tutorial_id, tutorials.tutorial_stream_id, projects.target_grade'). + order('tutorial_enrolments.tutorial_id, projects.target_grade'). + map { |r| { tutorial_id: r.tutorial_id, tutorial_stream_id: r.tutorial_stream_id, grade: r.target_grade, num: r.num } } end # @@ -1378,11 +2013,13 @@ def self.active_units # def _student_task_completion_data_base data = student_tasks - .select('projects.tutorial_id as tutorial_id', 'projects.target_grade as target_grade', 'tasks.project_id', 'Count(tasks.id) as num') + .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = tasks.project_id') + .joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = task_definitions.tutorial_stream_id OR tutorials.tutorial_stream_id IS NULL)') + .select('tutorials.tutorial_stream_id as tutorial_stream_id', 'tutorial_enrolments.tutorial_id as tutorial_id', 'projects.target_grade as target_grade', 'tasks.project_id', 'Count(tasks.id) as num') .where('task_status_id = :complete', complete: TaskStatus.complete.id) - .group('projects.tutorial_id', 'projects.target_grade', 'tasks.project_id') - .order('projects.tutorial_id') - data.map { |r| { tutorial_id: r.tutorial_id, grade: r.target_grade, project: r.project_id, num: r.num } } + .group('tutorial_enrolments.tutorial_id', 'tutorials.tutorial_stream_id', 'projects.target_grade', 'tasks.project_id') + .order('tutorial_enrolments.tutorial_id') + data.map { |r| { tutorial_id: r.tutorial_id, tutorial_stream_id: r.tutorial_stream_id, grade: r.target_grade, project: r.project_id, num: r.num } } end def _calculate_task_completion_stats(data) @@ -1425,8 +2062,6 @@ def _calculate_task_completion_stats(data) def student_task_completion_stats data = _student_task_completion_data_base - puts data - result = {} result[:unit] = _calculate_task_completion_stats(data) result[:tutorial] = {} @@ -1436,7 +2071,7 @@ def student_task_completion_stats result[:tutorial][t.id] = _calculate_task_completion_stats(data.select { |r| r[:tutorial_id] == t.id }) end - for i in 0..3 do + for i in GradeHelper::RANGE do result[:grade][i] = _calculate_task_completion_stats(data.select { |r| r[:grade] == i }) end @@ -1451,18 +2086,21 @@ def student_ilo_progress_stats data = student_tasks .joins(task_definition: :learning_outcome_task_links) .joins(:task_status) - .select('projects.tutorial_id, projects.id as project_id, task_statuses.name as status_name, task_definitions.target_grade, learning_outcome_task_links.learning_outcome_id, learning_outcome_task_links.rating, COUNT(tasks.id) as num') + .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id') + .joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = task_definitions.tutorial_stream_id OR tutorials.tutorial_stream_id IS NULL)') + .select('tutorials.tutorial_stream_id as tutorial_stream_id, tutorial_enrolments.tutorial_id as tutorial_id, projects.id as project_id, task_statuses.id as status_id, task_definitions.target_grade, learning_outcome_task_links.learning_outcome_id, learning_outcome_task_links.rating, COUNT(tasks.id) as num') .where('projects.started = TRUE AND learning_outcome_task_links.task_id is NULL') - .group('projects.tutorial_id, projects.id, task_statuses.name, task_definitions.target_grade, learning_outcome_task_links.learning_outcome_id, learning_outcome_task_links.rating') - .order('projects.tutorial_id, projects.id') + .group('tutorial_enrolments.tutorial_id, tutorials.tutorial_stream_id, projects.id, task_statuses.id, task_definitions.target_grade, learning_outcome_task_links.learning_outcome_id, learning_outcome_task_links.rating') + .order('tutorial_enrolments.tutorial_id, projects.id') .map do |r| { project_id: r.project_id, tutorial_id: r.tutorial_id, + tutorial_stream_id: r.tutorial_stream_id, learning_outcome_id: r.learning_outcome_id, rating: r.rating, grade: r.target_grade, - status: TaskStatus.status_key_for_name(r.status_name), + status: TaskStatus.id_to_key(r.status_id), num: r.num } end @@ -1474,9 +2112,10 @@ def student_ilo_progress_stats working_on_it: 0.0, need_help: 0.0, redo: 0.1, - do_not_resubmit: 0.1, + feedback_exceeded: 0.1, fix_and_resubmit: 0.3, - ready_to_mark: 0.5, + time_exceeded: 0.5, + ready_for_feedback: 0.7, discuss: 0.8, demonstrate: 0.8, complete: 1.0 @@ -1617,8 +2256,8 @@ def student_grades_csv end # Used to calculate the number of assessment each tutor has performed - def tutor_assessment_csv(options = {}) - CSV.generate(options) do |csv| + def tutor_assessment_csv + CSV.generate() do |csv| csv << [ 'Username', 'Tutor Name', @@ -1626,7 +2265,7 @@ def tutor_assessment_csv(options = {}) ] tasks - .joins(project: [ { tutorial: { unit_role: :user } } ]) + .joins(project: [ {tutorial_enrolments: { tutorial: { unit_role: :user } } } ]) .select('users.username', 'users.first_name', 'users.last_name', 'SUM(times_assessed) AS total') .group('users.username', 'users.first_name', 'users.last_name') .each do |r| @@ -1662,12 +2301,12 @@ def generate_batch_task_zip(user, tasks) # Reject all tasks not for this unit... tasks = tasks.reject { |task| task.project.unit.id != id } - download_id = "#{Time.new.strftime('%Y-%m-%d')}-#{code}-#{user.username}" - filename = FileHelper.sanitized_filename("batch_ready_to_mark_#{user.username}.zip") - output_zip = Tempfile.new(filename) + output_zip = FileHelper.tmp_file("batch_ready_for_feedback_#{code}_#{user.username}.zip") + + return result if File.exist?(output_zip) # Create a new zip - Zip::File.open(output_zip.path, Zip::File::CREATE) do |zip| + Zip::File.open(output_zip, Zip::File::CREATE) do |zip| csv_str = mark_csv_headers # Add individual tasks... @@ -1680,12 +2319,12 @@ def generate_batch_task_zip(user, tasks) mark_col = if task.status == :need_help 'need_help' else - 'rtm' + 'rff' end - csv_str << "\n#{student.username.tr(',', '_')},#{student.name.tr(',', '_')},#{task.project.tutorial.abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_by(task.project.student).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",#{mark_col},,,#{task.task_definition.max_quality_pts}," + csv_str << "\n#{student.username.tr(',', '_')},#{student.name.tr(',', '_')},#{task.project.tutorial_for(task.task_definition).abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_by(task.project.student).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",#{mark_col},,,#{task.task_definition.max_quality_pts}," - src_path = task.portfolio_evidence + src_path = task.portfolio_evidence_path next if src_path.nil? || src_path.empty? next unless File.exist? src_path @@ -1705,9 +2344,9 @@ def generate_batch_task_zip(user, tasks) # Add to the template entry string grp = task.group next if grp.nil? - csv_str << "\nGRP_#{grp.id}_#{subm.id},#{grp.name.tr(',', '_')},#{grp.tutorial.abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_not_by(user).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",rtm,,#{task.task_definition.max_quality_pts}," + csv_str << "\nGRP_#{grp.id}_#{subm.id},#{grp.name.tr(',', '_')},#{grp.tutorial.abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_not_by(user).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",rff,,#{task.task_definition.max_quality_pts}," - src_path = task.portfolio_evidence + src_path = task.portfolio_evidence_path next if src_path.nil? || src_path.empty? next unless File.exist? src_path @@ -1826,7 +2465,7 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) task.grade_task(task_entry['new grade']) # try to grade task if need be if !(task_entry['new comment'].nil? || task_entry['new comment'].empty?) - task.add_comment user, task_entry['new comment'] + task.add_text_comment user, task_entry['new comment'] success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } success << { row: {}, message: "Added comment to #{task.task_definition.abbreviation} for #{owner_text}" } else @@ -1868,13 +2507,11 @@ def upload_batch_task_zip_or_csv(user, file) errors = [] ignored = [] - type = mime_type(file.tempfile.path) - - puts "type #{type}" + type = mime_type(file["tempfile"].path) # check mime is correct before uploading accept = ['text/', 'text/plain', 'text/csv', 'application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] - unless mime_in_list?(file.tempfile.path, accept) + unless mime_in_list?(file["tempfile"].path, accept) errors << { row: {}, message: "File given is not a zip or csv file - detected #{type}" } return { success: success, @@ -1884,7 +2521,7 @@ def upload_batch_task_zip_or_csv(user, file) end if type.start_with?('text/', 'text/plain', 'text/csv') - update_task_status_from_csv(user, File.open(file.tempfile.path).read, success, ignored, errors) + update_task_status_from_csv(user, File.open(file["tempfile"].path).read, success, ignored, errors) else # files are extracted to a temp dir first i = 0 @@ -1900,7 +2537,7 @@ def upload_batch_task_zip_or_csv(user, file) FileUtils.mkdir_p(tmp_dir) begin - Zip::File.open(file.tempfile.path) do |zip| + Zip::File.open(file["tempfile"].path) do |zip| # Find the marking file within the directory tree marking_file = zip.glob('**/marks.csv').first @@ -1936,70 +2573,70 @@ def upload_batch_task_zip_or_csv(user, file) # Copy over the updated/marked files to the file system zip.each do |file| # Skip processing marking file - next if File.basename(file.name) == 'marks.csv' || File.basename(file.name) == 'readme.txt' + next if File.basename(file[:name]) == 'marks.csv' || File.basename(file[:name]) == 'readme.txt' # Test filename pattern - if (/.*-\d+.pdf/i =~ File.basename(file.name)) != 0 - if file.name[-1] != '/' - ignored << { row: "File #{file.name}", message: 'Does not appear to be a task PDF.' } + if (/.*-\d+.pdf/i =~ File.basename(file[:name])) != 0 + if file[:name][-1] != '/' + ignored << { row: "File #{file[:name]}", message: 'Does not appear to be a task PDF.' } end next end - if (/\._.*/ =~ File.basename(file.name)) == 0 - ignored << { row: "File #{file.name}", message: 'Does not appear to be a task PDF.' } + if (/\._.*/ =~ File.basename(file[:name])) == 0 + ignored << { row: "File #{file[:name]}", message: 'Does not appear to be a task PDF.' } next end # Extract the id from the filename - task_id_from_filename = File.basename(file.name, '.pdf').split('-').last + task_id_from_filename = File.basename(file[:name], '.pdf').split('-').last task = Task.find_by(id: task_id_from_filename) if task.nil? - ignored << { row: "File #{file.name}", message: 'Unable to find associated task.' } + ignored << { row: "File #{file[:name]}", message: 'Unable to find associated task.' } next end # Ensure that this task's id is inside entry_data task_entry = entry_data.select { |t| t['task'] == task.task_definition.abbreviation.tr(',', '_') && t['username'] == task.project.user.username }.first if task_entry.nil? - # error!({"error" => "File #{file.name} has a mismatch of task id ##{task.id} (this task id does not exist in marks.csv)"}, 403) - errors << { row: "File #{file.name}", message: "Task id #{task.id} not in marks.csv" } + # error!({"error" => "File #{file[:name]} has a mismatch of task id ##{task.id} (this task id does not exist in marks.csv)"}, 403) + errors << { row: "File #{file[:name]}", message: "Task id #{task.id} not in marks.csv" } next end if task.unit != self - errors << { row: "File #{file.name}", message: 'This task does not relate to this unit.' } + errors << { row: "File #{file[:name]}", message: 'This task does not relate to this unit.' } next end # Can the user assess this task? unless AuthorisationHelpers.authorise? user, task, :put - errors << { row: "File #{file.name}", error: "You do not have permission to assess task with id #{task.id}" } + errors << { row: "File #{file[:name]}", error: "You do not have permission to assess task with id #{task.id}" } next end # Read into the task's portfolio_evidence path the new file - tmp_file = File.join(tmp_dir, File.basename(file.name)) - task.portfolio_evidence = task.final_pdf_path + tmp_file = File.join(tmp_dir, File.basename(file[:name])) + task.portfolio_evidence_path = task.final_pdf_path # get file out of zip... to tmp_file file.extract(tmp_file) { true } # copy tmp_file to dest - if FileHelper.copy_pdf(tmp_file, task.portfolio_evidence) + if FileHelper.copy_pdf(tmp_file, task.portfolio_evidence_path) if task.group.nil? - success << { row: "File #{file.name}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } + success << { row: "File #{file[:name]}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } else - success << { row: "File #{file.name}", message: "Replace PDF of group task #{task.task_definition.abbreviation} for #{task.group.name}" } + success << { row: "File #{file[:name]}", message: "Replace PDF of group task #{task.task_definition.abbreviation} for #{task.group.name}" } end FileUtils.rm tmp_file else - errors << { row: "File #{file.name}", message: 'The file does not appear to be a valid PDF.' } + errors << { row: "File #{file[:name]}", message: 'The file does not appear to be a valid PDF.' } next end end end rescue - # FileUtils.cp(file.tempfile.path, Doubtfire::Application.config.student_work_dir) + # FileUtils.cp(file["tempfile"].path, Doubtfire::Application.config.student_work_dir) raise end @@ -2015,6 +2652,7 @@ def upload_batch_task_zip_or_csv(user, file) end def send_weekly_status_emails(summary_stats) + return unless send_notifications summary_stats[:unit] = self summary_stats[:unit_week_comments] = comments.where("task_comments.created_at > :start AND task_comments.created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count @@ -2023,18 +2661,20 @@ def send_weekly_status_emails(summary_stats) summary_stats[:revert] = {} summary_stats[:staff] = {} + days_to_end_of_unit = (end_date.to_date - DateTime.now).to_i + days_from_start_of_unit = (DateTime.now - start_date.to_date).to_i + + return if days_from_start_of_unit < 4 || days_to_end_of_unit < 0 + staff.each do |ur| summary_stats[:revert][ur.user] = [] end - days_to_end_of_unit = (end_date.to_date - DateTime.now).to_i - days_from_start_of_unit = (DateTime.now - start_date.to_date).to_i - active_projects.each do |project| project.send_weekly_status_email(summary_stats, days_from_start_of_unit > 28 && days_to_end_of_unit > 14 ) end - summary_stats[:num_students_without_tutors] = active_projects.where(tutorial_id: nil).count + summary_stats[:num_students_without_tutors] = active_projects.joins('LEFT OUTER JOIN tutorial_enrolments on tutorial_enrolments.project_id = projects.id').where('tutorial_enrolments.tutorial_id' => nil).count staff.each do |ur| ur.populate_summary_stats(summary_stats) @@ -2045,6 +2685,25 @@ def send_weekly_status_emails(summary_stats) end summary_stats[:staff] = {} + end + +private + def delete_associated_files + FileUtils.rm_rf FileHelper.unit_dir(self) + FileUtils.rm_rf FileHelper.unit_portfolio_dir(self) + FileUtils.cd FileHelper.student_work_dir + end + + def propogate_date_changes_to_tasks + return unless saved_change_to_start_date? + + # Get the time from the old start date to the new start date. + # using... new - old ... if moved forward in time new > old + # so diff is positive and added to each task definition moves task definitions forward + date_diff = saved_change_to_start_date[1] - saved_change_to_start_date[0] + task_definitions.each do |td| + td.propogate_date_changes date_diff + end end end diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 67db7a04c..51a5722d8 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -1,9 +1,9 @@ -class UnitRole < ActiveRecord::Base +class UnitRole < ApplicationRecord # Model associations - belongs_to :unit # Foreign key - belongs_to :user # Foreign key + belongs_to :unit, optional: false # Foreign key + belongs_to :user, optional: false # Foreign key - belongs_to :role # Foreign key + belongs_to :role, optional: false # Foreign key has_many :tutorials, class_name: 'Tutorial', dependent: :nullify has_many :projects, through: :tutorials @@ -15,22 +15,21 @@ class UnitRole < ActiveRecord::Base validates :user_id, presence: true validates :role_id, presence: true - scope :tutors, -> { joins(:role).where('roles.name = :role', role: 'Tutor') } - scope :convenors, -> { joins(:role).where('roles.name = :role', role: 'Convenor') } - # scope :staff, -> { where('role_id != ?', 1) } + validate :ensure_valid_user_for_role + validate :ensure_convenor, if: :is_main_convenor? - def self.for_user(user) - UnitRole.joins(:role, :unit).where("user_id = :user_id and roles.name <> 'Student'", user_id: user.id) + before_destroy do + if is_main_convenor? + errors.add :base, 'Cannot delete this role as the user is the main contact for the unit' + throw :abort + end end - # unit roles are now unique for users in units - # TODO: check this usage - def other_roles - [] - end + scope :tutors, -> { joins(:role).where('roles.name = :role', role: 'Tutor') } + scope :convenors, -> { joins(:role).where('roles.name = :role', role: 'Convenor') } def tasks_awaiting_feedback - tasks.joins(:task_definition).where('projects.enrolled = TRUE AND projects.target_grade >= task_definitions.target_grade AND tasks.task_status_id = :status', status: TaskStatus.ready_to_mark) + tasks.joins(:task_definition).where('projects.enrolled = TRUE AND projects.target_grade >= task_definitions.target_grade AND tasks.task_status_id = :status', status: TaskStatus.ready_for_feedback) end def oldest_task_awaiting_feedback @@ -119,7 +118,7 @@ def populate_summary_stats(summary_stats) data[:engagements] = task_engagements. where( - "task_engagements.engagement_time >= :start AND task_engagements.engagement_time < :end", + "task_engagements.engagement_time >= :start AND task_engagements.engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]) data[:total_engagements_count] = task_engagements.count @@ -135,8 +134,8 @@ def populate_summary_stats(summary_stats) data[:number_of_students] = number_of_students - data[:total_staff_engagements] = task_engagements.where(engagement: [TaskStatus.complete.name, TaskStatus.do_not_resubmit.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]).count - data[:staff_engagements] = data[:engagements].where(engagement: [TaskStatus.complete.name, TaskStatus.do_not_resubmit.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]).count + data[:total_staff_engagements] = task_engagements.where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]).count + data[:staff_engagements] = data[:engagements].where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]).count data[:received_comments] = comments.where("recipient_id = :staff_id AND task_comments.created_at > :start", staff_id: data[:staff].id, start: Time.zone.today - 7.days).count data[:sent_comments] = comments.where("task_comments.user_id = :staff_id AND task_comments.created_at > :start", staff_id: data[:staff].id, start: Time.zone.today - 7.days).count @@ -150,4 +149,20 @@ def send_weekly_status_email(summary_stats) NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now end + + def ensure_valid_user_for_role + if is_convenor? + errors.add :user, 'must have a role that id able to administer units (request admin to adjust user role)' unless user.has_convenor_capability? + else + errors.add :user, 'must have a role that id able to teach units (request admin to adjust user role)' unless user.has_tutor_capability? + end + end + + def is_main_convenor? + unit.main_convenor_id == id + end + + def ensure_convenor + errors.add(:user, 'must retain current role to administer units as they are currently the main contact for the unit') unless is_convenor? + end end diff --git a/app/models/user.rb b/app/models/user.rb index fb25407a1..fd939b914 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,19 +1,25 @@ require 'bcrypt' require 'authorisation_helpers' -class User < ActiveRecord::Base +# Modify the string class to fix the titilize issue where +# names could be stripped on import. Eg a blank name entered as "-" +# +# encoding: utf-8 +class String + def titleize() + result = ActiveSupport::Inflector.titleize(self) + return self if self.present? && result.blank? + return result + end +end + +class User < ApplicationRecord include AuthenticationHelpers ### # Authentication ### - # Auth token encryption settings - attr_encrypted :auth_token, - key: Doubtfire::Application.secrets.secret_key_attr, - encode: true, - attribute: 'authentication_token' - # User authentication config if AuthenticationHelpers.aaf_auth? # @@ -50,6 +56,21 @@ def valid_jwt?(jws) devise strategy, *devise_keys end + # + # We incorporate password details for local dev server - needed to keep devise happy + # + def password + 'password' + end + + def password_confirmation + 'password' + end + + def password= (value) + self.encrypted_password = BCrypt::Password.create(value) + end + # # Authenticates a user against a piece of data # @@ -63,76 +84,42 @@ def authenticate?(data) end end - # - # Extends an existing auth_token if needed - # - def extend_authentication_token(remember) - # Extending a nil token will just create one first - if auth_token.nil? - generate_authentication_token! false - return - end - - # Default expire time - expiry_time = Time.zone.now + 2.hours - - # Extended expiry times only apply to students and convenors - if remember - student_expiry_time = Time.zone.now + 2.weeks - tutor_expiry_time = Time.zone.now + 1.week - expiry_time = - if role == Role.student || role == :student - student_expiry_time - elsif role == Role.tutor || role == :tutor - tutor_expiry_time - else - expiry_time - end - end - - self.auth_token_expiry = expiry_time - save - end - # # Force-generates a new authentication token, regardless of whether or not # it is actually expired # - def generate_authentication_token!(remember) - # Loop until new unique auth token is found - token = loop do - token = Devise.friendly_token - break token unless User.find_by_auth_token(token) - end - # Set and return new auth token - self.auth_token = token - extend_authentication_token(remember) - save - token + def generate_authentication_token!(remember = false) + # Ensure this user is saved... so it has an id + self.save unless self.persisted? + AuthToken.generate(self, remember) end # # Generate an authentication token that will expire in 30 seconds # def generate_temporary_authentication_token! - generate_authentication_token!(false) - self.auth_token_expiry = Time.zone.now + 30.seconds + # Ensure this user is saved... so it has an id + self.save unless self.persisted? + AuthToken.generate(self, false, Time.zone.now + 30.seconds) end # - # Deletes authentication token + # Returns whether the authentication token has expired # - def reset_authentication_token! - self.auth_token = nil - self.auth_token_expiry = Time.zone.now - 1.week - save + def authentication_token_expired? + auth_token_expiry.nil? || auth_token_expiry <= Time.zone.now end # - # Returns whether the authentication token has expired + # Returns authentication of the user # - def authentication_token_expired? - auth_token_expiry.nil? || auth_token_expiry <= Time.zone.now + def token_for_text?(a_token) + self.auth_tokens.each do |token| + if a_token == token.authentication_token + return token + end + end + return nil end ### @@ -140,16 +127,18 @@ def authentication_token_expired? ### # Model associations - belongs_to :role # Foreign Key + belongs_to :role, optional: false # Foreign Key has_many :unit_roles, dependent: :destroy has_many :projects + has_many :auth_tokens + has_one :webcal, dependent: :destroy # Model validations/constraints validates :first_name, presence: true validates :last_name, presence: true validates :role_id, presence: true validates :username, presence: true, uniqueness: { case_sensitive: false } - validates :email, presence: true, uniqueness: { case_sensitive: false } + validates :email, presence: true, uniqueness: { case_sensitive: false }, format: {with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i} validates :student_id, uniqueness: true, allow_nil: true # Queries @@ -161,12 +150,12 @@ def self.teaching(unit) User.joins(:unit_roles).where('unit_roles.unit_id = :unit_id and ( unit_roles.role_id = :tutor_role_id or unit_roles.role_id = :convenor_role_id) ', unit_id: unit.id, tutor_role_id: Role.tutor_id, convenor_role_id: Role.convenor_id) end - def username=(name) - # strip S or s from start of ids in the form S1234567 or S123456X - truncate_s_match = (name =~ /^[Ss]\d{6,10}([Xx]|\d)$/) - name[0] = '' if !truncate_s_match.nil? && truncate_s_match.zero? - self[:username] = name.downcase - end + # def username=(name) + # # strip S or s from start of ids in the form S1234567 or S123456X + # truncate_s_match = (name =~ /^[Ss]\d{6,10}([Xx]|\d)$/) + # name[0] = '' if !truncate_s_match.nil? && truncate_s_match.zero? + # self[:username] = name.downcase + # end def has_student_capability? true @@ -262,7 +251,14 @@ def self.permissions :admin_units, :admin_users, :convene_units, - :download_stats + :download_stats, + :handle_teaching_period, + :handle_campuses, + :handle_activity_types, + :get_teaching_periods, + :rollover, + :admin_overseer, + :use_overseer ] # What can convenors do with users? @@ -277,17 +273,21 @@ def self.permissions :create_unit, :act_tutor, :convene_units, - :download_stats + :download_stats, + :get_teaching_periods, + :use_overseer ] # What can tutors do with users? tutor_role_permissions = [ :act_tutor, - :download_unit_csv + :download_unit_csv, + :get_teaching_periods ] # What can students do with users? student_role_permissions = [ + :get_teaching_periods ] @@ -345,6 +345,11 @@ def email_required? false end + # Get all of the currently valid auth tokens + def valid_auth_tokens + auth_tokens.where("auth_token_expiry > :now", now: Time.zone.now) + end + def name fn = first_name.split(' ').first # fn = nickname @@ -398,9 +403,12 @@ def self.import_from_csv(current_user, file) errors = [] ignored = [] - CSV.parse(file, headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_') unless hdr.nil? } ], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + data = FileHelper.read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_') unless hdr.nil? } ], + converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| next if row[0] =~ /(email)|(username)/ begin @@ -427,7 +435,7 @@ def self.import_from_csv(current_user, file) next unless pass_checks - if !email =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i + unless email =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i errors << { row: row, message: "Invalid email address (#{email})" } next end @@ -459,7 +467,6 @@ def self.import_from_csv(current_user, file) # will not be persisted initially as password cannot be blank - so can check # which were created using this - will persist changes imported if user.new_record? - user.password = 'password' user.save! success << { row: row, message: "Added user #{username} as #{role}." } else diff --git a/app/models/webcal.rb b/app/models/webcal.rb new file mode 100644 index 000000000..820d62fc7 --- /dev/null +++ b/app/models/webcal.rb @@ -0,0 +1,163 @@ +require 'icalendar' + +class Webcal < ApplicationRecord + + belongs_to :user, optional: false + + has_many :webcal_unit_exclusions, dependent: :destroy + + # + # Array of valid units by which task reminders (alarms) can be set. + # Documented at https://tools.ietf.org/html/rfc5545#section-3.3.6 + # + def self.valid_time_units + %w(W D H M) + end + + # + # Represents the presence of `reminder_time` and `reminder_unit`. + # + def reminder? + reminder_time.present? && reminder_unit.present? + end + + # + # Retrieves `TaskDefinition`s that must be included in the generation of this webcal. + # Eager loads all associations used by the `Webcal.to_ical` method. + # Currently executes in just 1 SQL query! + # + def task_definitions + TaskDefinition + .joins(:unit, unit: :projects) + .eager_load(:tasks) + .includes(:unit, :tasks, unit: :projects) + .where( + projects: { user_id: user_id }, + units: { active: true } + ) + .where.not( + units: { id: WebcalUnitExclusion.where(webcal_id: id).select(:unit_id) } # exclude :webcal_unit_exclusions + ) + .where('tasks.project_id is null or tasks.project_id = projects.id') # eager_load only :tasks of :projects + .where('? BETWEEN units.start_date AND units.end_date', Time.zone.now) # Current units + .where('task_definitions.target_grade <= projects.target_grade') # only :tasks of the targeted_grade or lower + end + + # + # Retrieves the event name for the specified task definition in the calendar. + # Valid values for `variant` are, + # - 'start' retrieves the name for the _start event_ + # - 'end' (default) retrieves the name for the _end event_ + # + def event_name_for_task_definition(task_def, variant = 'end') + name = "#{task_def.unit.code}: #{task_def.abbreviation}: #{task_def.name}" + case variant + when 'start' then "Start: #{name}" + when 'end' then (include_start_dates ? "End: #{name}" : name) + end + end + + # + # Generates a single `Icalendar::Calendar` object from this `Webcal` including calendar events for the specified + # collection of `TaskDefinition`s. + # + # The `unit` property of each `TaskDefinition` is accessed; ensure it is included to prevent N+1 selects. For example, + # + # to_ical_with_task_definitions( + # TaskDefinition + # .joins(:unit) + # .includes(:unit) + # ) + # + def to_ical(task_defs = task_definitions) + ical = Icalendar::Calendar.new + ical.publish + ical.prodid = Doubtfire::Application.config.institution[:product_name] + + # Add iCalendar events for the specified definition. + task_defs.each do |td| + # Notes: + # - Start and end dates of events are equal because the calendar event is expected to be an "all-day" event. + # - iCalendar clients identify events across syncs by their UID property, which is currently the task definition + # ID prefixed with S- or E- based on whether it is a start or end event. + + ev_date_format = '%Y%m%d' + ev_reminders = reminder? + ev_reminder_trigger = "-PT#{reminder_time}#{reminder_unit}" + + # Add event for start date, if the user opted in. + if include_start_dates + ical.event do |ev| + ev.uid = "S-#{td.id}" + ev.summary = event_name_for_task_definition(td, 'start') + ev.status = 'CONFIRMED' + ev.dtstart = ev.dtend = Icalendar::Values::Date.new(td.start_date.strftime(ev_date_format)) + + Webcal.add_metadata_to_ical_event(ev, td) + + if ev_reminders + ev.alarm do |a| + a.action = 'DISPLAY' + a.description = ev.summary + a.trigger = ev_reminder_trigger + end + end + end + end + + # Add event for target/extended date. + ical.event do |ev| + ev.uid = "E-#{td.id}" + ev.summary = event_name_for_task_definition(td, 'end') + ev.status = 'CONFIRMED' + ev.dtstart = ev.dtend = Icalendar::Values::Date.new(Webcal.end_date_for_task_definition(td).strftime(ev_date_format)) + + Webcal.add_metadata_to_ical_event(ev, td) + + if ev_reminders + ev.alarm do |a| + a.action = 'DISPLAY' + a.description = ev.summary + a.trigger = ev_reminder_trigger + end + end + end + end + + # Specify refresh interval. + refresh_interval = Icalendar::Values::Duration.new('1D') + # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/1fc7b244-ecd1-4d28-ac0c-2bb4df855a1f + ical.append_custom_property('X-PUBLISHED-TTL', refresh_interval) + # https://tools.ietf.org/html/rfc7986#section-5.7 + ical.append_custom_property('REFRESH-INTERVAL', refresh_interval) + + ical + end + + # + # Returns the target/extended date for the specified task definition. + # + def self.end_date_for_task_definition(task_def) + ev_date = task_def.target_date + ev_date += (task_def.tasks.first.extensions * 7).day if task_def.tasks.present? + return ev_date + end + + # + # Hydrates `Icalendar::Event`s with Doutbfire-specific metadata. + # + def self.add_metadata_to_ical_event(event, task_def) + event.append_custom_property('X-DOUBTFIRE-UNIT', task_def.unit.id.to_s) + event.append_custom_property('X-DOUBTFIRE-TASK', task_def.id.to_s) + end + + # + # Retrieves Doubtfire-specific metadata from `Icalendar::Event`s previously hydrated via `add_metadata_to_ical_event`. + # + def self.get_metadata_for_ical_event(event) + return { + unit_id: event.custom_property('X-DOUBTFIRE-UNIT').first.to_i, + task_definition_id: event.custom_property('X-DOUBTFIRE-TASK').first.to_i, + } + end +end diff --git a/app/models/webcal_unit_exclusion.rb b/app/models/webcal_unit_exclusion.rb new file mode 100644 index 000000000..2cbb5d57c --- /dev/null +++ b/app/models/webcal_unit_exclusion.rb @@ -0,0 +1,4 @@ +class WebcalUnitExclusion < ApplicationRecord + belongs_to :webcal, optional: false + belongs_to :unit, optional: false +end diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb deleted file mode 100644 index f8788ab6f..000000000 --- a/app/serializers/group_serializer.rb +++ /dev/null @@ -1,3 +0,0 @@ -class GroupSerializer < ActiveModel::Serializer - attributes :id, :name, :tutorial_id, :group_set_id, :number -end diff --git a/app/serializers/group_set_serializer.rb b/app/serializers/group_set_serializer.rb deleted file mode 100644 index 3190767f3..000000000 --- a/app/serializers/group_set_serializer.rb +++ /dev/null @@ -1,6 +0,0 @@ -class GroupSetSerializer < ActiveModel::Serializer - attributes :id, :name, - :allow_students_to_create_groups, - :allow_students_to_manage_groups, - :keep_groups_in_same_class -end diff --git a/app/serializers/learning_outcome_serializer.rb b/app/serializers/learning_outcome_serializer.rb deleted file mode 100644 index e8bc3dfae..000000000 --- a/app/serializers/learning_outcome_serializer.rb +++ /dev/null @@ -1,4 +0,0 @@ - -class LearningOutcomeSerializer < ActiveModel::Serializer - attributes :id, :ilo_number, :abbreviation, :name, :description -end diff --git a/app/serializers/learning_outcome_task_link_serializer.rb b/app/serializers/learning_outcome_task_link_serializer.rb deleted file mode 100644 index 042b9375c..000000000 --- a/app/serializers/learning_outcome_task_link_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -class LearningOutcomeTaskLinkSerializer < ActiveModel::Serializer - attributes :id, - :description, - :rating, - :learning_outcome_id, - :task_definition_id, - :task_id -end diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb deleted file mode 100644 index 00317f5fb..000000000 --- a/app/serializers/project_serializer.rb +++ /dev/null @@ -1,107 +0,0 @@ -require 'task_serializer' - -# Shallow serialization is used for student... -class ShallowProjectSerializer < ActiveModel::Serializer - attributes :unit_id, :unit_code, :unit_name, - :project_id, :student_name, - :tutor_name, :target_grade, - :has_portfolio, :start_date - - def project_id - object.id - end -end - -class ProjectSerializer < ActiveModel::Serializer - attributes :unit_id, - :project_id, - :student_id, - :started, - :stats, - :student_name, - :tutor_name, - :tutorial_id, - :burndown_chart_data, - :enrolled, - :target_grade, - :portfolio_files, - :compile_portfolio, - :portfolio_available, - :grade, - :grade_rationale, - :tasks - - def project_id - object.id - end - - def student_name - "#{object.student.name}#{object.student.nickname.nil? ? '' : ' (' << object.student.nickname << ')'}" - end - - def student_id - object.student.username - end - - def tutor_name - object.main_tutor.first_name unless object.main_tutor.nil? - end - - def stats - object.task_stats - end - - def tasks - object.task_details_for_shallow_serializer(Thread.current[:user]) - end - - has_many :groups, serializer: GroupSerializer - has_many :task_outcome_alignments, serializer: LearningOutcomeTaskLinkSerializer - - def my_role_obj - object.role_for(Thread.current[:user]) if Thread.current[:user] - end - - def include_grade? - ([ Role.convenor, :convenor, Role.tutor, :tutor ].include? my_role_obj) - end - - def include_grade_rationale? - ([ Role.convenor, :convenor, Role.tutor, :tutor ].include? my_role_obj) - end - - def filter(keys) - keys.delete :grade unless include_grade? - keys.delete :grade_rationale unless include_grade_rationale? - keys - end -end - -class GroupMemberProjectSerializer < ActiveModel::Serializer - attributes :student_id, :project_id, :student_name, :target_grade - - def project_id - object.id - end - - def student_id - object.student.username - end - - def student_name - "#{object.student.name}#{object.student.nickname.nil? ? '' : ' (' << object.student.nickname << ')'}" - end - - def my_role_obj - object.role_for(Thread.current[:user]) if Thread.current[:user] - end - - def include_student_id? - ([ Role.convenor, Role.tutor, :tutor, :convenor ].include? my_role_obj) - end - - def filter(keys) - keys.delete :student_id unless include_student_id? - keys - end -end diff --git a/app/serializers/role_serializer.rb b/app/serializers/role_serializer.rb deleted file mode 100644 index f7756d6af..000000000 --- a/app/serializers/role_serializer.rb +++ /dev/null @@ -1,3 +0,0 @@ -class RoleSerializer < ActiveModel::Serializer - attributes :name, :description -end diff --git a/app/serializers/task_comment_serializer.rb b/app/serializers/task_comment_serializer.rb deleted file mode 100644 index 8abede576..000000000 --- a/app/serializers/task_comment_serializer.rb +++ /dev/null @@ -1,19 +0,0 @@ -class TaskCommentSerializer < ActiveModel::Serializer - attributes :id, :comment, :created_at, :author, :recipient - - def author - { - id: object.user.id, - name: object.user.name, - email: object.user.email - } - end - - def recipient - { - id: object.recipient.id, - name: object.recipient.name, - email: object.user.email - } - end -end diff --git a/app/serializers/task_definition_serializer.rb b/app/serializers/task_definition_serializer.rb deleted file mode 100644 index 4f59e8c0b..000000000 --- a/app/serializers/task_definition_serializer.rb +++ /dev/null @@ -1,13 +0,0 @@ -class TaskDefinitionSerializer < ActiveModel::Serializer - attributes :id, :abbreviation, :name, :description, - :weight, :target_grade, :target_date, - :upload_requirements, - :plagiarism_checks, :plagiarism_report_url, :plagiarism_warn_pct, - :restrict_status_updates, - :group_set_id, :has_task_pdf?, :has_task_resources?, - :due_date, :start_date, :is_graded, :max_quality_pts - - def weight - object.weighting - end -end diff --git a/app/serializers/task_serializer.rb b/app/serializers/task_serializer.rb deleted file mode 100644 index 1502cfded..000000000 --- a/app/serializers/task_serializer.rb +++ /dev/null @@ -1,53 +0,0 @@ -class TaskUpdateSerializer < ActiveModel::Serializer - attributes :id, :status, :project_id, :new_stats, :include_in_portfolio, :other_projects, :times_assessed, :grade, :quality_pts - - def new_stats - object.project.task_stats - end - - def other_projects - grp = object.group - others = grp.projects.select { |p| p.id != object.project_id }.map { |p| { id: p.id, new_stats: p.task_stats } } - end - - def filter(keys) - keys.delete :other_projects unless object.group_task? && !object.group.nil? - keys - end -end - -class TaskStatSerializer < ActiveModel::Serializer - attributes :id, :task_abbr, :status, :tutorial_id, :times_assessed - - def task_abbr - object.task_definition.abbreviation - end - - # def tutorial_id - # object.project.tutorial.id unless object.project.tutorial.nil? - # end -end - -class TaskSerializer < ActiveModel::Serializer - attributes :id, :status, :completion_date, :task_name, :task_desc, :task_weight, :task_abbr, :upload_requirements, :pct_similar, :similar_to_count, :times_assessed, :similar_to_dismissed_count - - def task_name - object.task_definition.name - end - - def task_desc - object.task_definition.description - end - - def task_weight - object.task_definition.weighting - end - - def task_abbr - object.task_definition.abbreviation - end - - def upload_requirements - object.task_definition.upload_requirements - end -end diff --git a/app/serializers/tutorial_serializer.rb b/app/serializers/tutorial_serializer.rb deleted file mode 100644 index 039ba920f..000000000 --- a/app/serializers/tutorial_serializer.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'user_serializer' - -class TutorialSerializer < ActiveModel::Serializer - attributes :id, :meeting_day, :meeting_time, :meeting_location, :abbreviation, :tutor_name, :num_students - - def meeting_time - object.meeting_time.to_time - # DateTime.parse("#{object.meeting_time}") - end - - def tutor_name - object.tutor.name unless object.tutor.nil? - end - - has_one :tutor, serializer: ShallowUserSerializer - - def include_tutor? - if Thread.current[:user] - my_role = object.unit.role_for(Thread.current[:user]) - [ Role.convenor, Role.admin ].include? my_role - end - end - - def include_num_students? - if Thread.current[:user] - my_role = object.unit.role_for(Thread.current[:user]) - [ Role.convenor, Role.tutor, Role.admin ].include? my_role - end - end - - def filter(keys) - keys.delete :tutor unless include_tutor? - keys.delete :num_students unless include_num_students? - keys - end -end diff --git a/app/serializers/unit_role_serializer.rb b/app/serializers/unit_role_serializer.rb deleted file mode 100644 index 8b46fa7c4..000000000 --- a/app/serializers/unit_role_serializer.rb +++ /dev/null @@ -1,66 +0,0 @@ -require 'user_serializer' - -class ShallowUnitRoleSerializer < ActiveModel::Serializer - attributes :id, :role - - def role - object.role.name - end -end - -class UnitRoleSerializer < ActiveModel::Serializer - attributes :id, :role, :user_id, :unit_id, :unit_name, :name, :unit_code, :start_date, :active - - # has_one :user, serializer: ShallowUserSerializer - # has_one :unit, serializer: ShallowUnitSerializer - # has_one :role - - def role - object.role.name - end - - def unit_id - object.unit.id - end - - def unit_code - object.unit.code - end - - def unit_name - object.unit.name - end - - def name - object.user.name - end - - def active - object.unit.active - end - - def include_start_date? - object.has_attribute? :start_date - end - - def filter(keys) - keys.delete :start_date unless include_start_date? - keys - end -end - -class UserUnitRoleSerializer < ActiveModel::Serializer - attributes :id, :user_id, :name, :role #:user_name? - - def role - object.role.name - end - - def name - object.user.name - end - - def user_name - object.user.name - end -end diff --git a/app/serializers/unit_serializer.rb b/app/serializers/unit_serializer.rb deleted file mode 100644 index e72e3f1f4..000000000 --- a/app/serializers/unit_serializer.rb +++ /dev/null @@ -1,60 +0,0 @@ -require 'unit_role_serializer' - -class ShallowUnitSerializer < ActiveModel::Serializer - attributes :code, :id, :name, :start_date, :end_date, :active -end - -class UnitSerializer < ActiveModel::Serializer - attributes :code, :id, :name, :my_role, :description, :start_date, :end_date, :active, :convenors, :ilos - - def start_date - object.start_date.to_date - end - - def end_date - object.end_date.to_date - end - - def my_role_obj - object.role_for(Thread.current[:user]) if Thread.current[:user] - end - - def my_user_role - Thread.current[:user].role if Thread.current[:user] - end - - def role - role = my_role_obj - role.name unless role.nil? - end - - def my_role - role - end - - def ilos - object.learning_outcomes - end - - has_many :tutorials - has_many :task_definitions - has_many :convenors, serializer: UserUnitRoleSerializer - has_many :staff, serializer: UserUnitRoleSerializer - has_many :group_sets, serializer: GroupSetSerializer - has_many :ilos, serializer: LearningOutcomeSerializer - has_many :task_outcome_alignments, serializer: LearningOutcomeTaskLinkSerializer - - def include_convenors? - ([ Role.convenor, :convenor ].include? my_role_obj) || (my_user_role == Role.admin) - end - - def include_staff? - ([ Role.convenor, :convenor ].include? my_role_obj) || (my_user_role == Role.admin) - end - - def filter(keys) - keys.delete :convenors unless include_convenors? - keys.delete :staff unless include_staff? - keys - end -end diff --git a/app/serializers/user_role_serializer.rb b/app/serializers/user_role_serializer.rb deleted file mode 100644 index 3b32250b7..000000000 --- a/app/serializers/user_role_serializer.rb +++ /dev/null @@ -1,6 +0,0 @@ -class UserRoleSerializer < ActiveModel::Serializer - attributes :id, :role_id, :user_id - - has_one :role - has_one :user -end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb deleted file mode 100644 index 603dabd81..000000000 --- a/app/serializers/user_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ - -class UserSerializer < ActiveModel::Serializer - attributes :id, :student_id, :email, :name, :first_name, :last_name, :username, :nickname, :system_role, :receive_task_notifications, :receive_portfolio_notifications, :receive_feedback_notifications, :opt_in_to_research, :has_run_first_time_setup - - def system_role - object.role.name if object.role - end -end - -class ShallowUserSerializer < ActiveModel::Serializer - attributes :id, :name, :email, :student_id -end diff --git a/app/views/layouts/application.pdf.erbtex b/app/views/layouts/application.pdf.erbtex index b1b5e61db..35f61c1d0 100644 --- a/app/views/layouts/application.pdf.erbtex +++ b/app/views/layouts/application.pdf.erbtex @@ -1,4 +1,4 @@ -<% @latex_config={:command => "lualatex",:parse_runs => 2} %> +<% @latex_config={ :recipe => [ {:command => "lualatex",:parse_runs => 2} ] } %> \documentclass[11pt,a4paper]{article} \usepackage[T1]{fontenc} \usepackage{textcomp} @@ -28,7 +28,7 @@ \AppendGraphicsExtensions{.tif} \epstopdfDeclareGraphicsRule{.tiff}{png}{.png}{convert #1 \OutputFile} \AppendGraphicsExtensions{.tiff} -\epstopdfDeclareGraphicsRule{.gif}{png}{.png}{convert #1 \OutputFile} +\epstopdfDeclareGraphicsRule{.gif}{png}{.png}{convert -delete 1--1 #1 \OutputFile} \AppendGraphicsExtensions{.gif} \epstopdfDeclareGraphicsRule{.bmp}{png}{.png}{convert #1 \OutputFile} \AppendGraphicsExtensions{.bmp} @@ -62,7 +62,7 @@ } \definecolor{complete}{rgb}{0.357, 0.718, 0.357} -\definecolor{rtm}{rgb}{0.0,0.47,0.84} +\definecolor{rff}{rgb}{0.0,0.47,0.84} \definecolor{notstarted}{rgb}{0.8,0.8,0.8} \definecolor{workingonit}{rgb}{0.922, 0.561, 0.024} @@ -73,6 +73,7 @@ \definecolor{discuss}{rgb}{0.192, 0.69, 0.835} \definecolor{demo}{rgb}{0.259, 0.545, 0.792} \definecolor{fail}{rgb}{0.851, 0.216, 0.075} +\definecolor{timeexceeded}{rgb}{0.851, 0.216, 0.075} \begin{document} diff --git a/app/views/notifications_mailer/weekly_staff_summary.html.erb b/app/views/notifications_mailer/weekly_staff_summary.html.erb new file mode 100644 index 000000000..bab52a8b7 --- /dev/null +++ b/app/views/notifications_mailer/weekly_staff_summary.html.erb @@ -0,0 +1,142 @@ + + + + + + +
+

<%= @summary_stats[:unit].name %> - Weekly Summary

+

<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %>

+ +

Hi <%= @staff.first_name %>,

+

+ Hope you had a good week! Here's a summary of what has happened in this unit over the last week. +

+<% if @unit_role.has_students? %> +

+

    +
  • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made this unit.
  • +
  • You received <%= @data[:received_comments] %> comment<%= "s" if @data[:received_comments] != 1 %> and posted a total of <%= @data[:sent_comments] %> comment<%= "s" if @data[:sent_comments] != 1 %>.
  • +
  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • +
  • Your student's tasks changed state <%= @data[:weekly_engagements_count] %> time<%= "s" unless @data[:weekly_engagements_count] == 1 %>.
  • +
  • You assigned a new status to <%= @data[:staff_engagements] %> task<%= "s" unless @data[:staff_engagements] == 1 %>.
  • +
  • You have <%= @data[:tasks_awaiting_feedback_count] %> task<%= "s" unless @data[:tasks_awaiting_feedback_count] == 1 %> waiting for feedback.
  • +<% if @data[:tasks_awaiting_feedback_count] > 0 %> +
  • The oldest of these tasks was submitted <%= @data[:oldest_task_days] %> day<%= "s" unless @data[:oldest_task_days] == 1 %> ago.
  • +<% end %> +
+

+<% if @summary_stats[:revert][@staff].count > 0 %> +

+ The following students in your tutorials have been advised to revert their grade to Pass as they are falling behind with their pass tasks: +

    +<% @summary_stats[:revert][@staff].each do |p|%> +
  • <%= p.student.name %>
  • +<% end %> +
+

+<% end %> +<% else %> +

+

    +
  • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit.
  • +
  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • +
+

+<% end %> +<% if @unit_role.is_convenor? %> + + + + + + + + + + + + + + + + + + +<% @summary_stats[:staff].each do | user, data | %> +<% next unless data[:number_of_students] > 0%> + + + + + + + + + ><%= '%6i' % data[:oldest_task_days] %> days + +<% end %> + +
Name# StudAssessmentsCommentsAwaiting FeedbackOldest Task
TotalWeekTotalWeek
<%= '%-12s' % data[:staff].name.truncate(12) %><%= '%8i' % data[:number_of_students] %><%= '%7i' % data[:total_staff_engagements] %><%= '%6i' % data[:staff_engagements] %><%= '%5i' % data[:total_comments] %><%= '%5i' % data[:sent_comments] %><%= '%8i' % data[:tasks_awaiting_feedback_count] %>
+<% if @summary_stats[:num_students_without_tutors] > 0 %> +

+ Please note that <%=@summary_stats[:num_students_without_tutors]%> student<%= "s" unless @summary_stats[:num_students_without_tutors] == 1 %> <%= are_is(@summary_stats[:num_students_without_tutors]) %> not allocated to a tutorial. Work submitted by <%= this_these(@summary_stats[:num_students_without_tutors]) %> student<%= "s" unless @summary_stats[:num_students_without_tutors] == 1 %> will not appear in any of the tutor inboxes! +

+<% end %> +<% end %> +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %> +

+
+
+ Unsubscribe | Generated with <%= @doubtfire_product_name %> +
+ + \ No newline at end of file diff --git a/app/views/notifications_mailer/weekly_staff_summary.text.erb b/app/views/notifications_mailer/weekly_staff_summary.text.erb index d7a827ed5..b0dd38f0c 100644 --- a/app/views/notifications_mailer/weekly_staff_summary.text.erb +++ b/app/views/notifications_mailer/weekly_staff_summary.text.erb @@ -50,10 +50,10 @@ Please note that <%=@summary_stats[:num_students_without_tutors]%> student<%= "s <% end %> cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @convenor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/notifications_mailer/weekly_student_summary.html.erb b/app/views/notifications_mailer/weekly_student_summary.html.erb new file mode 100644 index 000000000..849dfb413 --- /dev/null +++ b/app/views/notifications_mailer/weekly_student_summary.html.erb @@ -0,0 +1,121 @@ + + + + + + +
+

<%= @summary_stats[:unit].name %> - Weekly Summary

+

<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %>

+ +

Hi <%= @student.first_name %>,

+<% if @did_revert_to_pass %> +

Hope you had a good week!

+

Before we get into the summary, it seems that you are falling behind in your Pass Tasks. It is really important that you catch up with these tasks, as you must have all Pass tasks marked as Complete to Pass the unit. I've reset your Target Grade to Pass for the moment, to help you focus on these tasks. I would like to encourage you to work through the Pass tasks in order to catch up as quickly as you can. Once you have caught up, you can upgrade your Target Grade again, and go back and complete any higher grade tasks you skipped.

+

With that out of the way, here's a summary of what has happened in this unit over the last week and some notes on what you should do next.

+<% else %> +

Hope you had a good week! Here's a summary of what has happened in this unit over the last week and some notes on what you should do next.

+<% end %> + +<% if @project.tutorial_enrolments.blank? %> +

Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that on the tutorials page.

+<% end %> +

Here is what you should focus on right now.

+ +<% if @top_tasks && @top_tasks.count > 0 %> +<% if @overdue_top && @overdue_top.count > 0 %> +

Catch up by completing the following overdue task<%="s" if @overdue_top.count > 1%>!

+
    +<% @overdue_top.each do |ot| %> +
  • <%= top_task_desc(ot) %>
  • +<% end %> +
+<% end %> +<% if @soon_top && @soon_top.count > 0 %> +

Work to get the following task<%="s" if @soon_top.count > 1%> done, as these are due soon!

+
    +<% @soon_top.each do |st| %> + <%= top_task_desc(st) %> +<% end %> +
+<% end %> +<% if @ahead_top && @ahead_top.count > 0 %> +

Get ahead by working on the following task<%="s" if @ahead_top.count > 1%> next!

+
    +<% @ahead_top.each do |at| %> +
  • <%= top_task_desc(at) %>
  • +<% end %> +
+<% end %> +<% elsif @project.has_portfolio %> +

Its time to Party! You have completed all of the tasks and prepared your portfolio.

+<% else %> +

Its almost party time... You have completed all of the tasks, now make sure you login and prepare your portfolio.

+<% end %> +

+ What has happened in <%= @doubtfire_product_name %> this week: +

    +
  • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit.
  • +
  • You posted a total of <%= @sent_comments %> comment<%= "s" if @sent_comments != 1 %>, and received back <%= @received_comments %> comment<%= "s" if @received_comments != 1 %> <%= "- try posting some comments this week" if @sent_comments == 0 %>
  • +
  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • +
  • Your tasks changed state <%= @engagements_count %> time<%= "s" unless @engagements_count == 1 %> <%= "- looks like you need to be more active" if @student_engagements == 0%>
  • +
+ We hope your studies are going well, and look forward to your submissions over the next week! +

+ +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> +

+
+
+ Unsubscribe | Generated with <%= @doubtfire_product_name %> +
+ + diff --git a/app/views/notifications_mailer/weekly_student_summary.text.erb b/app/views/notifications_mailer/weekly_student_summary.text.erb index a563599ef..c9351c3de 100644 --- a/app/views/notifications_mailer/weekly_student_summary.text.erb +++ b/app/views/notifications_mailer/weekly_student_summary.text.erb @@ -12,7 +12,8 @@ With that out of the way, here's a summary of what has happened in this unit ove <% else %> Hope you had a good week! Here's a summary of what has happened in this unit over the last week and some notes on what you should do next. <% end %> -<% if @project.tutorial.nil? %> + +<% if @project.tutorial_enrolments.blank? %> Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that here: https://doubtfire.deakin.edu.au/#/projects/<%= @project.id %>/tutorials @@ -47,7 +48,7 @@ Its time to Party! You have completed all of the tasks and prepared your portfol Its almost party time... You have completed all of the tasks, now make sure you login and prepare your portfolio. <% end %> -So what has happened in the last week? +What has happened in <%= @doubtfire_product_name %> this week: * In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit. * You posted a total of <%= @sent_comments %> comment<%= "s" if @sent_comments != 1 %>, and received back <%= @received_comments %> comment<%= "s" if @received_comments != 1 %> <%= "- try posting some comments this week" if @sent_comments == 0 %> @@ -57,10 +58,10 @@ So what has happened in the last week? We hope your studies are going well, and look forward to your submissions over the next week! Cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/portfolio/portfolio_pdf.pdf.erb b/app/views/portfolio/portfolio_pdf.pdf.erb index fc860b9de..6731e0e17 100644 --- a/app/views/portfolio/portfolio_pdf.pdf.erb +++ b/app/views/portfolio/portfolio_pdf.pdf.erb @@ -7,12 +7,13 @@ def latex_color_for_status(task) when :complete then 'complete' when :not_started then 'notstarted' when :fix_and_resubmit then 'fix' - when :do_not_resubmit then 'dnr' + when :time_exceeded then 'timeexceeded' + when :feedback_exceeded then 'dnr' when :redo then 'redo' when :need_help then 'needhelp' when :working_on_it then 'workingonit' when :discuss then 'discuss' - when :ready_to_mark then 'rtm' + when :ready_for_feedback then 'rff' when :demonstrate then 'demo' when :fail then 'fail' end @@ -67,8 +68,8 @@ end \begin{minipage}{0.4\textwidth} \begin{flushright} \large \emph{Tutor:} \\ -<% if not @project.main_tutor.nil? %> -<%= lesc @project.main_tutor.first_name %> \textsc{<%= lesc @project.main_tutor.last_name %>} +<% if not @project.main_convenor_user.nil? %> +<%= lesc @project.main_convenor_user.first_name %> \textsc{<%= lesc @project.main_convenor_user.last_name %>} <% else %> No Tutor <% end %> @@ -215,7 +216,7 @@ No Tutor <% end %> \end{tabular} <% end %> - <% if File.exists? task.portfolio_evidence %> - \includepdf[pages={1-}]{<%= task.portfolio_evidence %>} + <% if File.exists? task.portfolio_evidence_path %> + \includepdf[pages={1-}]{<%= task.portfolio_evidence_path %>} <% end %> <% end %> diff --git a/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb b/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb index e97b13dae..5b49d43f4 100644 --- a/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb +++ b/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb @@ -11,7 +11,7 @@ font-size: 0.9em; } main { - background-image: url('http://<%= @doubtfire_host %>/assets/images/logo.png'); + background-image: url('https://<%= @doubtfire_host %>/assets/images/logo.svg'); background-size: 3.5em; background-repeat: no-repeat; background-position: 30px 45px; @@ -45,18 +45,18 @@
-

<%= @doubtfire_host_name %> Notification

-

Hi <%= @student.first_name %>,

+

<%= @doubtfire_product_name %> Notification

+

Hi <%= @student.first_name %>,

- There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to Doubtfire and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again. + There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to <%= @doubtfire_product_name %> and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again.

Cheers,
- The <%= @doubtfire_host_name %> Team on behalf of <%= @convenor.name %> + The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %>

- Unsubscribe | Generated with <%= @doubtfire_host_name %> + Unsubscribe | Generated with <%= @doubtfire_product_name %>
diff --git a/app/views/portfolio_evidence_mailer/portfolio_failed.text.erb b/app/views/portfolio_evidence_mailer/portfolio_failed.text.erb index ccd3f456e..6bf8f4c06 100644 --- a/app/views/portfolio_evidence_mailer/portfolio_failed.text.erb +++ b/app/views/portfolio_evidence_mailer/portfolio_failed.text.erb @@ -1,12 +1,12 @@ Hi <%= @student.first_name %>, -There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to Doubtfire and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again. +There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to <%= @doubtfire_product_name %> and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again. Cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @convenor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> \ No newline at end of file +Generated with <%= @doubtfire_product_name %> \ No newline at end of file diff --git a/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb b/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb index dd1f9db18..131857084 100644 --- a/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb +++ b/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb @@ -11,7 +11,7 @@ font-size: 0.9em; } main { - background-image: url('http://<%= @doubtfire_host %>/assets/images/logo.png'); + background-image: url('https://<%= @doubtfire_host %>/assets/images/logo.svg'); background-size: 3.5em; background-repeat: no-repeat; background-position: 30px 45px; @@ -45,18 +45,18 @@
-

Doubtfire Notification

-

Hi <%= @student.first_name %>,

+

<%= @doubtfire_product_name %> Notification

+

Hi <%= @student.first_name %>,

- Your portfolio has been compiled and can now be viewed and downloaded from Doubtfire. + Your portfolio has been compiled and can now be viewed and downloaded from <%= @doubtfire_product_name %>.

Cheers,
- The <%= @doubtfire_host_name %> Team on behalf of <%= @convenor.name %> + The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %>

- Unsubscribe | Generated with <%= @doubtfire_host_name %> + Unsubscribe | Generated with <%= @doubtfire_product_name %>
diff --git a/app/views/portfolio_evidence_mailer/portfolio_ready.text.erb b/app/views/portfolio_evidence_mailer/portfolio_ready.text.erb index 9ac50bc9b..96a09f7ad 100644 --- a/app/views/portfolio_evidence_mailer/portfolio_ready.text.erb +++ b/app/views/portfolio_evidence_mailer/portfolio_ready.text.erb @@ -1,12 +1,12 @@ Hi <%= @student.first_name %>, -Your portfolio has been compiled and can now be viewed and downloaded from Doubtfire. +Your portfolio has been compiled and can now be viewed and downloaded from <%= @doubtfire_product_name %>. Cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @convenor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> \ No newline at end of file +Generated with <%= @doubtfire_product_name %> \ No newline at end of file diff --git a/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb b/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb index c35045b58..b2adc71a0 100644 --- a/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb +++ b/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb @@ -11,7 +11,7 @@ font-size: 0.9em; } main { - background-image: url('http://<%= @doubtfire_host %>/assets/images/logo.svg'); + background-image: url('https://<%= @doubtfire_host %>/assets/images/logo.svg'); background-size: 3.5em; background-repeat: no-repeat; background-position: 30px 45px; @@ -45,15 +45,15 @@
-

Doubtfire Notification

-

Hi <%= @student.first_name %>,

+

<%= @doubtfire_product_name %> Notification

+

Hi <%= @student.first_name %>,

I have checked your tasks and provided some feedback. Login and check the status <%= @hasComments ? 'and comments' : ''%> of the following tasks:

    <% @tasks.each do |task| %>
  • <%= task.task_definition.abbreviation %> - - + <%=task.task_definition.name%> <%= task.is_last_comment_by?(@tutor) ? ' with comments' : ''%> @@ -63,11 +63,11 @@

    Cheers,
    - The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> + The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %>

- Unsubscribe | Generated with <%= @doubtfire_host_name %> + Unsubscribe | Generated with <%= @doubtfire_product_name %>
diff --git a/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb b/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb index bdfe0cb3b..d40a8c7ce 100644 --- a/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb +++ b/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb @@ -7,10 +7,10 @@ I have checked your tasks and provided some feedback. Login and check the status <% end %> Cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb b/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb index 63ded4439..a825a33dd 100644 --- a/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb +++ b/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb @@ -11,7 +11,7 @@ font-size: 0.9em; } main { - background-image: url('http://<%= @doubtfire_host %>/assets/images/logo.svg'); + background-image: url('https://<%= @doubtfire_host %>/assets/images/logo.svg'); background-size: 3.5em; background-repeat: no-repeat; background-position: 30px 45px; @@ -45,8 +45,8 @@
-

Doubtfire Notification

-

Hi <%= @student.first_name %>,

+

<%= @doubtfire_product_name %> Notification

+

Hi <%= @student.first_name %>,

Something went wrong with the processing of the following tasks. Please check what you have submitted and try uploading them again. Watch out for character encodings, and file formats.

@@ -56,7 +56,7 @@ <% @tasks.each do |task| %>
  • <%= task.task_definition.abbreviation %> - - + <%=task.task_definition.name%>
  • @@ -65,11 +65,11 @@

    Cheers,
    - The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> + The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %>

    - Unsubscribe | Generated with <%= @doubtfire_host_name %> + Unsubscribe | Generated with <%= @doubtfire_product_name %>
    diff --git a/app/views/portfolio_evidence_mailer/task_pdf_failed.text.erb b/app/views/portfolio_evidence_mailer/task_pdf_failed.text.erb index 4aaee23b6..04e33ce02 100644 --- a/app/views/portfolio_evidence_mailer/task_pdf_failed.text.erb +++ b/app/views/portfolio_evidence_mailer/task_pdf_failed.text.erb @@ -8,10 +8,10 @@ The tasks with issues are: <% end %> Cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> \ No newline at end of file +Generated with <%= @doubtfire_product_name %> \ No newline at end of file diff --git a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb index f2f84080f..0c46f454b 100644 --- a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb +++ b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb @@ -11,7 +11,7 @@ font-size: 0.9em; } main { - background-image: url('http://<%= @doubtfire_host %>/assets/images/logo.svg'); + background-image: url('https://<%= @doubtfire_host %>/assets/images/logo.svg'); background-size: 3.5em; background-repeat: no-repeat; background-position: 30px 45px; @@ -45,15 +45,15 @@
    -

    Doubtfire Notification

    -

    Hi <%= @student.first_name %>,

    +

    <%= @doubtfire_product_name %> Notification

    +

    Hi <%= @student.first_name %>,

    - The following evidence you submitted to Doubtfire is now prepared and ready to be viewed and assessed by your tutor: + The following evidence you submitted to <%= @doubtfire_product_name %> is now prepared and ready to be viewed and assessed by your tutor:

      <% @tasks.each do |task| %>
    • <%= task.task_definition.abbreviation %> - - + <%=task.task_definition.name%>
    • @@ -62,11 +62,11 @@

      Cheers,
      - The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> + The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %>

    - Unsubscribe | Generated with <%= @doubtfire_host_name %> + Unsubscribe | Generated with <%= @doubtfire_product_name %>
    diff --git a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.text.erb b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.text.erb index 09fe038a8..f279c6203 100644 --- a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.text.erb +++ b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.text.erb @@ -1,16 +1,16 @@ Hi <%= @student.first_name %>, -The following evidence you submitted to Doubtfire is now prepared and ready to be viewed and assessed by your tutor: +The following evidence you submitted to <%= @doubtfire_product_name %> is now prepared and ready to be viewed and assessed by your tutor: <% @tasks.each do |task| %> * <%= task.task_definition.abbreviation %> - <%=task.task_definition.name%> <% end %> Cheers, -The <%= @doubtfire_host_name %> Team on behalf of <%= @tutor.name %> +The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> --- Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. -Generated with <%= @doubtfire_host_name %> \ No newline at end of file +Generated with <%= @doubtfire_product_name %> \ No newline at end of file diff --git a/app/views/task/task_pdf.pdf.erb b/app/views/task/task_pdf.pdf.erb index 8fcc27bb6..aaa9015d4 100644 --- a/app/views/task/task_pdf.pdf.erb +++ b/app/views/task/task_pdf.pdf.erb @@ -8,7 +8,7 @@ \textsc{\LARGE <%= lesc @institution_name %>}\\[1.5cm] % Name of your university/college \textsc{\Large <%= lesc @task.unit.name %>}\\[0.5cm] % Major heading such as course name - \textsc{\large <%= lesc @doubtfire_host_name %> Submission}\\[0.5cm] % Minor heading such as course title + \textsc{\large <%= lesc @doubtfire_product_name %> Submission}\\[0.5cm] % Minor heading such as course title %---------------------------------------------------------------------------------------- % TITLE SECTION @@ -41,8 +41,8 @@ \emph{Tutor:} \\ <% if @task.group_task? and not @task.group.tutor.nil? %> <%= lesc @task.group.tutor.first_name %> \textsc{<%= lesc @task.group.tutor.last_name %>} % Supervisor's Name -<% elsif not @task.project.main_tutor.nil? %> -<%= lesc @task.project.main_tutor.first_name %> \textsc{<%= lesc @task.project.main_tutor.last_name %>} % Supervisor's Name +<% elsif not @task.project.tutor_for(@task.task_definition).nil? %> +<%= lesc @task.project.tutor_for(@task.task_definition).first_name %> \textsc{<%= lesc @task.project.tutor_for(@task.task_definition).last_name %>} % Supervisor's Name <% else %> No Tutor % Supervisor's Name <% end %> @@ -72,7 +72,7 @@ No Tutor % Supervisor's Name <% end %> \end{tabular}\\[0.5cm] -<%= lesc @task.learning_outcome_task_links.last.description %> \\[2cm] +<%= lesc @task.learning_outcome_task_links.last.description.present? && @task.learning_outcome_task_links.last.description.strip.length > 0 ? @task.learning_outcome_task_links.last.description : "(none)" %> \\[2cm] <% end %> %---------------------------------------------------------------------------------------- @@ -103,7 +103,7 @@ No Tutor % Supervisor's Name <% if file[:type] == 'image' %> \graphicspath{ {<%= @base_path %>} } - \includegraphics[width=\textwidth]{{<%= File.basename(file[:path], File.extname(file[:path])) %>}<%= File.extname(file[:path]) %>} + \includegraphics[width=\textwidth]{<%= File.basename(file[:path]) %>} <% end %> <% if file[:type] == 'code' %> diff --git a/config/application.rb b/config/application.rb index c9a9f1eab..0f195f864 100644 --- a/config/application.rb +++ b/config/application.rb @@ -2,7 +2,7 @@ require 'rails/all' require 'csv' require 'yaml' -require 'grape-active_model_serializers' +require 'bunny-pub-sub/services_manager' # Precompile assets before deploying to production if defined?(Bundler) @@ -14,12 +14,14 @@ module Doubtfire # Doubtfire generic application configuration # class Application < Rails::Application + config.load_defaults 7.0 + # Load .env variables Dotenv::Railtie.load # ==> Authentication Method # Authentication method default is database, but possible settings - # are: database, ldap, aaf. It can be overridden using the DF_AUTH_METHOD + # are: database, ldap, aaf, or saml. It can be overridden using the DF_AUTH_METHOD # environment variable. config.auth_method = (ENV['DF_AUTH_METHOD'] || :database).to_sym @@ -36,9 +38,59 @@ class Application < Rails::Application config.institution[:email_domain] = ENV['DF_INSTITUTION_EMAIL_DOMAIN'] if ENV['DF_INSTITUTION_EMAIL_DOMAIN'] config.institution[:host] = ENV['DF_INSTITUTION_HOST'] if ENV['DF_INSTITUTION_HOST'] config.institution[:product_name] = ENV['DF_INSTITUTION_PRODUCT_NAME'] if ENV['DF_INSTITUTION_PRODUCT_NAME'] + config.institution[:privacy] = ENV['DF_INSTITUTION_PRIVACY'] if ENV['DF_INSTITUTION_PRIVACY'] + config.institution[:plagiarism] = ENV['DF_INSTITUTION_PLAGIARISM'] if ENV['DF_INSTITUTION_PLAGIARISM'] # Institution host becomes localhost in all but prod config.institution[:host] = 'localhost:3000' if Rails.env.development? config.institution[:host_url] = Rails.env.development? ? "http://#{config.institution[:host]}/" : "https://#{config.institution[:host]}/" + config.institution[:settings] = ENV['DF_INSTITUTION_SETTINGS_RB'] if ENV['DF_INSTITUTION_SETTINGS_RB'] + config.institution[:ffmpeg] = ENV['DF_FFMPEG_PATH'] || 'ffmpeg' + + require "#{Rails.root}/config/#{config.institution[:settings]}" unless config.institution[:settings].nil? + + # ==> SAML2.0 authentication + if config.auth_method == :saml + config.saml = HashWithIndifferentAccess.new + # URL of the XML SAML Metadata (if available). + config.saml[:SAML_metadata_url] = ENV['DF_SAML_METADATA_URL'] + # URL to return the SAML response to (e.g., 'https://doubtfire.edu/api/auth/jwt' + config.saml[:assertion_consumer_service_url] = ENV['DF_SAML_CONSUMER_SERVICE_URL'] + # URL of the registered application (e.g., https://doubtfire.unifoo.edu.au) + config.saml[:entity_id] = ENV['DF_SAML_SP_ENTITY_ID'] + # The IDP SAML login URL, (e.g., "https://login.microsoftonline.com/xxxx/saml2") + config.saml[:idp_sso_target_url] = ENV['DF_SAML_IDP_TARGET_URL'] + # The IDP SAML logout URL, (e.g., "https://login.microsoftonline.com/xxxx/saml2") + config.saml[:idp_sso_signout_url] = ENV['DF_SAML_IDP_SIGNOUT_URL'] + + # The SAML response certificate and name format (if no XML URL metadata is provided) + if config.saml[:SAML_metadata_url].nil? + config.saml[:idp_sso_cert] = ENV['DF_SAML_IDP_CERT'] + + # One of urn:oasis:names:tc:SAML:2.0:nameid-format:persistent, urn:oasis:names:tc:SAML:2.0:nameid-format:transient, urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + # urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified, urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName, urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName + # urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos, urn:oasis:names:tc:SAML:2.0:nameid-format:entity + config.saml[:idp_name_identifier_format] = ENV['DF_SAML_IDP_SAML_NAME_IDENTIFIER_FORMAT'] || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + end + + # Check we have all values + # always need: + if config.saml[:assertion_consumer_service_url].nil? || + config.saml[:entity_id].nil? || + config.saml[:idp_sso_target_url].nil? + raise "Invalid values specified to saml, check the following environment variables: \n"\ + " key => variable set?\n"\ + " DF_SAML_CONSUMER_SERVICE_URL => #{!ENV['DF_SAML_CONSUMER_SERVICE_URL'].nil?}\n"\ + " DF_SAML_SP_ENTITY_ID => #{!ENV['DF_SAML_SP_ENTITY_ID'].nil?}\n"\ + " DF_SAML_IDP_SIGNOUT_URL => #{!ENV['DF_SAML_IDP_SIGNOUT_URL'].nil?}\n"\ + " DF_SAML_IDP_TARGET_URL => #{!ENV['DF_SAML_IDP_TARGET_URL'].nil?}\n" + end + + # If there's no XML url, we need the cert + if config.saml[:SAML_metadata_url].nil? && + config.saml[:idp_sso_cert].nil? + raise "Missing IDP certificate for SAML config: \n" + end + end # ==> AAF authentication # Must require AAF devise authentication method. @@ -56,6 +108,8 @@ class Application < Rails::Application config.aaf[:redirect_url] = ENV['DF_AAF_UNIQUE_URL'] # URL of the identity provider (e.g., https://unifoo.edu.au/idp/shibboleth) config.aaf[:identity_provider_url] = ENV['DF_AAF_IDENTITY_PROVIDER_URL'] + # The URL to redirect to after a signout + config.aaf[:auth_signout_url] = ENV['DF_AAF_AUTH_SIGNOUT_URL'] # Redirection URL to use on front-end config.aaf[:redirect_url] += "?entityID=#{config.aaf[:identity_provider_url]}" # Check we have all values @@ -83,6 +137,9 @@ class Application < Rails::Application " DF_SECRET_KEY_ATTR => #{!secrets.secret_key_base.nil?}\n"\ " DF_SECRET_KEY_DEVISE => #{!secrets.secret_key_base.nil?}" end + + config.active_record.legacy_connection_handling = false + # Localization config.i18n.enforce_available_locales = true # Ensure that auth tokens do not appear in log files @@ -92,9 +149,12 @@ class Application < Rails::Application password_confirmation ) # Grape Serialization - config.paths.add 'app/api', glob: '**/*.rb' - config.autoload_paths += Dir["#{Rails.root}/app"] - config.autoload_paths += Dir["#{Rails.root}/app/serializers"] + + # config.paths.add 'app/api', glob: '**/*.rb' + # config.autoload_paths += Dir["#{Rails.root}/app"] + # config.autoload_paths += Dir[Rails.root.join("app", "models", "{*/}")] + config.eager_load_paths << Rails.root.join('app') << Rails.root.join('app', 'models', 'comments') + # CORS config config.middleware.insert_before Warden::Manager, Rack::Cors do allow do @@ -114,5 +174,43 @@ class Application < Rails::Application request_specs: true end end + + config.sm_instance = nil + config.overseer_enabled = ENV['OVERSEER_ENABLED'].present? && ENV['OVERSEER_ENABLED'].to_s.downcase != "false" && ENV['OVERSEER_ENABLED'].to_i != 0 ? true : false + + if (config.overseer_enabled) + config.overseer_images = YAML.load_file(Rails.root.join('config/overseer-images.yml')).with_indifferent_access + config.has_overseer_image = -> (key){ config.overseer_images['images'].any? { |img| img[:name] == key } } + + publisher_config = { + RABBITMQ_HOSTNAME: ENV['RABBITMQ_HOSTNAME'], + RABBITMQ_USERNAME: ENV['RABBITMQ_USERNAME'], + RABBITMQ_PASSWORD: ENV['RABBITMQ_PASSWORD'], + EXCHANGE_NAME: 'ontrack', + DURABLE_QUEUE_NAME: 'q.tasks', + # Publisher specific key -- all publishers will post task submissions with this key + ROUTING_KEY: 'task.submission' + } + + subscriber_config = { + RABBITMQ_HOSTNAME: ENV['RABBITMQ_HOSTNAME'], + RABBITMQ_USERNAME: ENV['RABBITMQ_USERNAME'], + RABBITMQ_PASSWORD: ENV['RABBITMQ_PASSWORD'], + EXCHANGE_NAME: 'ontrack', + DURABLE_QUEUE_NAME: 'q.overseer', + # No need to define BINDING_KEYS for now! + # In future, OnTrack will listen to + # topics related to PDF generation too. + # That is when we should have BINDING_KEYS defined. + # BINDING_KEYS: ENV['BINDING_KEYS'], + + # This is enough for now: + DEFAULT_BINDING_KEY: '*.result' + } + + config.sm_instance = ServicesManager.instance + config.sm_instance.register_client(:ontrack, publisher_config, subscriber_config) + end + end end diff --git a/config/boot.rb b/config/boot.rb index 92db17c0b..754ecf406 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,19 +1,5 @@ -require 'rubygems' -require 'rails/commands/server' - -# Set default binding to 0.0.0.0 to allow connections from everyone if -# under development -if Rails.env.development? - module Rails - class Server - def default_options - super.merge(Host: '0.0.0.0', Port: 3000) - end - end - end -end - # Set up gems listed in the Gemfile. ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/config/database.yml b/config/database.yml index 54f58a65f..06faaed68 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,17 +1,17 @@ development: - adapter: postgresql - database: doubtfire_dev - username: itig - password: d872$dh - host: localhost + adapter: <%= ENV['DF_DEV_DB_ADAPTER'] %> + database: <%= ENV['DF_DEV_DB_DATABASE'] %> + username: <%= ENV['DF_DEV_DB_USERNAME'] %> + password: <%= ENV['DF_DEV_DB_PASSWORD'] %> + host: <%= ENV['DF_DEV_DB_HOST'] %> min_messages: warning test: - adapter: postgresql - database: doubtfire_dev_test - username: itig - password: d872$dh - host: localhost + adapter: <%= ENV['DF_TEST_DB_ADAPTER'] %> + database: <%= ENV['DF_TEST_DB_DATABASE'] %> + username: <%= ENV['DF_TEST_DB_USERNAME'] %> + password: <%= ENV['DF_TEST_DB_PASSWORD'] %> + host: <%= ENV['DF_TEST_DB_HOST'] %> min_messages: warning staging: diff --git a/config/deakin.rb b/config/deakin.rb new file mode 100644 index 000000000..8acfd363b --- /dev/null +++ b/config/deakin.rb @@ -0,0 +1,573 @@ +require 'rest-client' + +# +# This is an institution settings - used for custom imports of users into units. +# +class DeakinInstitutionSettings + def logger + Rails.logger + end + + def initialize() + @base_url = ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL'] + @client_id = ENV['DF_INSTITUTION_SETTINGS_SYNC_CLIENT_ID'] + @client_secret = ENV['DF_INSTITUTION_SETTINGS_SYNC_CLIENT_SECRET'] + + @star_url = ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL'] + @star_user = ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_USER'] + @star_secret = ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_SECRET'] + end + + def are_callista_headers? (headers) + headers[0] == "person id" && headers.count == 35 + end + + def are_star_headers? (headers) + headers.include?("student_code") && headers.count == 11 + end + + def are_headers_institution_users? (headers) + are_callista_headers?(headers) || are_star_headers?(headers) + end + + def missing_headers(row, headers) + headers - row.to_hash.keys + end + + def user_import_settings_for(headers) + if are_callista_headers?(headers) + { + missing_headers_lambda: ->(row) { + missing_headers(row, ["person id", "surname", "given names", "unit code", "student attempt status", "email", "preferred given name", "campus"]) + }, + fetch_row_data_lambda: ->(row, unit) { fetch_callista_row(row, unit) }, + replace_existing_tutorial: false + } + else + { + missing_headers_lambda: ->(row) { + missing_headers(row, ["student_code","first_name","last_name","email_address","preferred_name","subject_code","activity_code","campus","day_of_week","start_time","location", "campus"]) + }, + fetch_row_data_lambda: ->(row, unit) { fetch_star_row(row, unit) }, + replace_existing_tutorial: true + } + end + end + + def day_abbr_to_name(day) + case day.downcase + when 'mon' + 'Monday' + when 'tue' + 'Tuesday' + when 'wed' + 'Wednesday' + when 'thu' + 'Thursday' + when 'fri' + 'Friday' + else + day + end + end + + def activity_type_for_group_code (activity_group_code, description) + result = ActivityType.where('lower(abbreviation) = :abbr', abbr: activity_group_code[0...-2].downcase).first + + if result.nil? + name = description[0...-2] + abbr = activity_group_code[0...-2] + + result = ActivityType.create!(name: name, abbreviation: abbr) + end + + result + end + + def default_cloud_campus_abbr + 'Cloud-01' + end + + # Multi code units have a stream for unit - and do not sync with star + def setup_multi_code_streams unit + logger.info("Setting up multi unit for #{unit.code}") + + codes = unit.code.split '/' + + stream = find_or_add_stream unit, "Cohort", "Enrolment" + + for code in codes do + tutorial = stream.tutorials.where(abbreviation: code, campus_id: nil).first + if tutorial.nil? + unit.add_tutorial( + 'NA', #day + 'NA', #time + 'NA', #location + unit.main_convenor_user, #tutor + nil, #campus + -1, #capacity + code, #abbrev + stream #tutorial_stream + ) + end + end + end + + def find_or_add_stream unit, abbr, desc + stream = unit.tutorial_streams.where(abbreviation: abbr).first + + # Create the stream ... but skip classes - unless it is in the unit's current streams + if stream.nil? && activity_type_for_group_code(abbr, desc).abbreviation.casecmp('Cls') != 0 + stream = unit.add_tutorial_stream desc, abbr, activity_type_for_group_code(abbr, desc) + end + + stream + end + + # Doubtfire::Application.config.institution_settings.sync_streams_from_star(Unit.last) + def sync_streams_from_star(unit) + return unless unit.enable_sync_timetable + tp = unit.teaching_period + + # url = "#{@star_url}/star-#{tp.year}/rest/activities" + server = unit.start_date.year % 2 == 0 ? 'even' : 'odd' + url = "#{@star_url}/#{server}/rest/activities" + + logger.info("Fetching #{unit.name} timetable from #{url}") + response = RestClient.post(url, {username: @star_user, password: @star_secret, where_clause:"subject_code LIKE '#{unit.code}%_#{tp.period.last}'"}) + + if response.code == 200 + jsonData = JSON.parse(response.body) + if jsonData["activities"].nil? + logger.error "Failed to sync #{unit.code} - No response from #{url}" + return + end + + activityData = jsonData["activities"] + + activityData.each do |activity| + # Make sure units match + subject_match = /.*?(?=_)/.match( activity["subject_code"] ) + unit_code = subject_match.nil? ? nil : subject_match[0] + unless unit_code == unit.code + logger.error "Failed to sync #{unit.code} - response had unit code #{enrolmentData['unitCode']}" + return + end + + # Get the stream or create it... + stream = find_or_add_stream unit, activity['activity_group_code'], activity['description'] + next if stream.nil? + + campus = Campus.find_by(abbreviation: activity['campus']) + + abbr = tutorial_abbr_for_star(activity) + tutorial = unit.tutorials.where(abbreviation: abbr).first + if tutorial.nil? + unit.add_tutorial( + activity['day_of_week'], #day + activity['start_time'], #time + activity['location'], #location + unit.main_convenor_user, #tutor + campus, #campus + -1, #capacity + abbr, #abbrev + stream #tutorial_stream=nil + ) + end + end + end + end + + def fetch_star_row(row, unit) + email_match = /(.*)(?=@)/.match( row["email_address"] ) + subject_match = /.*?(?=_)/.match( row["subject_code"] ) + username = email_match.nil? ? nil : email_match[0] + unit_code = subject_match.nil? ? nil : subject_match[0] + + tutorial_code = fetch_tutorial unit, row + + { + unit_code: unit_code, + username: username, + student_id: row["student_code"], + first_name: row["first_name"], + last_name: row["last_name"], + nickname: row["preferred_name"] == '-' ? nil : row["preferred_name"], + email: row["email_address"], + enrolled: true, + tutorials: tutorial_code.present? ? [ tutorial_code ] : [], + campus: row["campus"] + } + end + + def map_callista_to_campus(row) + key = row["unit mode"] == 'OFF' ? 'C' : row['unit location'] + Campus.find_by(abbreviation: key) + end + + def cloud_campus + Campus.find_by(abbreviation: 'C') + end + + def fetch_callista_row(row, unit) + campus = map_callista_to_campus(row) + + result = { + unit_code: row["unit code"], + username: row["email"], + student_id: row["person id"], + first_name: row["given names"], + last_name: row["surname"], + nickname: row["preferred given name"] == "-" ? nil : row["preferred given name"], + email: "#{row["email"]}@deakin.edu.au", + enrolled: row["student attempt status"] == 'ENROLLED', + campus: campus.name, + tutorials: [] + } + + sync_student_user_from_callista(result) + result + end + + # + # Ensure that changes in email are propagated to users with matching ids + # + def sync_student_user_from_callista(row_data) + username_user = User.find_by(username: row_data[:username]) + student_id_user = User.find_by(student_id: row_data[:student_id]) + + return username_user if username_user.present? && student_id_user.present? && username_user.id == student_id_user.id + return nil if username_user.nil? && student_id_user.nil? + + if username_user.nil? && student_id_user.present? + # Have with stident_id but not username + student_id_user.email = row_data[:email] # update to new emails and... + student_id_user.username = row_data[:username] # switch username - its the same person as the id is the same + student_id_user.login_id = row_data[:username] # reset to make sure not caching old data + + if student_id_user.valid? + student_id_user.save + else + logger.error("Unable to fix user #{row_data} - record invalid!") + end + + student_id_user + elsif username_user.present? && student_id_user.nil? + # Have with username but not student id + username_user.student_id = row_data[:student_id] # should just need the student id + + if username_user.valid? + username_user.save + else + logger.error("Unable to fix user #{row_data} - record invalid!") + end + + username_user + elsif username_user.present? && student_id_user.present? + # Both present, but different + + logger.error("Unable to fix user #{row_data} - both username and student id users present. Need manual fix.") + nil + else + logger.error("Unable to fix user #{row_data} - Need manual fix.") + nil + end + end + + def find_cloud_tutorial(unit, tutorial_stats) + if tutorial_stats.count == 1 + # There is only one... so return it! + return tutorial_stats.first[:abbreviation] + end + + # Sort the tutorials by fill % + # Get the first one + # Return its abbreviation + list = tutorial_stats.sort_by { |r| + capacity = r[:capacity].present? ? r[:capacity] : 0 + capacity = 10000 if capacity <= 0 + (r[:enrolment_count] + r[:added]) / capacity + } + result = list.first + result[:added] += 1 + result[:abbreviation] + end + + # Doubtfire::Application.config.institution_settings.sync_enrolments(Unit.last) + def sync_enrolments(unit) + return unless unit.enable_sync_enrolments + + logger.info("Starting sync for #{unit.code}") + result = { + success: [], + ignored: [], + errors: [] + } + + tp = unit.teaching_period + + # in this process we need to keep track of those students already enrolled for + # cases where multi-unit enrolments "enrol" a user in unit 1 and "withdraw" them in unit 2 + # this will keep a list of the enrolled students from earlier units to ensure they are not + # subsequently withdrawn + already_enrolled = {} + + unless tp.present? + logger.error "Failing to sync unit #{unit.code} as not in teaching period" + return + end + + begin + codes = unit.code.split('/') + multi_unit = codes.length > 1 + + if multi_unit + setup_multi_code_streams(unit) + timetable_data = {} + end + + for code in codes do + # Get URL to enrolment data for this code + url = "#{@base_url}?academicYear=#{tp.year}&periodType=trimester&period=#{tp.period.last}&unitCode=#{code}" + logger.info("Requesting #{url}") + + # Get json from enrolment server + response = RestClient.get(url, headers={ "client_id" => @client_id, "client_secret" => @client_secret}) + + # Check we get a valid response + if response.code == 200 + jsonData = JSON.parse(response.body) + if jsonData["unitEnrolments"].nil? + logger.error "Failed to sync #{code} - No response from #{url}" + next + end + + enrolmentData = jsonData["unitEnrolments"].first + # Make sure units match + unless enrolmentData['unitCode'] == code + logger.error "Failed to sync #{code} - response had unit code #{enrolmentData['unitCode']}" + next + end + + # Make sure correct trimester + unless enrolmentData['teachingPeriod']['year'].to_i == tp.year && "#{enrolmentData['teachingPeriod']['type'][0].upcase}#{enrolmentData['teachingPeriod']['period']}" == tp.period + logger.error "Failed to sync #{code} - response had trimester #{enrolmentData['teachingPeriod']}" + next + end + + logger.info "Syncing enrolment for #{code} - #{tp.year} #{tp.period}" + + # Get the list of students + student_list = [] + + # Get the timetable data () + if multi_unit + # We just enrol people in a "tutorial" associated with the unit code + tutorials = [ code ] + else + # Get timetable data for students - unless it is multi-unit... cant sync those timetables ATM + timetable_data = fetch_timetable_data(unit) + end + + # For each location in the enrolment data... + enrolmentData['locations'].each do |location| + logger.info " - Syncing #{location['name']}" + + # Get campus + campus_name = location['name'] + campus = Campus.find_by(name: campus_name) + + if campus.nil? + logger.error "Unable to find location #{location['name']}" + next + end + + is_cloud = (campus == cloud_campus) + + # Cloud tutorials are allocated to the tutorial with the smallest pct full + # We need to determine the stats here before the enrolments. + # This is not needed for multi unit as we do not setup the tutorials for multi units + + if is_cloud && ! multi_unit && unit.enable_sync_timetable + if unit.tutorials.where(campus_id: campus.id).count == 0 + unit.add_tutorial( + 'Asynchronous', #day + '', #time + 'Cloud', #location + unit.main_convenor_user, #tutor + cloud_campus, #campus + -1, #capacity + default_cloud_campus_abbr, #abbrev + nil #tutorial_stream=nil + ) + end + + # Get stats for distribution of students across tutorials - for enrolment of cloud students + tutorial_stats = unit.tutorials. + joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.tutorial_id = tutorials.id'). + where(campus_id: campus.id). + select( + 'tutorials.abbreviation AS abbreviation', + 'capacity', + 'COUNT(tutorial_enrolments.id) AS enrolment_count' + ). + group('tutorials.abbreviation', 'capacity'). + map { |row| + { + abbreviation: row.abbreviation, + enrolment_count: row.enrolment_count, + added: 0.0, # float to force float division in % full calc + capacity: row.capacity + } + } + end # is cloud + + # For each of the enrolments... + location['enrolments'].each do |enrolment| + + # Skip enrolments without an email + if enrolment['email'].nil? + # Only error if they were enrolled + if ['ENROLLED', 'COMPLETED'].include?(enrolment['status'].upcase) + result[:errors] << { row: enrolment, message: 'Missing email and username!' } + else + result[:ignored] << { row: enrolment, message: 'Not enrolled, but no email/username' } + end + + next + end + + # Get the list of tutorials for the student + unless multi_unit || !unit.enable_sync_timetable + tutorials = timetable_data[enrolment['studentId']] + # multi unit tutorials is already setup with the unit code + end + + # Record the data associated with the student record + row_data = { + unit_code: enrolmentData['unitCode'], + username: enrolment['email'][/[^@]+/], + student_id: enrolment['studentId'], + first_name: enrolment['givenNames'], + last_name: enrolment['surname'], + nickname: enrolment['preferredName'], + email: enrolment['email'], + enrolled: ['ENROLLED', 'COMPLETED'].include?(enrolment['status'].upcase), + tutorials: tutorials || [], # tutorials unless they are not present + campus: campus_name, + row: enrolment + } + + logger.debug(row_data) + + # Record details for students already enrolled to work with multi-units + if row_data[:enrolled] + already_enrolled[row_data[:username]] = true + elsif already_enrolled[row_data[:username]] + # skip to the next enrolment... this person was enrolled in an earlier unit nested within this unit... so skip this row as it would result in withdrawal + next + end + + user = sync_student_user_from_callista(row_data) + + # if they are enrolled, but not timetabled and cloud... + if is_cloud && row_data[:enrolled] && !multi_unit && unit.enable_sync_timetable && timetable_data[enrolment['studentId']].nil? # Is this a cloud user that we have the user data for? + # try to get their exising data + project = unit.projects.where(user_id: user.id).first unless user.nil? + + if project.nil? || project.tutorial_enrolments.count == 0 + # not present (so new), or has no enrolment... so we can enrol it into the cloud tutorial + tutorial = find_cloud_tutorial(unit, tutorial_stats) + row_data[:tutorials] = [ tutorial ] unless tutorial.nil? + end + end + + student_list << row_data + end + end + + import_settings = { + replace_existing_tutorial: false + } + + # Now get unit to sync + unit.sync_enrolment_with(student_list, import_settings, result) + else + logger.error "Failed to sync #{unit.code} - #{response}" + end # if response 200 + end # for each code + rescue Exception => e + logger.error "Failed to sync unit: #{e.message}" + end + result + end + + # Doubtfire::Application.config.institution_settings.fetch_timetable_data(Unit.last) + def fetch_timetable_data(unit) + return {} unless unit.enable_sync_timetable + + logger.info("Fetching STAR data for #{unit.code}") + + sync_streams_from_star(unit) + + result = {} + + tp = unit.teaching_period + + # url = "#{@star_url}/star-#{tp.year}/rest/students/allocated" + server = unit.start_date.year % 2 == 0 ? 'even' : 'odd' + url = "#{@star_url}/#{server}/rest/students/allocated" + + unit.tutorial_streams.each do |tutorial_stream| + logger.info("Fetching #{tutorial_stream.abbreviation} from #{url}") + response = RestClient.post(url, {username: @star_user, password: @star_secret, where_clause:"subject_code LIKE '#{unit.code}%' AND activity_group_code LIKE '#{tutorial_stream.abbreviation}'"}) + + if response.code == 200 + jsonData = JSON.parse(response.body) + + # Switch to the next activity type if this one is empty + next if jsonData['allocations'].count == 0 + + jsonData['allocations'].each do |allocation| + if result[allocation['student_code'].to_i].nil? + result[allocation['student_code'].to_i] = [] + end + + tutorial = fetch_tutorial(unit, allocation) unless allocation['student_code'].nil? + result[allocation['student_code'].to_i] << tutorial unless tutorial.nil? + end + end + end + + result + end + + def tutorial_abbr_for_star(star_data) + "#{star_data['campus']}-#{star_data['activity_group_code']}-#{star_data['activity_code']}" + end + + # Returns the tutorial abbr to enrol in for this activity (one in a stream) + def fetch_tutorial(unit, star_data) + tutorial_code = star_data["activity_group_code"].strip() == "" ? nil : tutorial_abbr_for_star(star_data) + + unless tutorial_code.nil? + tutorial_code = nil if unit.tutorials.where(abbreviation: tutorial_code).count == 0 + end + + tutorial_code + end + + def details_for_next_tutorial_stream(unit, activity_type) + counter = 1 + + begin + name = "#{activity_type.name} #{counter}" + abbreviation = "#{activity_type.abbreviation} #{counter}" + counter += 1 + end while unit.tutorial_streams.where("abbreviation = :abbr OR name = :name", abbr: abbreviation, name: name).present? + + [name, abbreviation] + end +end + +Doubtfire::Application.config.institution_settings = DeakinInstitutionSettings.new diff --git a/config/environment.rb b/config/environment.rb index 8221b35f8..bf2c7af28 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,5 +1,5 @@ # Load the rails application -require File.expand_path('../application', __FILE__) +require_relative "application" # Initialize the rails application Doubtfire::Application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb index d4a248351..77fe9da8b 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,37 +1,92 @@ # Settings specified here will take precedence over those in config/application.rb Doubtfire::Application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # Eager loading on models + config.eager_load = false + # Show full error reports and disable caching config.consider_all_requests_local = true - config.action_controller.perform_caching = false - # Raise errors if the mailer can't send - config.action_mailer.raise_delivery_errors = true + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join('tmp', 'caching-dev.txt').exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + # config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false # Tell Action Mailer not to deliver emails to the real world. # Write them to file instead (under doubtfire-api/tmp/mails) config.action_mailer.delivery_method = :file - # Print deprecation notices to the Rails logger + + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log - # Logging level (:debug, :info, :warn, :error, :fatal) - config.log_level = :info + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise - # Eager loading on models - config.eager_load = false + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Use an evented file watcher to asynchronously detect changes in source code, + # routes, locales, etc. This feature depends on the listen gem. + config.file_watcher = ActiveSupport::EventedFileUpdateChecker + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + + config.action_controller.perform_caching = false + + # Logging level (:debug, :info, :warn, :error, :fatal) + config.log_level = :debug # Only use best-standards-support built into browsers config.action_dispatch.best_standards_support = :builtin - # Do not compress assets - config.assets.compress = false + # Set deterministic randomness, source: https://github.com/stympy/faker#deterministic-random + Faker::Config.random = Random.new(77) - # Expands the lines which load the assets - config.assets.debug = true + require_relative 'doubtfire_logger' + config.logger = DoubtfireLogger.logger + Rails.logger = DoubtfireLogger.logger - # Use the doubtfire logger instead of the default one - if Rails.env.development? - require 'doubtfire_logger' - config.logger = DoubtfireLogger.logger - end + config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z' + config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_DETERMINISTIC_KEY'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx' + config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_PRIMARY_KEY'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5' end diff --git a/config/environments/doubtfire_logger.rb b/config/environments/doubtfire_logger.rb new file mode 100644 index 000000000..84c8f9671 --- /dev/null +++ b/config/environments/doubtfire_logger.rb @@ -0,0 +1,31 @@ +class DoubtfireLogger + # By default, nil is provided + # + # Arguments match: + # 1. logdev - filename or IO object (STDOUT or STDERR) + # 2. shift_age - number of files to keep, or age (e.g., monthly) + # 3. shift_size - maximum log file size (only used when shift_age) + #     is a number + # + # Rails.logger initialises these as nil, so we will do the same + @@file_logger = ActiveSupport::Logger.new(Doubtfire::Application.config.paths['log'].first) + @@console_logger = ActiveSupport::Logger.new(STDOUT) + + @@logger = @@console_logger.extend(ActiveSupport::Logger.broadcast(@@file_logger)) + + @@logger.formatter = proc do |severity, datetime, progname, msg| + "#{datetime},#{DoubtfireLogger.remote_ip},#{severity}: #{msg}\n" + end + + def self.remote_ip + Thread.current.thread_variable_get(:ip) || 'unknown' + end + + # + # Singleton logger returned + # + def self.logger + @@logger + end + +end diff --git a/config/environments/production.rb b/config/environments/production.rb index cc58db732..da6f1a1b6 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -8,16 +8,7 @@ config.action_controller.perform_caching = true # Disable Rails's static asset server (Apache or nginx will already do this) - config.serve_static_files = false - - # Compress JavaScripts and CSS - config.assets.compress = true - - # Don't fallback to assets pipeline if a precompiled asset is missed - config.assets.compile = false - - # Generate digests for assets URLs - config.assets.digest = true + config.serve_static_files = true # Eager loading on models config.eager_load = true @@ -32,5 +23,28 @@ # Send deprecation notices to registered listeners config.active_support.deprecation = :notify + require_relative 'doubtfire_logger' + config.logger = DoubtfireLogger.logger + Rails.logger = DoubtfireLogger.logger config.log_level = :info + + config.action_mailer.perform_deliveries = (ENV['DF_MAIL_PERFORM_DELIVERIES'] || 'yes') == 'yes' + config.action_mailer.delivery_method = (ENV['DF_MAIL_DELIVERY_METHOD'] || 'smtp').to_sym + + if config.action_mailer.delivery_method == :smtp + config.action_mailer.smtp_settings = { + address: (ENV['DF_SMTP_ADDRESS'] || 'localhost'), + port: (ENV['DF_SMTP_PORT'] || 25), + domain: (ENV['DF_SMTP_DOMAIN']), + user_name: (ENV['DF_SMTP_USERNAME']), + password: (ENV['DF_SMTP_PASSWORD']), + authentication: (ENV['DF_SMTP_AUTHENTICATION'] || 'plain'), + enable_starttls_auto: true + } + end + + config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] + config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_DETERMINISTIC_KEY'] + config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_PRIMARY_KEY'] + end diff --git a/config/environments/replica.rb b/config/environments/replica.rb deleted file mode 120000 index 7ea2dafe4..000000000 --- a/config/environments/replica.rb +++ /dev/null @@ -1 +0,0 @@ -config/environments/development.rb \ No newline at end of file diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 42af7b161..5202b4748 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -3,6 +3,13 @@ Doubtfire::Application.configure do # Staging uses production configuration, with minor changes to logging # levels for extra information - config.log_level = :info config.force_ssl = false + + # Set deterministic randomness, source: https://github.com/stympy/faker#deterministic-random + Faker::Config.random = Random.new(77) + + require_relative 'doubtfire_logger' + config.logger = DoubtfireLogger.logger + Rails.logger = DoubtfireLogger.logger + config.log_level = :info end diff --git a/config/environments/test.rb b/config/environments/test.rb index 0611cdfde..107fd390b 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -30,4 +30,18 @@ # Print deprecation notices to the stderr config.active_support.deprecation = :stderr + + # Set deterministic randomness, source: https://github.com/stympy/faker#deterministic-random + Faker::Config.random = Random.new(77) + + require_relative 'doubtfire_logger' + config.logger = DoubtfireLogger.logger + Rails.logger = DoubtfireLogger.logger + + # Logging level (:debug, :info, :warn, :error, :fatal) + config.log_level = :warn + + config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z' + config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx' + config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5' end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 5d8d9be23..1be718083 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -2,12 +2,12 @@ # Add new inflection rules using the following format # (all these examples are active by default): -# ActiveSupport::Inflector.inflections do |inflect| -# inflect.plural /^(ox)$/i, '\1en' +ActiveSupport::Inflector.inflections do |inflect| + inflect.irregular 'campus', 'campuses' + # inflect.plural /^(ox)$/i, '\1en' # inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' # inflect.uncountable %w( fish sheep ) -# end +end # # These inflection rules are supported but not enabled by default: # ActiveSupport::Inflector.inflections do |inflect| diff --git a/config/initializers/serializers.rb b/config/initializers/serializers.rb deleted file mode 100644 index 8f1062329..000000000 --- a/config/initializers/serializers.rb +++ /dev/null @@ -1,7 +0,0 @@ -ActiveSupport.on_load(:active_model_serializers) do - # Disable for all serializers (except ArraySerializer) - ActiveModel::Serializer.root = false - - # Disable for ArraySerializer - ActiveModel::ArraySerializer.root = false -end diff --git a/config/initializers/swagger.rb b/config/initializers/swagger.rb new file mode 100644 index 000000000..e59847a52 --- /dev/null +++ b/config/initializers/swagger.rb @@ -0,0 +1,8 @@ +GrapeSwaggerRails.options.url = '/api/swagger_doc' +GrapeSwaggerRails.options.before_action do + GrapeSwaggerRails.options.app_url = request.protocol + request.host_with_port +end + +GrapeSwaggerRails.options.before_filter_proc = proc { + GrapeSwaggerRails.options.app_url = request.protocol + request.host_with_port +} \ No newline at end of file diff --git a/config/institution.yml b/config/institution.yml index effe45318..702edfc23 100644 --- a/config/institution.yml +++ b/config/institution.yml @@ -2,3 +2,6 @@ name: Doubtfire University email_domain: doubtfire.com host: localhost:3000 product_name: Doubtfire +settings: no_institution_setting.rb +privacy: By clicking on the Upload button, I certify that the attached work is entirely my own (or where submitted to meet the requirements of an approved group assignment is the work of the group), except where work quoted or paraphrased is acknowledged in the text. I also certify that it has not been previously submitted for assessment in this or any other unit or course unless permission for this has been granted by the teaching staff coordinating this unit. I agree that the University may make and retain copies of this work for the purposes of marking and review, and may submit this work to an external plagiarism and collusion detection service who may retain a copy for future plagiarism and collusion detection but will not release it or use it for any other purpose. +plagiarism: Plagiarism and collusion constitute extremely serious academic misconduct. They are forms of cheating, and severe penalties are associated with them, including cancellation of marks for a specific assignment, for a specific unit or even exclusion from the course. If you are ever in doubt about how to cite a reference properly, consult your lecturer or the Study Support website Plagiarism occurs when a student passes off as the student’s own work, or copies without acknowledgement as to its authorship, the work of any other person. Collusion occurs when a student obtains the agreement of another person for a fraudulent purpose, with the intent of obtaining an advantage in submitting an assignment or other work. Work submitted may be reproduced and/or communicated by the university for the purpose of detecting plagiarism and collusion. Students are reminded that assessment work, or parts of assessment work, cannot be re-submitted for a different assessment task in the same unit or any other unit, without the approval from the teaching staff involved. This includes work submitted for assessment at another academic institution. If students wish to reuse or extend parts of previously submitted work then they should discuss this with the teaching staff prior to the submission date. Depending on the nature of the task, the teaching staff may permit or decline the request. \ No newline at end of file diff --git a/config/no_institution_setting.rb b/config/no_institution_setting.rb new file mode 100644 index 000000000..926cd0b4a --- /dev/null +++ b/config/no_institution_setting.rb @@ -0,0 +1,36 @@ + +class InstitutionSettings + def are_headers_institution_users? (headers) + false + end + + def extract_user_from_row(row) + { + unit_code: nil, + username: nil, + student_id: nil, + first_name: nil, + last_name: nil, + email: nil, + tutorials: nil + } + end + + def sync_enrolments(unit) + puts 'Unit sync not enabled' + end + + def details_for_next_tutorial_stream(unit, activity_type) + counter = 1 + + begin + name = "#{activity_type.name} #{counter}" + abbreviation = "#{activity_type.abbreviation} #{counter}" + counter += 1 + end while unit.tutorial_streams.where("abbreviation = :abbr OR name = :name", abbr: abbreviation, name: name).present? + + [name, abbreviation] + end +end + +Doubtfire::Application.config.institution_settings = InstitutionSettings.new diff --git a/config/overseer-images.yml b/config/overseer-images.yml new file mode 100644 index 000000000..186f2a5fe --- /dev/null +++ b/config/overseer-images.yml @@ -0,0 +1,24 @@ +images: + - name: overseer/dotnet:2.2 + packages: + - dotnet + doc_links: + - hub.docker.com + tags: + - c# + - dotnet + - microsoft + info: Dotnet is beautiful and ugly at the same time. + # - name: overseer/dotnet-splashkit:1.0 + # packages: + # - dotnet + # - splashkit + # doc_links: + # - hub.docker.com + # - splashkit.io + # tags: + # - c# + # - dotnet + # - splashkit + # - microsoft + # info: Dotnet is more beautiful with splashkit, yet ugly at the same time. diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 000000000..d9b3e836c --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,43 @@ +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked web server processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +# workers ENV.fetch("WEB_CONCURRENCY") { 2 } + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +# preload_app! + +# Allow puma to be restarted by `rails restart` command. +plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index 3ce3ae4cb..582a6c860 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,9 @@ Doubtfire::Application.routes.draw do - devise_for :users - mount Api::Root => '/' get 'api/submission/unit/:id/portfolio', to: 'portfolio_downloads#index' get 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions', to: 'task_downloads#index' + get 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs', to: 'task_submission_pdfs#index' get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index' + + mount ApiRoot => '/' + mount GrapeSwaggerRails::Engine => '/api/docs' end diff --git a/config/secrets.yml b/config/secrets.yml index e59d46c66..71241f314 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -1,13 +1,21 @@ -development: &development +development: &development-settings secret_key_base: <%= ENV['DF_SECRET_KEY_BASE'] || '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97' %> secret_key_attr: <%= ENV['DF_SECRET_KEY_ATTR'] || 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055' %> secret_key_devise: <%= ENV['DF_SECRET_KEY_DEVISE'] || 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9' %> secret_key_aaf: <%= ENV['DF_SECRET_KEY_AAF'] || 'secretsecret12345' %> secret_key_moss: <%= ENV['DF_SECRET_KEY_MOSS'] %> test: - <<: *development + secret_key_base: <%= ENV['DF_SECRET_KEY_BASE'] || '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97' %> + secret_key_attr: <%= ENV['DF_SECRET_KEY_ATTR'] || 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055' %> + secret_key_devise: <%= ENV['DF_SECRET_KEY_DEVISE'] || 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9' %> + secret_key_aaf: <%= ENV['DF_SECRET_KEY_AAF'] || 'secretsecret12345' %> + secret_key_moss: <%= ENV['DF_SECRET_KEY_MOSS'] %> staging: - <<: *development + secret_key_base: <%= ENV['DF_SECRET_KEY_BASE'] || '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97' %> + secret_key_attr: <%= ENV['DF_SECRET_KEY_ATTR'] || 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055' %> + secret_key_devise: <%= ENV['DF_SECRET_KEY_DEVISE'] || 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9' %> + secret_key_aaf: <%= ENV['DF_SECRET_KEY_AAF'] || 'secretsecret12345' %> + secret_key_moss: <%= ENV['DF_SECRET_KEY_MOSS'] %> production: secret_key_base: <%= ENV['DF_SECRET_KEY_BASE'] %> secret_key_attr: <%= ENV['DF_SECRET_KEY_ATTR'] %> diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 000000000..759af90d2 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,37 @@ +# We do not use ActiveStorage, so while this config file is required +# to be present, it's not used. + +# test: +# service: Disk +# root: <%= Rails.root.join("tmp/storage") %> + +# local: +# service: Disk +# root: <%= Rails.root.join("storage") %> + +# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket +# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: + +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] \ No newline at end of file diff --git a/db/migrate/20120706054808_devise_create_users.rb b/db/migrate/20120706054808_devise_create_users.rb index 4a8546dfd..e77a7dc98 100644 --- a/db/migrate/20120706054808_devise_create_users.rb +++ b/db/migrate/20120706054808_devise_create_users.rb @@ -1,4 +1,4 @@ -class DeviseCreateUsers < ActiveRecord::Migration +class DeviseCreateUsers < ActiveRecord::Migration[4.2] def self.up create_table(:users) do |t| ## Database authenticatable diff --git a/db/migrate/20120706055151_add_name_columns_to_user.rb b/db/migrate/20120706055151_add_name_columns_to_user.rb index e29e80b3b..5f4469483 100644 --- a/db/migrate/20120706055151_add_name_columns_to_user.rb +++ b/db/migrate/20120706055151_add_name_columns_to_user.rb @@ -1,4 +1,4 @@ -class AddNameColumnsToUser < ActiveRecord::Migration +class AddNameColumnsToUser < ActiveRecord::Migration[4.2] def change add_column :users, :first_name, :string add_column :users, :last_name, :string diff --git a/db/migrate/20120712045433_create_project_templates.rb b/db/migrate/20120712045433_create_project_templates.rb index 79f3998e7..a2f8cc9d5 100644 --- a/db/migrate/20120712045433_create_project_templates.rb +++ b/db/migrate/20120712045433_create_project_templates.rb @@ -1,4 +1,4 @@ -class CreateProjectTemplates < ActiveRecord::Migration +class CreateProjectTemplates < ActiveRecord::Migration[4.2] def change create_table :project_templates do |t| t.string :name diff --git a/db/migrate/20120712045608_create_task_templates.rb b/db/migrate/20120712045608_create_task_templates.rb index 7d516c4a0..050741878 100644 --- a/db/migrate/20120712045608_create_task_templates.rb +++ b/db/migrate/20120712045608_create_task_templates.rb @@ -1,4 +1,4 @@ -class CreateTaskTemplates < ActiveRecord::Migration +class CreateTaskTemplates < ActiveRecord::Migration[4.2] def change create_table :task_templates do |t| t.references :project_template diff --git a/db/migrate/20120712045717_create_teams.rb b/db/migrate/20120712045717_create_teams.rb index 71838defd..ada8d5160 100644 --- a/db/migrate/20120712045717_create_teams.rb +++ b/db/migrate/20120712045717_create_teams.rb @@ -1,4 +1,4 @@ -class CreateTeams < ActiveRecord::Migration +class CreateTeams < ActiveRecord::Migration[4.2] def change create_table :teams do |t| t.references :project_template diff --git a/db/migrate/20120712051522_create_task_statuses.rb b/db/migrate/20120712051522_create_task_statuses.rb index b9b66025c..9dd84862f 100644 --- a/db/migrate/20120712051522_create_task_statuses.rb +++ b/db/migrate/20120712051522_create_task_statuses.rb @@ -1,4 +1,4 @@ -class CreateTaskStatuses < ActiveRecord::Migration +class CreateTaskStatuses < ActiveRecord::Migration[4.2] def change create_table :task_statuses do |t| t.string :name diff --git a/db/migrate/20120712051608_create_projects.rb b/db/migrate/20120712051608_create_projects.rb index cce2f7984..808f1a1a9 100644 --- a/db/migrate/20120712051608_create_projects.rb +++ b/db/migrate/20120712051608_create_projects.rb @@ -1,4 +1,4 @@ -class CreateProjects < ActiveRecord::Migration +class CreateProjects < ActiveRecord::Migration[4.2] def change create_table :projects do |t| t.references :project_template diff --git a/db/migrate/20120712051703_create_tasks.rb b/db/migrate/20120712051703_create_tasks.rb index ee1e36163..024b55150 100644 --- a/db/migrate/20120712051703_create_tasks.rb +++ b/db/migrate/20120712051703_create_tasks.rb @@ -1,4 +1,4 @@ -class CreateTasks < ActiveRecord::Migration +class CreateTasks < ActiveRecord::Migration[4.2] def change create_table :tasks do |t| t.references :task_template diff --git a/db/migrate/20120713033807_create_team_memberships.rb b/db/migrate/20120713033807_create_team_memberships.rb index 6aca76de7..c8bde3aaa 100644 --- a/db/migrate/20120713033807_create_team_memberships.rb +++ b/db/migrate/20120713033807_create_team_memberships.rb @@ -1,4 +1,4 @@ -class CreateTeamMemberships < ActiveRecord::Migration +class CreateTeamMemberships < ActiveRecord::Migration[4.2] def change create_table :team_memberships do |t| t.references :user diff --git a/db/migrate/20120717075326_create_project_convenors.rb b/db/migrate/20120717075326_create_project_convenors.rb index af699e591..acc81988b 100644 --- a/db/migrate/20120717075326_create_project_convenors.rb +++ b/db/migrate/20120717075326_create_project_convenors.rb @@ -1,4 +1,4 @@ -class CreateProjectConvenors < ActiveRecord::Migration +class CreateProjectConvenors < ActiveRecord::Migration[4.2] def change create_table :project_convenors do |t| t.references :project_template diff --git a/db/migrate/20120717081019_add_system_role_to_user.rb b/db/migrate/20120717081019_add_system_role_to_user.rb index d78122983..babc325d3 100644 --- a/db/migrate/20120717081019_add_system_role_to_user.rb +++ b/db/migrate/20120717081019_add_system_role_to_user.rb @@ -1,4 +1,4 @@ -class AddSystemRoleToUser < ActiveRecord::Migration +class AddSystemRoleToUser < ActiveRecord::Migration[4.2] def up add_column :users, :system_role, :string end @@ -6,4 +6,4 @@ def up def down remove_column :users, :system_role end -end \ No newline at end of file +end diff --git a/db/migrate/20120719234620_add_completion_date_to_task.rb b/db/migrate/20120719234620_add_completion_date_to_task.rb index 6e5f31016..e8955743b 100644 --- a/db/migrate/20120719234620_add_completion_date_to_task.rb +++ b/db/migrate/20120719234620_add_completion_date_to_task.rb @@ -1,4 +1,4 @@ -class AddCompletionDateToTask < ActiveRecord::Migration +class AddCompletionDateToTask < ActiveRecord::Migration[4.2] def change add_column :tasks, :completion_date, :date end diff --git a/db/migrate/20120802053203_add_username_nickame_to_users.rb b/db/migrate/20120802053203_add_username_nickame_to_users.rb index 94a8b4612..26a9c6f16 100644 --- a/db/migrate/20120802053203_add_username_nickame_to_users.rb +++ b/db/migrate/20120802053203_add_username_nickame_to_users.rb @@ -1,4 +1,4 @@ -class AddUsernameNickameToUsers < ActiveRecord::Migration +class AddUsernameNickameToUsers < ActiveRecord::Migration[4.2] def change add_column :users, :username, :string add_column :users, :nickname, :string diff --git a/db/migrate/20120808133306_create_logins.rb b/db/migrate/20120808133306_create_logins.rb index e64a3c144..40144dceb 100644 --- a/db/migrate/20120808133306_create_logins.rb +++ b/db/migrate/20120808133306_create_logins.rb @@ -1,4 +1,4 @@ -class CreateLogins < ActiveRecord::Migration +class CreateLogins < ActiveRecord::Migration[4.2] def change create_table :logins do |t| t.datetime :timestamp diff --git a/db/migrate/20120808141243_create_task_submissions.rb b/db/migrate/20120808141243_create_task_submissions.rb index 229f16e28..88bbc1cf6 100644 --- a/db/migrate/20120808141243_create_task_submissions.rb +++ b/db/migrate/20120808141243_create_task_submissions.rb @@ -1,4 +1,4 @@ -class CreateTaskSubmissions < ActiveRecord::Migration +class CreateTaskSubmissions < ActiveRecord::Migration[4.2] def change create_table :task_submissions do |t| t.datetime :submission_time diff --git a/db/migrate/20120810054628_add_official_name_to_project_template.rb b/db/migrate/20120810054628_add_official_name_to_project_template.rb index 7bf8dda47..fae35f1d8 100644 --- a/db/migrate/20120810054628_add_official_name_to_project_template.rb +++ b/db/migrate/20120810054628_add_official_name_to_project_template.rb @@ -1,4 +1,4 @@ -class AddOfficialNameToProjectTemplate < ActiveRecord::Migration +class AddOfficialNameToProjectTemplate < ActiveRecord::Migration[4.2] def change add_column :project_templates, :official_name, :string end diff --git a/db/migrate/20120810070733_add_official_name_to_team.rb b/db/migrate/20120810070733_add_official_name_to_team.rb index 9d23def06..5056d8341 100644 --- a/db/migrate/20120810070733_add_official_name_to_team.rb +++ b/db/migrate/20120810070733_add_official_name_to_team.rb @@ -1,4 +1,4 @@ -class AddOfficialNameToTeam < ActiveRecord::Migration +class AddOfficialNameToTeam < ActiveRecord::Migration[4.2] def change add_column :teams, :official_name, :string end diff --git a/db/migrate/20120814004221_add_assessor_to_task_submission.rb b/db/migrate/20120814004221_add_assessor_to_task_submission.rb index 3305eb625..1468b50d2 100644 --- a/db/migrate/20120814004221_add_assessor_to_task_submission.rb +++ b/db/migrate/20120814004221_add_assessor_to_task_submission.rb @@ -1,4 +1,4 @@ -class AddAssessorToTaskSubmission < ActiveRecord::Migration +class AddAssessorToTaskSubmission < ActiveRecord::Migration[4.2] def self.up add_column :task_submissions, :assessor_id, :integer end diff --git a/db/migrate/20120817005120_rename_recommended_completion_date_to_target_date.rb b/db/migrate/20120817005120_rename_recommended_completion_date_to_target_date.rb index 89f285da6..d4d99f203 100644 --- a/db/migrate/20120817005120_rename_recommended_completion_date_to_target_date.rb +++ b/db/migrate/20120817005120_rename_recommended_completion_date_to_target_date.rb @@ -1,4 +1,4 @@ -class RenameRecommendedCompletionDateToTargetDate < ActiveRecord::Migration +class RenameRecommendedCompletionDateToTargetDate < ActiveRecord::Migration[4.2] def change rename_column :task_templates, :recommended_completion_date, :target_date end diff --git a/db/migrate/20120819132403_create_task_engagements.rb b/db/migrate/20120819132403_create_task_engagements.rb index 4426330c3..3286b86d6 100644 --- a/db/migrate/20120819132403_create_task_engagements.rb +++ b/db/migrate/20120819132403_create_task_engagements.rb @@ -1,4 +1,4 @@ -class CreateTaskEngagements < ActiveRecord::Migration +class CreateTaskEngagements < ActiveRecord::Migration[4.2] def change create_table :task_engagements do |t| t.datetime :engagement_time diff --git a/db/migrate/20120903040421_add_started_to_projects.rb b/db/migrate/20120903040421_add_started_to_projects.rb index 284a698c2..dececb047 100644 --- a/db/migrate/20120903040421_add_started_to_projects.rb +++ b/db/migrate/20120903040421_add_started_to_projects.rb @@ -1,4 +1,4 @@ -class AddStartedToProjects < ActiveRecord::Migration +class AddStartedToProjects < ActiveRecord::Migration[4.2] def change add_column :projects, :started, :boolean @@ -14,4 +14,4 @@ def change project.update_attribute(:started, started) end end -end \ No newline at end of file +end diff --git a/db/migrate/20120911232016_add_temporal_progress_status_to_projects.rb b/db/migrate/20120911232016_add_temporal_progress_status_to_projects.rb index 4d6959a44..aa843006f 100644 --- a/db/migrate/20120911232016_add_temporal_progress_status_to_projects.rb +++ b/db/migrate/20120911232016_add_temporal_progress_status_to_projects.rb @@ -1,4 +1,4 @@ -class AddTemporalProgressStatusToProjects < ActiveRecord::Migration +class AddTemporalProgressStatusToProjects < ActiveRecord::Migration[4.2] def change add_column :projects, :progress, :string add_column :projects, :status, :string diff --git a/db/migrate/20120917032431_add_abbreviation_to_task_templates.rb b/db/migrate/20120917032431_add_abbreviation_to_task_templates.rb index 720377763..b4703fe89 100644 --- a/db/migrate/20120917032431_add_abbreviation_to_task_templates.rb +++ b/db/migrate/20120917032431_add_abbreviation_to_task_templates.rb @@ -1,4 +1,4 @@ -class AddAbbreviationToTaskTemplates < ActiveRecord::Migration +class AddAbbreviationToTaskTemplates < ActiveRecord::Migration[4.2] def change add_column :task_templates, :abbreviation, :string end diff --git a/db/migrate/20130227035353_add_active_to_project_template.rb b/db/migrate/20130227035353_add_active_to_project_template.rb index 1609d3f2c..d5beb6e98 100644 --- a/db/migrate/20130227035353_add_active_to_project_template.rb +++ b/db/migrate/20130227035353_add_active_to_project_template.rb @@ -1,4 +1,4 @@ -class AddActiveToProjectTemplate < ActiveRecord::Migration +class AddActiveToProjectTemplate < ActiveRecord::Migration[4.2] def change add_column :project_templates, :active, :boolean, default: true end diff --git a/db/migrate/20130613063145_rename_project_template_to_unit.rb b/db/migrate/20130613063145_rename_project_template_to_unit.rb index a5eac0f98..4cd99ba38 100644 --- a/db/migrate/20130613063145_rename_project_template_to_unit.rb +++ b/db/migrate/20130613063145_rename_project_template_to_unit.rb @@ -1,4 +1,4 @@ -class RenameProjectTemplateToUnit < ActiveRecord::Migration +class RenameProjectTemplateToUnit < ActiveRecord::Migration[4.2] def change rename_table :project_templates, :units end diff --git a/db/migrate/20130613070051_rename_project_template_id_to_unit_id.rb b/db/migrate/20130613070051_rename_project_template_id_to_unit_id.rb index ae14e18ea..4ed369404 100644 --- a/db/migrate/20130613070051_rename_project_template_id_to_unit_id.rb +++ b/db/migrate/20130613070051_rename_project_template_id_to_unit_id.rb @@ -1,4 +1,4 @@ -class RenameProjectTemplateIdToUnitId < ActiveRecord::Migration +class RenameProjectTemplateIdToUnitId < ActiveRecord::Migration[4.2] def change rename_column :teams, :project_template_id, :unit_id rename_column :task_templates, :project_template_id, :unit_id @@ -9,4 +9,4 @@ def change rename_index :task_templates, "index_task_templates_on_project_template_id", "index_task_templates_on_unit_id" rename_index :teams, "index_teams_on_project_template_id", "index_teams_on_unit_id" end -end \ No newline at end of file +end diff --git a/db/migrate/20130613123626_rename_task_template_to_task_definition.rb b/db/migrate/20130613123626_rename_task_template_to_task_definition.rb index 85cbc78d1..534f79864 100644 --- a/db/migrate/20130613123626_rename_task_template_to_task_definition.rb +++ b/db/migrate/20130613123626_rename_task_template_to_task_definition.rb @@ -1,4 +1,4 @@ -class RenameTaskTemplateToTaskDefinition < ActiveRecord::Migration +class RenameTaskTemplateToTaskDefinition < ActiveRecord::Migration[4.2] def change rename_table :task_templates, :task_definitions end diff --git a/db/migrate/20130613123739_rename_task_template_id_to_task_definition_id.rb b/db/migrate/20130613123739_rename_task_template_id_to_task_definition_id.rb index c7837189e..b513ece2f 100644 --- a/db/migrate/20130613123739_rename_task_template_id_to_task_definition_id.rb +++ b/db/migrate/20130613123739_rename_task_template_id_to_task_definition_id.rb @@ -1,4 +1,4 @@ -class RenameTaskTemplateIdToTaskDefinitionId < ActiveRecord::Migration +class RenameTaskTemplateIdToTaskDefinitionId < ActiveRecord::Migration[4.2] def change rename_column :tasks, :task_template_id, :task_definition_id diff --git a/db/migrate/20130614104114_change_team_membership_to_unit_role.rb b/db/migrate/20130614104114_change_team_membership_to_unit_role.rb index 6ec92b12a..be137d1ad 100644 --- a/db/migrate/20130614104114_change_team_membership_to_unit_role.rb +++ b/db/migrate/20130614104114_change_team_membership_to_unit_role.rb @@ -1,4 +1,4 @@ -class ChangeTeamMembershipToUnitRole < ActiveRecord::Migration +class ChangeTeamMembershipToUnitRole < ActiveRecord::Migration[4.2] def change rename_table :team_memberships, :unit_roles end diff --git a/db/migrate/20130614110143_change_team_membership_id_to_unit_role_id.rb b/db/migrate/20130614110143_change_team_membership_id_to_unit_role_id.rb index 370570339..97f0279ff 100644 --- a/db/migrate/20130614110143_change_team_membership_id_to_unit_role_id.rb +++ b/db/migrate/20130614110143_change_team_membership_id_to_unit_role_id.rb @@ -1,4 +1,4 @@ -class ChangeTeamMembershipIdToUnitRoleId < ActiveRecord::Migration +class ChangeTeamMembershipIdToUnitRoleId < ActiveRecord::Migration[4.2] def change rename_column :projects, :team_membership_id, :unit_role_id diff --git a/db/migrate/20130617115645_rename_team_to_tutorial.rb b/db/migrate/20130617115645_rename_team_to_tutorial.rb index 8aec5208f..8f07950af 100644 --- a/db/migrate/20130617115645_rename_team_to_tutorial.rb +++ b/db/migrate/20130617115645_rename_team_to_tutorial.rb @@ -1,4 +1,4 @@ -class RenameTeamToTutorial < ActiveRecord::Migration +class RenameTeamToTutorial < ActiveRecord::Migration[4.2] def change rename_table :teams, :tutorials diff --git a/db/migrate/20130617133146_create_roles.rb b/db/migrate/20130617133146_create_roles.rb index 3fe9bb834..9bedcbdd8 100644 --- a/db/migrate/20130617133146_create_roles.rb +++ b/db/migrate/20130617133146_create_roles.rb @@ -1,4 +1,4 @@ -class CreateRoles < ActiveRecord::Migration +class CreateRoles < ActiveRecord::Migration[4.2] def up create_table :roles do |t| t.string :name diff --git a/db/migrate/20130619003738_create_sub_task_definitions.rb b/db/migrate/20130619003738_create_sub_task_definitions.rb deleted file mode 100644 index 0b85f88b1..000000000 --- a/db/migrate/20130619003738_create_sub_task_definitions.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateSubTaskDefinitions < ActiveRecord::Migration - def change - create_table :sub_task_definitions do |t| - t.string :name - t.text :description - t.references :badges - t.references :task_definitions - - t.timestamps - end - add_index :sub_task_definitions, :badges_id - end -end \ No newline at end of file diff --git a/db/migrate/20130619075014_create_badges.rb b/db/migrate/20130619075014_create_badges.rb deleted file mode 100644 index 4bb3f2a16..000000000 --- a/db/migrate/20130619075014_create_badges.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateBadges < ActiveRecord::Migration - def change - create_table :badges do |t| - t.string :name - t.text :description - t.string :large_image_url - t.string :small_image_url - t.references :sub_task_definition - - t.timestamps - end - end -end diff --git a/db/migrate/20130619093449_add_role_to_unit_role.rb b/db/migrate/20130619093449_add_role_to_unit_role.rb index 38162adba..deb09b164 100644 --- a/db/migrate/20130619093449_add_role_to_unit_role.rb +++ b/db/migrate/20130619093449_add_role_to_unit_role.rb @@ -1,4 +1,4 @@ -class AddRoleToUnitRole < ActiveRecord::Migration +class AddRoleToUnitRole < ActiveRecord::Migration[4.2] def up add_column :unit_roles, :role_id, :integer add_index :unit_roles, :role_id @@ -33,7 +33,7 @@ def up Tutorial.all.each do |tutorial| tutor = tutorial.user_id unit = tutorial.unit_id - tutorial_id = tutorial.id + tutorial_id = tutorial.id.first tutorial_unit_map[tutorial_id] = unit tutorial_user_map[tutorial_id] = tutor @@ -74,4 +74,4 @@ def down remove_index :unit_roles, :role_id remove_column :unit_roles, :role_id end -end \ No newline at end of file +end diff --git a/db/migrate/20130621061941_create_user_roles.rb b/db/migrate/20130621061941_create_user_roles.rb index ddaa52d22..8dfc587b2 100644 --- a/db/migrate/20130621061941_create_user_roles.rb +++ b/db/migrate/20130621061941_create_user_roles.rb @@ -1,4 +1,4 @@ -class CreateUserRoles < ActiveRecord::Migration +class CreateUserRoles < ActiveRecord::Migration[4.2] def up create_table :user_roles do |t| t.references :user @@ -18,4 +18,4 @@ def up def down drop_table :user_roles end -end \ No newline at end of file +end diff --git a/db/migrate/20130621062310_rename_unit_official_name_to_code.rb b/db/migrate/20130621062310_rename_unit_official_name_to_code.rb index de0c9b73e..ee486f6d8 100644 --- a/db/migrate/20130621062310_rename_unit_official_name_to_code.rb +++ b/db/migrate/20130621062310_rename_unit_official_name_to_code.rb @@ -1,5 +1,5 @@ -class RenameUnitOfficialNameToCode < ActiveRecord::Migration +class RenameUnitOfficialNameToCode < ActiveRecord::Migration[4.2] def change rename_column :units, :official_name, :code end -end \ No newline at end of file +end diff --git a/db/migrate/20130621064636_remove_project_convenor_in_favour_of_unit_role.rb b/db/migrate/20130621064636_remove_project_convenor_in_favour_of_unit_role.rb index 2d7f05ff8..0fb25338d 100644 --- a/db/migrate/20130621064636_remove_project_convenor_in_favour_of_unit_role.rb +++ b/db/migrate/20130621064636_remove_project_convenor_in_favour_of_unit_role.rb @@ -1,4 +1,4 @@ -class RemoveProjectConvenorInFavourOfUnitRole < ActiveRecord::Migration +class RemoveProjectConvenorInFavourOfUnitRole < ActiveRecord::Migration[4.2] def up convenor_role = Role.where(name: 'Convenor').first ProjectConvenor.all.each do |convenor| diff --git a/db/migrate/20130621092707_rename_tutorial_official_name_to_code.rb b/db/migrate/20130621092707_rename_tutorial_official_name_to_code.rb index 2c4573097..de2c05895 100644 --- a/db/migrate/20130621092707_rename_tutorial_official_name_to_code.rb +++ b/db/migrate/20130621092707_rename_tutorial_official_name_to_code.rb @@ -1,4 +1,4 @@ -class RenameTutorialOfficialNameToCode < ActiveRecord::Migration +class RenameTutorialOfficialNameToCode < ActiveRecord::Migration[4.2] def change rename_column :tutorials, :official_name, :code end diff --git a/db/migrate/20130626104951_create_sub_tasks.rb b/db/migrate/20130626104951_create_sub_tasks.rb deleted file mode 100644 index ee7da405c..000000000 --- a/db/migrate/20130626104951_create_sub_tasks.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CreateSubTasks < ActiveRecord::Migration - def change - create_table :sub_tasks do |t| - t.datetime :completion_date - t.references :sub_task_definition - t.references :task - - t.timestamps - end - end -end diff --git a/db/migrate/20130627092526_add_required_to_sub_task_definitions.rb b/db/migrate/20130627092526_add_required_to_sub_task_definitions.rb index 4a492270b..a1cea7280 100644 --- a/db/migrate/20130627092526_add_required_to_sub_task_definitions.rb +++ b/db/migrate/20130627092526_add_required_to_sub_task_definitions.rb @@ -1,4 +1,4 @@ -class AddRequiredToSubTaskDefinitions < ActiveRecord::Migration +class AddRequiredToSubTaskDefinitions < ActiveRecord::Migration[4.2] def change add_column :sub_task_definitions, :required, :boolean, default: false, null: false end diff --git a/db/migrate/20140216084714_change_role_description_to_text.rb b/db/migrate/20140216084714_change_role_description_to_text.rb index c3e08bd04..776886cac 100644 --- a/db/migrate/20140216084714_change_role_description_to_text.rb +++ b/db/migrate/20140216084714_change_role_description_to_text.rb @@ -1,4 +1,4 @@ -class ChangeRoleDescriptionToText < ActiveRecord::Migration +class ChangeRoleDescriptionToText < ActiveRecord::Migration[4.2] def change change_column :roles, :description, :text end diff --git a/db/migrate/20140219035622_add_authentication_token_to_user.rb b/db/migrate/20140219035622_add_authentication_token_to_user.rb index 7af5ae507..5b95b43c0 100644 --- a/db/migrate/20140219035622_add_authentication_token_to_user.rb +++ b/db/migrate/20140219035622_add_authentication_token_to_user.rb @@ -1,4 +1,4 @@ -class AddAuthenticationTokenToUser < ActiveRecord::Migration +class AddAuthenticationTokenToUser < ActiveRecord::Migration[4.2] def change add_column :users, :authentication_token, :string add_index :users, :authentication_token, unique: true diff --git a/db/migrate/20140219093336_add_lockable_attributes_to_user.rb b/db/migrate/20140219093336_add_lockable_attributes_to_user.rb index dbb313db6..ae9f187c4 100644 --- a/db/migrate/20140219093336_add_lockable_attributes_to_user.rb +++ b/db/migrate/20140219093336_add_lockable_attributes_to_user.rb @@ -1,4 +1,4 @@ -class AddLockableAttributesToUser < ActiveRecord::Migration +class AddLockableAttributesToUser < ActiveRecord::Migration[4.2] def change add_column :users, :failed_attempts, :integer, default: 0, null: false add_column :users, :unlock_token, :string diff --git a/db/migrate/20140604111219_add_project_stats_cache.rb b/db/migrate/20140604111219_add_project_stats_cache.rb index 38be4856e..dec0d9049 100644 --- a/db/migrate/20140604111219_add_project_stats_cache.rb +++ b/db/migrate/20140604111219_add_project_stats_cache.rb @@ -1,4 +1,4 @@ -class AddProjectStatsCache < ActiveRecord::Migration +class AddProjectStatsCache < ActiveRecord::Migration[4.2] def change add_column :projects, :task_stats, :string end diff --git a/db/migrate/20140702050107_add_abbreviation_to_tutorials.rb b/db/migrate/20140702050107_add_abbreviation_to_tutorials.rb index 212e1ec6b..7d8ba22dc 100644 --- a/db/migrate/20140702050107_add_abbreviation_to_tutorials.rb +++ b/db/migrate/20140702050107_add_abbreviation_to_tutorials.rb @@ -1,4 +1,4 @@ -class AddAbbreviationToTutorials < ActiveRecord::Migration +class AddAbbreviationToTutorials < ActiveRecord::Migration[4.2] def change add_column :tutorials, :abbreviation, :string end diff --git a/db/migrate/20140709023710_remove_user_roles.rb b/db/migrate/20140709023710_remove_user_roles.rb index e2c2ebb2b..94c231e38 100644 --- a/db/migrate/20140709023710_remove_user_roles.rb +++ b/db/migrate/20140709023710_remove_user_roles.rb @@ -1,4 +1,4 @@ -class RemoveUserRoles < ActiveRecord::Migration +class RemoveUserRoles < ActiveRecord::Migration[4.2] def change drop_table :user_roles end diff --git a/db/migrate/20140709041205_change_users_system_role_to_integer.rb b/db/migrate/20140709041205_change_users_system_role_to_integer.rb index 9aa776aed..c3e7d35d2 100644 --- a/db/migrate/20140709041205_change_users_system_role_to_integer.rb +++ b/db/migrate/20140709041205_change_users_system_role_to_integer.rb @@ -1,4 +1,4 @@ -class ChangeUsersSystemRoleToInteger < ActiveRecord::Migration +class ChangeUsersSystemRoleToInteger < ActiveRecord::Migration[4.2] def change # Change column won't work because a string would need to be cast to an int remove_column :users, :system_role diff --git a/db/migrate/20140709045846_rename_users_system_role_id_to_role_id.rb b/db/migrate/20140709045846_rename_users_system_role_id_to_role_id.rb index 4c5e7cfc6..87162579f 100644 --- a/db/migrate/20140709045846_rename_users_system_role_id_to_role_id.rb +++ b/db/migrate/20140709045846_rename_users_system_role_id_to_role_id.rb @@ -1,4 +1,4 @@ -class RenameUsersSystemRoleIdToRoleId < ActiveRecord::Migration +class RenameUsersSystemRoleIdToRoleId < ActiveRecord::Migration[4.2] def change rename_column :users, :system_role_id, :role_id end diff --git a/db/migrate/20140710042612_remove_user_account_locking.rb b/db/migrate/20140710042612_remove_user_account_locking.rb index dc36e1e17..dabd0e966 100644 --- a/db/migrate/20140710042612_remove_user_account_locking.rb +++ b/db/migrate/20140710042612_remove_user_account_locking.rb @@ -1,4 +1,4 @@ -class RemoveUserAccountLocking < ActiveRecord::Migration +class RemoveUserAccountLocking < ActiveRecord::Migration[4.2] def change remove_column :users, :failed_attempts remove_column :users, :locked_at diff --git a/db/migrate/20140721013207_add_upload_requirements_to_task_definitions.rb b/db/migrate/20140721013207_add_upload_requirements_to_task_definitions.rb index 9ff5a45ce..65e3733fe 100644 --- a/db/migrate/20140721013207_add_upload_requirements_to_task_definitions.rb +++ b/db/migrate/20140721013207_add_upload_requirements_to_task_definitions.rb @@ -1,4 +1,4 @@ -class AddUploadRequirementsToTaskDefinitions < ActiveRecord::Migration +class AddUploadRequirementsToTaskDefinitions < ActiveRecord::Migration[4.2] def up add_column :task_definitions, :upload_requirements, :json end diff --git a/db/migrate/20140721013831_add_portfolio_evidence_to_tasks.rb b/db/migrate/20140721013831_add_portfolio_evidence_to_tasks.rb index 5341274bc..571cff997 100644 --- a/db/migrate/20140721013831_add_portfolio_evidence_to_tasks.rb +++ b/db/migrate/20140721013831_add_portfolio_evidence_to_tasks.rb @@ -1,4 +1,4 @@ -class AddPortfolioEvidenceToTasks < ActiveRecord::Migration +class AddPortfolioEvidenceToTasks < ActiveRecord::Migration[4.2] def up add_column :tasks, :portfolio_evidence, :string end diff --git a/db/migrate/20140729035754_change_json_to_string_in_db.rb b/db/migrate/20140729035754_change_json_to_string_in_db.rb index d6bc3057e..26ce27de4 100644 --- a/db/migrate/20140729035754_change_json_to_string_in_db.rb +++ b/db/migrate/20140729035754_change_json_to_string_in_db.rb @@ -1,4 +1,4 @@ -class ChangeJsonToStringInDb < ActiveRecord::Migration +class ChangeJsonToStringInDb < ActiveRecord::Migration[4.2] def change change_column :task_definitions, :upload_requirements, :string end diff --git a/db/migrate/20140803232423_update_upload_requirements.rb b/db/migrate/20140803232423_update_upload_requirements.rb index 353c8450b..dfd932d76 100644 --- a/db/migrate/20140803232423_update_upload_requirements.rb +++ b/db/migrate/20140803232423_update_upload_requirements.rb @@ -1,4 +1,4 @@ -class UpdateUploadRequirements < ActiveRecord::Migration +class UpdateUploadRequirements < ActiveRecord::Migration[4.2] def change change_column :task_definitions, :upload_requirements, :string, :limit => 2048 end diff --git a/db/migrate/20140811032520_add_enrolled_status_to_projects.rb b/db/migrate/20140811032520_add_enrolled_status_to_projects.rb index 3227d45b2..54d0aa186 100644 --- a/db/migrate/20140811032520_add_enrolled_status_to_projects.rb +++ b/db/migrate/20140811032520_add_enrolled_status_to_projects.rb @@ -1,4 +1,4 @@ -class AddEnrolledStatusToProjects < ActiveRecord::Migration +class AddEnrolledStatusToProjects < ActiveRecord::Migration[4.2] def change add_column :projects, :enrolled, :boolean, :default => true add_index :projects, :enrolled diff --git a/db/migrate/20140823032141_add_target_grade.rb b/db/migrate/20140823032141_add_target_grade.rb index 11adfe502..e2e08aefc 100644 --- a/db/migrate/20140823032141_add_target_grade.rb +++ b/db/migrate/20140823032141_add_target_grade.rb @@ -1,4 +1,4 @@ -class AddTargetGrade < ActiveRecord::Migration +class AddTargetGrade < ActiveRecord::Migration[4.2] def change add_column :projects, :target_grade, :integer, :default => 0 add_column :task_definitions, :target_grade, :integer, :default => 0 diff --git a/db/migrate/20141021220125_add_include_task_in_portfolio_flag.rb b/db/migrate/20141021220125_add_include_task_in_portfolio_flag.rb index a94f11a13..1d626d0bc 100644 --- a/db/migrate/20141021220125_add_include_task_in_portfolio_flag.rb +++ b/db/migrate/20141021220125_add_include_task_in_portfolio_flag.rb @@ -1,4 +1,4 @@ -class AddIncludeTaskInPortfolioFlag < ActiveRecord::Migration +class AddIncludeTaskInPortfolioFlag < ActiveRecord::Migration[4.2] def change add_column :tasks, :include_in_portfolio, :boolean, :default => true end diff --git a/db/migrate/20141022042317_add_project_compile_flag.rb b/db/migrate/20141022042317_add_project_compile_flag.rb index e3f7abfd4..d5fd3870a 100644 --- a/db/migrate/20141022042317_add_project_compile_flag.rb +++ b/db/migrate/20141022042317_add_project_compile_flag.rb @@ -1,4 +1,4 @@ -class AddProjectCompileFlag < ActiveRecord::Migration +class AddProjectCompileFlag < ActiveRecord::Migration[4.2] def change add_column :projects, :compile_portfolio, :boolean, :default => false end diff --git a/db/migrate/20141106232201_add_portfolio_production_date.rb b/db/migrate/20141106232201_add_portfolio_production_date.rb index c5de07ae0..85858d4a0 100644 --- a/db/migrate/20141106232201_add_portfolio_production_date.rb +++ b/db/migrate/20141106232201_add_portfolio_production_date.rb @@ -1,4 +1,4 @@ -class AddPortfolioProductionDate < ActiveRecord::Migration +class AddPortfolioProductionDate < ActiveRecord::Migration[4.2] def change add_column :projects, :portfolio_production_date, :date end diff --git a/db/migrate/20141107003540_create_intended_learning_outcomes.rb b/db/migrate/20141107003540_create_intended_learning_outcomes.rb index 8a3ed2886..b26085061 100644 --- a/db/migrate/20141107003540_create_intended_learning_outcomes.rb +++ b/db/migrate/20141107003540_create_intended_learning_outcomes.rb @@ -1,4 +1,4 @@ -class CreateIntendedLearningOutcomes < ActiveRecord::Migration +class CreateIntendedLearningOutcomes < ActiveRecord::Migration[4.2] def change create_table :intended_learning_outcomes do |t| t.references :unit diff --git a/db/migrate/20150320000131_create_task_comments.rb b/db/migrate/20150320000131_create_task_comments.rb index cad5c9046..4318e5d89 100644 --- a/db/migrate/20150320000131_create_task_comments.rb +++ b/db/migrate/20150320000131_create_task_comments.rb @@ -1,4 +1,4 @@ -class CreateTaskComments < ActiveRecord::Migration +class CreateTaskComments < ActiveRecord::Migration[4.2] def change create_table :task_comments do |t| t.references :task, null: false diff --git a/db/migrate/20150417062654_add_notification_settings.rb b/db/migrate/20150417062654_add_notification_settings.rb index 21f5fef05..54a4651ca 100644 --- a/db/migrate/20150417062654_add_notification_settings.rb +++ b/db/migrate/20150417062654_add_notification_settings.rb @@ -1,4 +1,4 @@ -class AddNotificationSettings < ActiveRecord::Migration +class AddNotificationSettings < ActiveRecord::Migration[4.2] def change add_column :users, :receive_task_notifications, :boolean, default: true add_column :users, :receive_feedback_notifications, :boolean, default: true diff --git a/db/migrate/20150506073706_add_restrict_task_status_updates.rb b/db/migrate/20150506073706_add_restrict_task_status_updates.rb index 71a773d90..345035575 100644 --- a/db/migrate/20150506073706_add_restrict_task_status_updates.rb +++ b/db/migrate/20150506073706_add_restrict_task_status_updates.rb @@ -1,4 +1,4 @@ -class AddRestrictTaskStatusUpdates < ActiveRecord::Migration +class AddRestrictTaskStatusUpdates < ActiveRecord::Migration[4.2] def change add_column :task_definitions, :restrict_status_updates, :boolean, default: false end diff --git a/db/migrate/20150521052522_add_plagarism_detection_date.rb b/db/migrate/20150521052522_add_plagarism_detection_date.rb index 236889f74..5bd4ce827 100644 --- a/db/migrate/20150521052522_add_plagarism_detection_date.rb +++ b/db/migrate/20150521052522_add_plagarism_detection_date.rb @@ -1,4 +1,4 @@ -class AddPlagarismDetectionDate < ActiveRecord::Migration +class AddPlagarismDetectionDate < ActiveRecord::Migration[4.2] def change add_column :units, :last_plagarism_scan, :datetime end diff --git a/db/migrate/20150528082420_add_plagiarism_checks_data.rb b/db/migrate/20150528082420_add_plagiarism_checks_data.rb index 756f0addf..61770f660 100644 --- a/db/migrate/20150528082420_add_plagiarism_checks_data.rb +++ b/db/migrate/20150528082420_add_plagiarism_checks_data.rb @@ -1,4 +1,4 @@ -class AddPlagiarismChecksData < ActiveRecord::Migration +class AddPlagiarismChecksData < ActiveRecord::Migration[4.2] def change add_column :task_definitions, :plagiarism_checks, :string, :limit => 2048 end diff --git a/db/migrate/20150529011018_add_match_links.rb b/db/migrate/20150529011018_add_match_links.rb index e88a177c2..fc8115ac2 100644 --- a/db/migrate/20150529011018_add_match_links.rb +++ b/db/migrate/20150529011018_add_match_links.rb @@ -1,4 +1,4 @@ -class AddMatchLinks < ActiveRecord::Migration +class AddMatchLinks < ActiveRecord::Migration[4.2] def change create_table :plagiarism_match_links do |t| t.belongs_to :task, index: true diff --git a/db/migrate/20150623070406_add_file_upload_date.rb b/db/migrate/20150623070406_add_file_upload_date.rb index c190b8673..6e5a52f05 100644 --- a/db/migrate/20150623070406_add_file_upload_date.rb +++ b/db/migrate/20150623070406_add_file_upload_date.rb @@ -1,4 +1,4 @@ -class AddFileUploadDate < ActiveRecord::Migration +class AddFileUploadDate < ActiveRecord::Migration[4.2] def change add_column :tasks, :file_uploaded_at, :datetime end diff --git a/db/migrate/20150624063251_add_plagiarism_pct_cache.rb b/db/migrate/20150624063251_add_plagiarism_pct_cache.rb index c32a58d47..15f43c6c7 100644 --- a/db/migrate/20150624063251_add_plagiarism_pct_cache.rb +++ b/db/migrate/20150624063251_add_plagiarism_pct_cache.rb @@ -1,4 +1,4 @@ -class AddPlagiarismPctCache < ActiveRecord::Migration +class AddPlagiarismPctCache < ActiveRecord::Migration[4.2] def change add_column :tasks, :max_pct_similar, :integer, :default => 0 add_column :projects, :max_pct_similar, :integer, :default => 0 diff --git a/db/migrate/20150629001715_keep_moss_urls.rb b/db/migrate/20150629001715_keep_moss_urls.rb index dd1acdd8f..3d987ab68 100644 --- a/db/migrate/20150629001715_keep_moss_urls.rb +++ b/db/migrate/20150629001715_keep_moss_urls.rb @@ -1,4 +1,4 @@ -class KeepMossUrls < ActiveRecord::Migration +class KeepMossUrls < ActiveRecord::Migration[4.2] def change add_column :task_definitions, :plagiarism_report_url, :string add_column :task_definitions, :plagiarism_updated, :boolean, :default => false diff --git a/db/migrate/20150716091948_create_group_sets.rb b/db/migrate/20150716091948_create_group_sets.rb index 3d9ab946c..eefb9cc34 100644 --- a/db/migrate/20150716091948_create_group_sets.rb +++ b/db/migrate/20150716091948_create_group_sets.rb @@ -1,4 +1,4 @@ -class CreateGroupSets < ActiveRecord::Migration +class CreateGroupSets < ActiveRecord::Migration[4.2] def change create_table :group_sets do |t| t.references :unit diff --git a/db/migrate/20150717061119_create_groups.rb b/db/migrate/20150717061119_create_groups.rb index b09debe52..5862590f2 100644 --- a/db/migrate/20150717061119_create_groups.rb +++ b/db/migrate/20150717061119_create_groups.rb @@ -1,4 +1,4 @@ -class CreateGroups < ActiveRecord::Migration +class CreateGroups < ActiveRecord::Migration[4.2] def change create_table :groups do |t| t.references :group_set diff --git a/db/migrate/20150724065147_add_group_submission_file_data.rb b/db/migrate/20150724065147_add_group_submission_file_data.rb index 2263b42ef..12241d5af 100644 --- a/db/migrate/20150724065147_add_group_submission_file_data.rb +++ b/db/migrate/20150724065147_add_group_submission_file_data.rb @@ -1,4 +1,4 @@ -class AddGroupSubmissionFileData < ActiveRecord::Migration +class AddGroupSubmissionFileData < ActiveRecord::Migration[4.2] def change # need the task id to add_column :group_submissions, :task_definition_id, :integer diff --git a/db/migrate/20150729035659_remove_required_from_task_defs.rb b/db/migrate/20150729035659_remove_required_from_task_defs.rb index 620772b6c..c19421d80 100644 --- a/db/migrate/20150729035659_remove_required_from_task_defs.rb +++ b/db/migrate/20150729035659_remove_required_from_task_defs.rb @@ -1,4 +1,4 @@ -class RemoveRequiredFromTaskDefs < ActiveRecord::Migration +class RemoveRequiredFromTaskDefs < ActiveRecord::Migration[4.2] def change remove_column :task_definitions, :required end diff --git a/db/migrate/20151105231614_create_outcome_task_links.rb b/db/migrate/20151105231614_create_outcome_task_links.rb index 86d7cae12..d44fcdd4c 100644 --- a/db/migrate/20151105231614_create_outcome_task_links.rb +++ b/db/migrate/20151105231614_create_outcome_task_links.rb @@ -1,4 +1,4 @@ -class CreateOutcomeTaskLinks < ActiveRecord::Migration +class CreateOutcomeTaskLinks < ActiveRecord::Migration[4.2] def change create_table :learning_outcome_task_links do |t| t.text :description diff --git a/db/migrate/20151105231733_rename_ilo_to_outcome.rb b/db/migrate/20151105231733_rename_ilo_to_outcome.rb index d78820665..1b1f717a1 100644 --- a/db/migrate/20151105231733_rename_ilo_to_outcome.rb +++ b/db/migrate/20151105231733_rename_ilo_to_outcome.rb @@ -1,4 +1,4 @@ -class RenameIloToOutcome < ActiveRecord::Migration +class RenameIloToOutcome < ActiveRecord::Migration[4.2] def change rename_table :intended_learning_outcomes, :learning_outcomes diff --git a/db/migrate/20151111234514_add_ilo_abbreviation.rb b/db/migrate/20151111234514_add_ilo_abbreviation.rb index f3e518ba9..34262f322 100644 --- a/db/migrate/20151111234514_add_ilo_abbreviation.rb +++ b/db/migrate/20151111234514_add_ilo_abbreviation.rb @@ -1,4 +1,4 @@ -class AddIloAbbreviation < ActiveRecord::Migration +class AddIloAbbreviation < ActiveRecord::Migration[4.2] def change add_column :learning_outcomes, :abbreviation, :string end diff --git a/db/migrate/20151116061222_add_times_assessed.rb b/db/migrate/20151116061222_add_times_assessed.rb index 88180d2d9..b81d7f104 100644 --- a/db/migrate/20151116061222_add_times_assessed.rb +++ b/db/migrate/20151116061222_add_times_assessed.rb @@ -1,4 +1,4 @@ -class AddTimesAssessed < ActiveRecord::Migration +class AddTimesAssessed < ActiveRecord::Migration[4.2] def change add_column :tasks, :times_assessed, :integer, default: 0 add_column :tasks, :submission_date, :datetime diff --git a/db/migrate/20151214194456_remove_unit_role_for_student.rb b/db/migrate/20151214194456_remove_unit_role_for_student.rb index c050c7590..eac6887a2 100644 --- a/db/migrate/20151214194456_remove_unit_role_for_student.rb +++ b/db/migrate/20151214194456_remove_unit_role_for_student.rb @@ -1,4 +1,4 @@ -class RemoveUnitRoleForStudent < ActiveRecord::Migration +class RemoveUnitRoleForStudent < ActiveRecord::Migration[4.2] def change add_column :projects, :tutorial_id, :integer add_column :projects, :user_id, :integer diff --git a/db/migrate/20151221195533_add_due_date.rb b/db/migrate/20151221195533_add_due_date.rb index 45152629a..294f18e4b 100644 --- a/db/migrate/20151221195533_add_due_date.rb +++ b/db/migrate/20151221195533_add_due_date.rb @@ -1,4 +1,4 @@ -class AddDueDate < ActiveRecord::Migration +class AddDueDate < ActiveRecord::Migration[4.2] def change add_column :task_definitions, :due_date, :datetime end diff --git a/db/migrate/20160111220041_add_demonstrate_state.rb b/db/migrate/20160111220041_add_demonstrate_state.rb index 6bb682e2c..e3de2efb7 100644 --- a/db/migrate/20160111220041_add_demonstrate_state.rb +++ b/db/migrate/20160111220041_add_demonstrate_state.rb @@ -1,4 +1,4 @@ -class AddDemonstrateState < ActiveRecord::Migration +class AddDemonstrateState < ActiveRecord::Migration[4.2] def change # if the other status are there... if TaskStatus.complete && TaskStatus.all.count == 9 diff --git a/db/migrate/20160112020534_add_fail_status.rb b/db/migrate/20160112020534_add_fail_status.rb index b3924dc14..dda89e491 100644 --- a/db/migrate/20160112020534_add_fail_status.rb +++ b/db/migrate/20160112020534_add_fail_status.rb @@ -1,4 +1,4 @@ -class AddFailStatus < ActiveRecord::Migration +class AddFailStatus < ActiveRecord::Migration[4.2] def change # if the other status are there... if TaskStatus.complete && TaskStatus.all.count == 10 diff --git a/db/migrate/20160112072747_add_opt_in_to_research_state_to_users.rb b/db/migrate/20160112072747_add_opt_in_to_research_state_to_users.rb index 8ec4dc4fb..d667c6fc5 100644 --- a/db/migrate/20160112072747_add_opt_in_to_research_state_to_users.rb +++ b/db/migrate/20160112072747_add_opt_in_to_research_state_to_users.rb @@ -1,4 +1,4 @@ -class AddOptInToResearchStateToUsers < ActiveRecord::Migration +class AddOptInToResearchStateToUsers < ActiveRecord::Migration[4.2] def up add_column :users, :opt_in_to_research, :boolean, default: nil end diff --git a/db/migrate/20160114121503_add_has_run_first_time_setup_to_users.rb b/db/migrate/20160114121503_add_has_run_first_time_setup_to_users.rb index ea33dc215..b57038592 100644 --- a/db/migrate/20160114121503_add_has_run_first_time_setup_to_users.rb +++ b/db/migrate/20160114121503_add_has_run_first_time_setup_to_users.rb @@ -1,4 +1,4 @@ -class AddHasRunFirstTimeSetupToUsers < ActiveRecord::Migration +class AddHasRunFirstTimeSetupToUsers < ActiveRecord::Migration[4.2] def up add_column :users, :has_run_first_time_setup, :boolean, default: false end diff --git a/db/migrate/20160129032223_add_task_start_date.rb b/db/migrate/20160129032223_add_task_start_date.rb index 5d670ff73..8d5dcb7be 100644 --- a/db/migrate/20160129032223_add_task_start_date.rb +++ b/db/migrate/20160129032223_add_task_start_date.rb @@ -1,4 +1,4 @@ -class AddTaskStartDate < ActiveRecord::Migration +class AddTaskStartDate < ActiveRecord::Migration[4.2] def change add_column :task_definitions, :start_date, :datetime diff --git a/db/migrate/20160203083806_add_project_grade.rb b/db/migrate/20160203083806_add_project_grade.rb index feb962c11..ba1adec52 100644 --- a/db/migrate/20160203083806_add_project_grade.rb +++ b/db/migrate/20160203083806_add_project_grade.rb @@ -1,4 +1,4 @@ -class AddProjectGrade < ActiveRecord::Migration +class AddProjectGrade < ActiveRecord::Migration[4.2] def change add_column :projects, :grade, :integer, default: 0 add_column :projects, :grade_rationale, :string, limit: 2048 diff --git a/db/migrate/20160217031823_extend_ilo_description.rb b/db/migrate/20160217031823_extend_ilo_description.rb index 5987a1804..9441e5ec0 100644 --- a/db/migrate/20160217031823_extend_ilo_description.rb +++ b/db/migrate/20160217031823_extend_ilo_description.rb @@ -1,4 +1,4 @@ -class ExtendIloDescription < ActiveRecord::Migration +class ExtendIloDescription < ActiveRecord::Migration[4.2] def change change_column :learning_outcomes, :description, :string, :limit => 2048 end diff --git a/db/migrate/20160223054040_add_ability_for_graded_tasks.rb b/db/migrate/20160223054040_add_ability_for_graded_tasks.rb index f8ed9a057..5808887e1 100644 --- a/db/migrate/20160223054040_add_ability_for_graded_tasks.rb +++ b/db/migrate/20160223054040_add_ability_for_graded_tasks.rb @@ -1,4 +1,4 @@ -class AddAbilityForGradedTasks < ActiveRecord::Migration +class AddAbilityForGradedTasks < ActiveRecord::Migration[4.2] def change add_column :task_definitions, :is_graded, :boolean, default: false add_column :tasks, :grade, :integer, default: nil diff --git a/db/migrate/20160503222451_add_task_unique_project_task_def_idx.rb b/db/migrate/20160503222451_add_task_unique_project_task_def_idx.rb index 79ee361a0..0b65baea0 100644 --- a/db/migrate/20160503222451_add_task_unique_project_task_def_idx.rb +++ b/db/migrate/20160503222451_add_task_unique_project_task_def_idx.rb @@ -1,4 +1,4 @@ -class AddTaskUniqueProjectTaskDefIdx < ActiveRecord::Migration +class AddTaskUniqueProjectTaskDefIdx < ActiveRecord::Migration[4.2] def change add_index :tasks, [:project_id, :task_definition_id], :unique => true, :name => "tasks_uniq_proj_task_def" end diff --git a/db/migrate/20160527101546_add_contribution_pts_to_group_submission.rb b/db/migrate/20160527101546_add_contribution_pts_to_group_submission.rb index b60c60a92..6bdce2fc1 100644 --- a/db/migrate/20160527101546_add_contribution_pts_to_group_submission.rb +++ b/db/migrate/20160527101546_add_contribution_pts_to_group_submission.rb @@ -1,4 +1,4 @@ -class AddContributionPtsToGroupSubmission < ActiveRecord::Migration +class AddContributionPtsToGroupSubmission < ActiveRecord::Migration[4.2] def change add_column :tasks, :contribution_pts, :integer, default: 3 end diff --git a/db/migrate/20160527112010_add_task_quality_stars.rb b/db/migrate/20160527112010_add_task_quality_stars.rb index e6f264992..0bcc80931 100644 --- a/db/migrate/20160527112010_add_task_quality_stars.rb +++ b/db/migrate/20160527112010_add_task_quality_stars.rb @@ -1,4 +1,4 @@ -class AddTaskQualityStars < ActiveRecord::Migration +class AddTaskQualityStars < ActiveRecord::Migration[4.2] def change add_column :tasks, :quality_pts, :integer, default: 0 add_column :task_definitions, :max_quality_pts, :integer, default: 0 diff --git a/db/migrate/20160528223143_increase_comment_size.rb b/db/migrate/20160528223143_increase_comment_size.rb index ad6ca7725..2cc89b8c8 100644 --- a/db/migrate/20160528223143_increase_comment_size.rb +++ b/db/migrate/20160528223143_increase_comment_size.rb @@ -1,4 +1,4 @@ -class IncreaseCommentSize < ActiveRecord::Migration +class IncreaseCommentSize < ActiveRecord::Migration[4.2] def change change_column :task_comments, :comment, :string, :limit => 4096 change_column :learning_outcomes, :description, :string, :limit => 4096 diff --git a/db/migrate/20160529012853_allow_plagiarism_dismiss.rb b/db/migrate/20160529012853_allow_plagiarism_dismiss.rb index 96c6676e9..d9d1c25db 100644 --- a/db/migrate/20160529012853_allow_plagiarism_dismiss.rb +++ b/db/migrate/20160529012853_allow_plagiarism_dismiss.rb @@ -1,4 +1,4 @@ -class AllowPlagiarismDismiss < ActiveRecord::Migration +class AllowPlagiarismDismiss < ActiveRecord::Migration[4.2] def change add_column :plagiarism_match_links, :dismissed, :boolean, default: false end diff --git a/db/migrate/20161208033634_add_login_id_to_user_model.rb b/db/migrate/20161208033634_add_login_id_to_user_model.rb index f93c300b9..b643ed49b 100644 --- a/db/migrate/20161208033634_add_login_id_to_user_model.rb +++ b/db/migrate/20161208033634_add_login_id_to_user_model.rb @@ -1,4 +1,4 @@ -class AddLoginIdToUserModel < ActiveRecord::Migration +class AddLoginIdToUserModel < ActiveRecord::Migration[4.2] def change add_column :users, :login_id, :string, null: false, default: '' add_index :users, :login_id, unique: true diff --git a/db/migrate/20161208055326_make_login_id_nullable.rb b/db/migrate/20161208055326_make_login_id_nullable.rb index b010205b7..49d901b08 100644 --- a/db/migrate/20161208055326_make_login_id_nullable.rb +++ b/db/migrate/20161208055326_make_login_id_nullable.rb @@ -1,4 +1,4 @@ -class MakeLoginIdNullable < ActiveRecord::Migration +class MakeLoginIdNullable < ActiveRecord::Migration[4.2] def change change_column :users, :login_id, :string, null: true, default: nil end diff --git a/db/migrate/20170105032555_add_recipient_to_task_comment.rb b/db/migrate/20170105032555_add_recipient_to_task_comment.rb index 2245c96d3..59dfefb9e 100644 --- a/db/migrate/20170105032555_add_recipient_to_task_comment.rb +++ b/db/migrate/20170105032555_add_recipient_to_task_comment.rb @@ -1,4 +1,4 @@ -class AddRecipientToTaskComment < ActiveRecord::Migration +class AddRecipientToTaskComment < ActiveRecord::Migration[4.2] def change add_column :task_comments, :is_new, :boolean, default: true diff --git a/db/migrate/20170106055629_rename_read_receipt_table.rb b/db/migrate/20170106055629_rename_read_receipt_table.rb index 41d481166..a1b229ac9 100644 --- a/db/migrate/20170106055629_rename_read_receipt_table.rb +++ b/db/migrate/20170106055629_rename_read_receipt_table.rb @@ -1,4 +1,4 @@ -class RenameReadReceiptTable < ActiveRecord::Migration +class RenameReadReceiptTable < ActiveRecord::Migration[4.2] def change # unique rows create_table :comments_read_receipts, id: false do |t| diff --git a/db/migrate/20170112231900_add_student_id_to_user.rb b/db/migrate/20170112231900_add_student_id_to_user.rb index 40af7ef3f..d545219b5 100644 --- a/db/migrate/20170112231900_add_student_id_to_user.rb +++ b/db/migrate/20170112231900_add_student_id_to_user.rb @@ -1,4 +1,4 @@ -class AddStudentIdToUser < ActiveRecord::Migration +class AddStudentIdToUser < ActiveRecord::Migration[4.2] def change add_column :users, :student_id, :string, null: true, unique: true end diff --git a/db/migrate/20170116035300_add_group_number.rb b/db/migrate/20170116035300_add_group_number.rb index b427da8ac..58caba624 100644 --- a/db/migrate/20170116035300_add_group_number.rb +++ b/db/migrate/20170116035300_add_group_number.rb @@ -1,4 +1,4 @@ -class AddGroupNumber < ActiveRecord::Migration +class AddGroupNumber < ActiveRecord::Migration[4.2] def change add_column :groups, :number, :integer, null: false, unique: true end diff --git a/db/migrate/20170117233120_add_primary_key_to_crr.rb b/db/migrate/20170117233120_add_primary_key_to_crr.rb index d15cd8849..4556feaae 100644 --- a/db/migrate/20170117233120_add_primary_key_to_crr.rb +++ b/db/migrate/20170117233120_add_primary_key_to_crr.rb @@ -1,4 +1,4 @@ -class AddPrimaryKeyToCrr < ActiveRecord::Migration +class AddPrimaryKeyToCrr < ActiveRecord::Migration[4.2] def change add_column :comments_read_receipts, :id, :primary_key end diff --git a/db/migrate/20180122022727_add_content_type_to_task_comments.rb b/db/migrate/20180122022727_add_content_type_to_task_comments.rb new file mode 100644 index 000000000..7a3fcd9bb --- /dev/null +++ b/db/migrate/20180122022727_add_content_type_to_task_comments.rb @@ -0,0 +1,5 @@ +class AddContentTypeToTaskComments < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :content_type, :string + end +end diff --git a/db/migrate/20180303011011_add_time_exceeded.rb b/db/migrate/20180303011011_add_time_exceeded.rb new file mode 100644 index 000000000..03b65dbb3 --- /dev/null +++ b/db/migrate/20180303011011_add_time_exceeded.rb @@ -0,0 +1,7 @@ +class AddTimeExceeded < ActiveRecord::Migration[4.2] + def change + if TaskStatus.where(name: 'Time Exceeded').count < 1 + TaskStatus.create name:"Time Exceeded", description: "You did not submit or complete the task before the appropriate deadline." + end + end +end diff --git a/db/migrate/20180405141539_create_teaching_periods.rb b/db/migrate/20180405141539_create_teaching_periods.rb new file mode 100644 index 000000000..d695c2398 --- /dev/null +++ b/db/migrate/20180405141539_create_teaching_periods.rb @@ -0,0 +1,11 @@ +class CreateTeachingPeriods < ActiveRecord::Migration[4.2] + def change + create_table :teaching_periods do |t| + t.string :period, null: false + t.datetime :start_date, null: false + t.datetime :end_date, null: false + end + add_reference :units, :teaching_period, index: true + add_foreign_key :units, :teaching_periods + end +end diff --git a/db/migrate/20180613113043_add_task_extensions.rb b/db/migrate/20180613113043_add_task_extensions.rb new file mode 100644 index 000000000..66eae75c5 --- /dev/null +++ b/db/migrate/20180613113043_add_task_extensions.rb @@ -0,0 +1,5 @@ +class AddTaskExtensions < ActiveRecord::Migration[4.2] + def change + add_column :tasks, :extensions, :integer, null: false, unique: true, default: 0 + end +end diff --git a/db/migrate/20180703114714_add_comment_attachment_extn.rb b/db/migrate/20180703114714_add_comment_attachment_extn.rb new file mode 100644 index 000000000..78bde4915 --- /dev/null +++ b/db/migrate/20180703114714_add_comment_attachment_extn.rb @@ -0,0 +1,5 @@ +class AddCommentAttachmentExtn < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :attachment_extension, :string + end +end diff --git a/db/migrate/20180720051403_add_year_to_teaching_periods.rb b/db/migrate/20180720051403_add_year_to_teaching_periods.rb new file mode 100644 index 000000000..9b72b66d9 --- /dev/null +++ b/db/migrate/20180720051403_add_year_to_teaching_periods.rb @@ -0,0 +1,5 @@ +class AddYearToTeachingPeriods < ActiveRecord::Migration[4.2] + def change + add_column :teaching_periods, :year, :integer, null: false + end +end diff --git a/db/migrate/20180812105323_add_unique_index_to_teaching_period.rb b/db/migrate/20180812105323_add_unique_index_to_teaching_period.rb new file mode 100644 index 000000000..57d966e3e --- /dev/null +++ b/db/migrate/20180812105323_add_unique_index_to_teaching_period.rb @@ -0,0 +1,5 @@ +class AddUniqueIndexToTeachingPeriod < ActiveRecord::Migration[4.2] + def change + add_index :teaching_periods, [:period, :year], unique: true + end +end diff --git a/db/migrate/20180815045338_update_quality_default.rb b/db/migrate/20180815045338_update_quality_default.rb new file mode 100644 index 000000000..945cf5f82 --- /dev/null +++ b/db/migrate/20180815045338_update_quality_default.rb @@ -0,0 +1,5 @@ +class UpdateQualityDefault < ActiveRecord::Migration[4.2] + def change + change_column_default :tasks, :quality_pts, -1 + end +end diff --git a/db/migrate/20180830010529_add_active_until_to_teaching_period.rb b/db/migrate/20180830010529_add_active_until_to_teaching_period.rb new file mode 100644 index 000000000..38f68ff03 --- /dev/null +++ b/db/migrate/20180830010529_add_active_until_to_teaching_period.rb @@ -0,0 +1,5 @@ +class AddActiveUntilToTeachingPeriod < ActiveRecord::Migration[4.2] + def change + add_column :teaching_periods, :active_until, :datetime, null: false + end +end diff --git a/db/migrate/20180913030346_create_break.rb b/db/migrate/20180913030346_create_break.rb new file mode 100644 index 000000000..b4c951694 --- /dev/null +++ b/db/migrate/20180913030346_create_break.rb @@ -0,0 +1,10 @@ +class CreateBreak < ActiveRecord::Migration[4.2] + def change + create_table :breaks do |t| + t.datetime :start_date, null: false + t.integer :number_of_weeks, null: false + end + add_reference :breaks, :teaching_period, index: true + add_foreign_key :breaks, :teaching_periods + end +end diff --git a/db/migrate/20190702063907_add_type_to_task_comments.rb b/db/migrate/20190702063907_add_type_to_task_comments.rb new file mode 100644 index 000000000..7da480e7e --- /dev/null +++ b/db/migrate/20190702063907_add_type_to_task_comments.rb @@ -0,0 +1,5 @@ +class AddTypeToTaskComments < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :type, :string + end +end diff --git a/db/migrate/20190702115223_add_discussion_comments_to_task_comments.rb b/db/migrate/20190702115223_add_discussion_comments_to_task_comments.rb new file mode 100644 index 000000000..8e6009446 --- /dev/null +++ b/db/migrate/20190702115223_add_discussion_comments_to_task_comments.rb @@ -0,0 +1,7 @@ +class AddDiscussionCommentsToTaskComments < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :time_discussion_started, :datetime + add_column :task_comments, :time_discussion_completed, :datetime + add_column :task_comments, :number_of_prompts, :integer + end +end diff --git a/db/migrate/20190704063834_add_extension_comments.rb b/db/migrate/20190704063834_add_extension_comments.rb new file mode 100644 index 000000000..01b03aa73 --- /dev/null +++ b/db/migrate/20190704063834_add_extension_comments.rb @@ -0,0 +1,7 @@ +class AddExtensionComments < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :date_extension_assessed, :datetime + add_column :task_comments, :extension_granted, :boolean + add_column :task_comments, :assessor_id, :integer + end +end diff --git a/db/migrate/20190704123700_add_task_status_comment.rb b/db/migrate/20190704123700_add_task_status_comment.rb new file mode 100644 index 000000000..34746f5f4 --- /dev/null +++ b/db/migrate/20190704123700_add_task_status_comment.rb @@ -0,0 +1,5 @@ +class AddTaskStatusComment < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :task_status_id, :integer + end +end diff --git a/db/migrate/20190705035441_add_weeks_to_extension_request.rb b/db/migrate/20190705035441_add_weeks_to_extension_request.rb new file mode 100644 index 000000000..13cf1e3ac --- /dev/null +++ b/db/migrate/20190705035441_add_weeks_to_extension_request.rb @@ -0,0 +1,5 @@ +class AddWeeksToExtensionRequest < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :extension_weeks, :integer + end +end diff --git a/db/migrate/20190705045015_add_response_to_extension_request.rb b/db/migrate/20190705045015_add_response_to_extension_request.rb new file mode 100644 index 000000000..b5d235916 --- /dev/null +++ b/db/migrate/20190705045015_add_response_to_extension_request.rb @@ -0,0 +1,5 @@ +class AddResponseToExtensionRequest < ActiveRecord::Migration[4.2] + def change + add_column :task_comments, :extension_response, :string + end +end diff --git a/db/migrate/20190930004309_create_campuses.rb b/db/migrate/20190930004309_create_campuses.rb new file mode 100644 index 000000000..511549607 --- /dev/null +++ b/db/migrate/20190930004309_create_campuses.rb @@ -0,0 +1,8 @@ +class CreateCampuses < ActiveRecord::Migration[4.2] + def change + create_table :campuses do |t| + t.string :name, null: false + t.integer :mode, null: false + end + end +end diff --git a/db/migrate/20190930013254_add_capacity_to_tutorials.rb b/db/migrate/20190930013254_add_capacity_to_tutorials.rb new file mode 100644 index 000000000..e5f786e7c --- /dev/null +++ b/db/migrate/20190930013254_add_capacity_to_tutorials.rb @@ -0,0 +1,5 @@ +class AddCapacityToTutorials < ActiveRecord::Migration + def change + add_column :tutorials, :capacity, :integer + end +end diff --git a/db/migrate/20190930031412_add_campus_to_tutorials.rb b/db/migrate/20190930031412_add_campus_to_tutorials.rb new file mode 100644 index 000000000..86d865568 --- /dev/null +++ b/db/migrate/20190930031412_add_campus_to_tutorials.rb @@ -0,0 +1,6 @@ +class AddCampusToTutorials < ActiveRecord::Migration + def change + add_reference :tutorials, :campus, index: true + add_foreign_key :tutorials, :campuses + end +end diff --git a/db/migrate/20190930031610_add_campus_to_projects.rb b/db/migrate/20190930031610_add_campus_to_projects.rb new file mode 100644 index 000000000..2ba757884 --- /dev/null +++ b/db/migrate/20190930031610_add_campus_to_projects.rb @@ -0,0 +1,6 @@ +class AddCampusToProjects < ActiveRecord::Migration + def change + add_reference :projects, :campus, index: true + add_foreign_key :projects, :campuses + end +end diff --git a/db/migrate/20191009005448_add_abbreviation_to_campuses.rb b/db/migrate/20191009005448_add_abbreviation_to_campuses.rb new file mode 100644 index 000000000..614b1ea5d --- /dev/null +++ b/db/migrate/20191009005448_add_abbreviation_to_campuses.rb @@ -0,0 +1,5 @@ +class AddAbbreviationToCampuses < ActiveRecord::Migration + def change + add_column :campuses, :abbreviation, :string, null: false + end +end diff --git a/db/migrate/20191010055541_ensure_task_alignment_rating_non_zero.rb b/db/migrate/20191010055541_ensure_task_alignment_rating_non_zero.rb new file mode 100644 index 000000000..e6360ff69 --- /dev/null +++ b/db/migrate/20191010055541_ensure_task_alignment_rating_non_zero.rb @@ -0,0 +1,5 @@ +class EnsureTaskAlignmentRatingNonZero < ActiveRecord::Migration + def change + LearningOutcomeTaskLink.where(rating: 0).update_all(rating: 1) + end +end diff --git a/db/migrate/20191011035719_add_active_to_campuses.rb b/db/migrate/20191011035719_add_active_to_campuses.rb new file mode 100644 index 000000000..736e9e70d --- /dev/null +++ b/db/migrate/20191011035719_add_active_to_campuses.rb @@ -0,0 +1,6 @@ +class AddActiveToCampuses < ActiveRecord::Migration + def change + add_column :campuses, :active, :boolean, :null => false + add_index :campuses, :active + end +end diff --git a/db/migrate/20191024222811_create_activity_types.rb b/db/migrate/20191024222811_create_activity_types.rb new file mode 100644 index 000000000..01a339b64 --- /dev/null +++ b/db/migrate/20191024222811_create_activity_types.rb @@ -0,0 +1,9 @@ +class CreateActivityTypes < ActiveRecord::Migration + def change + create_table :activity_types do |t| + t.string :name, null: false + t.string :abbreviation, null: false + t.timestamps null: false + end + end +end diff --git a/db/migrate/20191029220230_create_tutorial_streams.rb b/db/migrate/20191029220230_create_tutorial_streams.rb new file mode 100644 index 000000000..f1c26da97 --- /dev/null +++ b/db/migrate/20191029220230_create_tutorial_streams.rb @@ -0,0 +1,11 @@ +class CreateTutorialStreams < ActiveRecord::Migration + def change + create_table :tutorial_streams do |t| + t.string :name, null: false + t.string :abbreviation, null: false + t.timestamps null: false + end + add_reference :tutorial_streams, :activity_type, null: false, foreign_key: true + add_index :tutorial_streams, :abbreviation + end +end diff --git a/db/migrate/20191029232414_add_unit_to_tutorial_streams.rb b/db/migrate/20191029232414_add_unit_to_tutorial_streams.rb new file mode 100644 index 000000000..9c771b072 --- /dev/null +++ b/db/migrate/20191029232414_add_unit_to_tutorial_streams.rb @@ -0,0 +1,5 @@ +class AddUnitToTutorialStreams < ActiveRecord::Migration + def change + add_reference :tutorial_streams, :unit, null: false, foreign_key: true, index: true + end +end diff --git a/db/migrate/20191029233709_add_tutorial_stream_to_task_definition.rb b/db/migrate/20191029233709_add_tutorial_stream_to_task_definition.rb new file mode 100644 index 000000000..4c0315de9 --- /dev/null +++ b/db/migrate/20191029233709_add_tutorial_stream_to_task_definition.rb @@ -0,0 +1,5 @@ +class AddTutorialStreamToTaskDefinition < ActiveRecord::Migration + def change + add_reference :task_definitions, :tutorial_stream, foreign_key: true, index: true + end +end diff --git a/db/migrate/20191031233511_add_unique_index_to_tutorial_streams.rb b/db/migrate/20191031233511_add_unique_index_to_tutorial_streams.rb new file mode 100644 index 000000000..fbaead398 --- /dev/null +++ b/db/migrate/20191031233511_add_unique_index_to_tutorial_streams.rb @@ -0,0 +1,6 @@ +class AddUniqueIndexToTutorialStreams < ActiveRecord::Migration + def change + add_index :tutorial_streams, [:name, :unit_id], unique: true + add_index :tutorial_streams, [:abbreviation, :unit_id], unique: true + end +end diff --git a/db/migrate/20191031235109_add_unique_index_to_activity_types.rb b/db/migrate/20191031235109_add_unique_index_to_activity_types.rb new file mode 100644 index 000000000..aa3fba67e --- /dev/null +++ b/db/migrate/20191031235109_add_unique_index_to_activity_types.rb @@ -0,0 +1,6 @@ +class AddUniqueIndexToActivityTypes < ActiveRecord::Migration + def change + add_index :activity_types, :name, unique: true + add_index :activity_types, :abbreviation, unique: true + end +end diff --git a/db/migrate/20191031235849_add_unique_index_to_campuses.rb b/db/migrate/20191031235849_add_unique_index_to_campuses.rb new file mode 100644 index 000000000..4aa8d9912 --- /dev/null +++ b/db/migrate/20191031235849_add_unique_index_to_campuses.rb @@ -0,0 +1,6 @@ +class AddUniqueIndexToCampuses < ActiveRecord::Migration + def change + add_index :campuses, :name, unique: true + add_index :campuses, :abbreviation, unique: true + end +end diff --git a/db/migrate/20191109012643_create_tutorial_enrolments.rb b/db/migrate/20191109012643_create_tutorial_enrolments.rb new file mode 100644 index 000000000..e2ca3fa2a --- /dev/null +++ b/db/migrate/20191109012643_create_tutorial_enrolments.rb @@ -0,0 +1,11 @@ +class CreateTutorialEnrolments < ActiveRecord::Migration + def change + create_table :tutorial_enrolments do |t| + + t.timestamps null: false + end + add_reference :tutorial_enrolments, :project, null: false, foreign_key: true, index: true + add_reference :tutorial_enrolments, :tutorial, null: false, foreign_key: true, index: true + add_index :tutorial_enrolments, [:tutorial_id, :project_id], unique: true + end +end diff --git a/db/migrate/20191109034031_remove_tutorial_from_projects.rb b/db/migrate/20191109034031_remove_tutorial_from_projects.rb new file mode 100644 index 000000000..28da7ec20 --- /dev/null +++ b/db/migrate/20191109034031_remove_tutorial_from_projects.rb @@ -0,0 +1,5 @@ +class RemoveTutorialFromProjects < ActiveRecord::Migration + def change + remove_column :projects, :tutorial_id + end +end diff --git a/db/migrate/20191109052233_add_tutorial_stream_to_tutorials.rb b/db/migrate/20191109052233_add_tutorial_stream_to_tutorials.rb new file mode 100644 index 000000000..0394953f2 --- /dev/null +++ b/db/migrate/20191109052233_add_tutorial_stream_to_tutorials.rb @@ -0,0 +1,5 @@ +class AddTutorialStreamToTutorials < ActiveRecord::Migration + def change + add_reference :tutorials, :tutorial_stream, foreign_key: true, index: true + end +end diff --git a/db/migrate/20191118044637_add_columns_to_task_definitions.rb b/db/migrate/20191118044637_add_columns_to_task_definitions.rb new file mode 100644 index 000000000..d68083855 --- /dev/null +++ b/db/migrate/20191118044637_add_columns_to_task_definitions.rb @@ -0,0 +1,6 @@ +class AddColumnsToTaskDefinitions < ActiveRecord::Migration + def change + add_column :task_definitions, :assessment_enabled, :boolean, default: false + add_column :task_definitions, :routing_key, :string + end +end diff --git a/db/migrate/20191118044818_add_columns_to_units.rb b/db/migrate/20191118044818_add_columns_to_units.rb new file mode 100644 index 000000000..cc9f6d6e7 --- /dev/null +++ b/db/migrate/20191118044818_add_columns_to_units.rb @@ -0,0 +1,6 @@ +class AddColumnsToUnits < ActiveRecord::Migration + def change + add_column :units, :assessment_enabled, :boolean, default: true + add_column :units, :routing_key, :string + end +end diff --git a/db/migrate/20191126230531_add_tutorial_stream_to_tutorial_enrolments.rb b/db/migrate/20191126230531_add_tutorial_stream_to_tutorial_enrolments.rb new file mode 100644 index 000000000..585ced22c --- /dev/null +++ b/db/migrate/20191126230531_add_tutorial_stream_to_tutorial_enrolments.rb @@ -0,0 +1,6 @@ +class AddTutorialStreamToTutorialEnrolments < ActiveRecord::Migration + def change + add_reference :tutorial_enrolments, :tutorial_stream, foreign_key: true, index: true + add_index :tutorial_enrolments, [:tutorial_stream_id, :project_id], unique: true + end +end diff --git a/db/migrate/20191204020024_rename_routing_key_columns.rb b/db/migrate/20191204020024_rename_routing_key_columns.rb new file mode 100644 index 000000000..d0072e08b --- /dev/null +++ b/db/migrate/20191204020024_rename_routing_key_columns.rb @@ -0,0 +1,6 @@ +class RenameRoutingKeyColumns < ActiveRecord::Migration + def change + rename_column :task_definitions, :routing_key, :docker_image_name_tag + rename_column :units, :routing_key, :docker_image_name_tag + end +end diff --git a/db/migrate/20191204024146_add_limit_to_docker_image_name_tag.rb b/db/migrate/20191204024146_add_limit_to_docker_image_name_tag.rb new file mode 100644 index 000000000..40b3de851 --- /dev/null +++ b/db/migrate/20191204024146_add_limit_to_docker_image_name_tag.rb @@ -0,0 +1,6 @@ +class AddLimitToDockerImageNameTag < ActiveRecord::Migration + def change + change_column :task_definitions, :docker_image_name_tag, :string, :limit => 255 + change_column :units, :docker_image_name_tag, :string, :limit => 255 + end +end diff --git a/db/migrate/20191219002608_create_overseer_assessments.rb b/db/migrate/20191219002608_create_overseer_assessments.rb new file mode 100644 index 000000000..452c16033 --- /dev/null +++ b/db/migrate/20191219002608_create_overseer_assessments.rb @@ -0,0 +1,13 @@ +class CreateOverseerAssessments < ActiveRecord::Migration + def change + create_table :overseer_assessments do |t| + t.references :task, index: true, foreign_key: true, null: false + t.string :submission_timestamp, null: false + t.string :result_task_status + t.integer :status, null: false, default: 0 + + t.timestamps null: false + end + add_index :overseer_assessments, [:task_id, :submission_timestamp], unique: true + end +end diff --git a/db/migrate/20200106003146_unit_add_main_convenor.rb b/db/migrate/20200106003146_unit_add_main_convenor.rb new file mode 100644 index 000000000..b18457df8 --- /dev/null +++ b/db/migrate/20200106003146_unit_add_main_convenor.rb @@ -0,0 +1,9 @@ +class UnitAddMainConvenor < ActiveRecord::Migration + def change + add_reference :units, :main_convenor, references: :unit_roles + + Unit.all.each do |u| + u.update(main_convenor_id: u.convenors.first.id) + end + end +end diff --git a/db/migrate/20200107041636_drop_old_tables.rb b/db/migrate/20200107041636_drop_old_tables.rb new file mode 100644 index 000000000..8d2d28058 --- /dev/null +++ b/db/migrate/20200107041636_drop_old_tables.rb @@ -0,0 +1,7 @@ +class DropOldTables < ActiveRecord::Migration + def change + drop_table :sub_task_definitions + drop_table :sub_tasks + drop_table :badges + end +end diff --git a/db/migrate/20200107041946_drop_tables_not_in_schema.rb b/db/migrate/20200107041946_drop_tables_not_in_schema.rb new file mode 100644 index 000000000..44a07b553 --- /dev/null +++ b/db/migrate/20200107041946_drop_tables_not_in_schema.rb @@ -0,0 +1,10 @@ +class DropTablesNotInSchema < ActiveRecord::Migration + def change + drop_table :helpdesk_schedules + drop_table :helpdesk_sessions + drop_table :helpdesk_tickets + drop_table :project_convenors + drop_table :teams + drop_table :user_roles + end +end diff --git a/db/migrate/20200120043523_add_reply_id_to_task_comments.rb b/db/migrate/20200120043523_add_reply_id_to_task_comments.rb new file mode 100644 index 000000000..6b70b89d3 --- /dev/null +++ b/db/migrate/20200120043523_add_reply_id_to_task_comments.rb @@ -0,0 +1,5 @@ +class AddReplyIdToTaskComments < ActiveRecord::Migration + def change + add_reference :task_comments, :reply_to, index: true + end +end diff --git a/db/migrate/20200206210359_unit_extension_options.rb b/db/migrate/20200206210359_unit_extension_options.rb new file mode 100644 index 000000000..674842895 --- /dev/null +++ b/db/migrate/20200206210359_unit_extension_options.rb @@ -0,0 +1,5 @@ +class UnitExtensionOptions < ActiveRecord::Migration + def change + add_column :units, :auto_apply_extension_before_deadline, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20200210021949_remove_tutorial_enrolment_stream.rb b/db/migrate/20200210021949_remove_tutorial_enrolment_stream.rb new file mode 100644 index 000000000..9b5ea1ab4 --- /dev/null +++ b/db/migrate/20200210021949_remove_tutorial_enrolment_stream.rb @@ -0,0 +1,6 @@ +class RemoveTutorialEnrolmentStream < ActiveRecord::Migration + def change + remove_index :tutorial_enrolments, name: "index_tutorial_enrolments_on_tutorial_stream_id_and_project_id" + remove_reference :tutorial_enrolments, :tutorial_stream, foreign_key: true, index: true + end +end diff --git a/db/migrate/20200220100901_add_group_set_cap.rb b/db/migrate/20200220100901_add_group_set_cap.rb new file mode 100644 index 000000000..2a7535676 --- /dev/null +++ b/db/migrate/20200220100901_add_group_set_cap.rb @@ -0,0 +1,5 @@ +class AddGroupSetCap < ActiveRecord::Migration + def change + add_column :group_sets, :capacity, :integer + end +end diff --git a/db/migrate/20200226194206_add_default_to_tutorial_capacity.rb b/db/migrate/20200226194206_add_default_to_tutorial_capacity.rb new file mode 100644 index 000000000..aa3210c4a --- /dev/null +++ b/db/migrate/20200226194206_add_default_to_tutorial_capacity.rb @@ -0,0 +1,6 @@ +class AddDefaultToTutorialCapacity < ActiveRecord::Migration + def change + Tutorial.where(capacity: nil).update_all(capacity: -1) + change_column :tutorials, :capacity, :integer, default: -1 + end +end diff --git a/db/migrate/20200325094443_remove_group_number.rb b/db/migrate/20200325094443_remove_group_number.rb new file mode 100644 index 000000000..8cff442bd --- /dev/null +++ b/db/migrate/20200325094443_remove_group_number.rb @@ -0,0 +1,5 @@ +class RemoveGroupNumber < ActiveRecord::Migration + def change + remove_column :groups, :number + end +end diff --git a/db/migrate/20200325101201_add_send_notifications.rb b/db/migrate/20200325101201_add_send_notifications.rb new file mode 100644 index 000000000..c7c74ec7b --- /dev/null +++ b/db/migrate/20200325101201_add_send_notifications.rb @@ -0,0 +1,5 @@ +class AddSendNotifications < ActiveRecord::Migration + def change + add_column :units, :send_notifications, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20200327052250_add_group_capacity_adjustment.rb b/db/migrate/20200327052250_add_group_capacity_adjustment.rb new file mode 100644 index 000000000..346477838 --- /dev/null +++ b/db/migrate/20200327052250_add_group_capacity_adjustment.rb @@ -0,0 +1,5 @@ +class AddGroupCapacityAdjustment < ActiveRecord::Migration + def change + add_column :groups, :capacity_adjustment, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20200508012913_add_submitted_grade_to_projects.rb b/db/migrate/20200508012913_add_submitted_grade_to_projects.rb new file mode 100644 index 000000000..a0816021b --- /dev/null +++ b/db/migrate/20200508012913_add_submitted_grade_to_projects.rb @@ -0,0 +1,5 @@ +class AddSubmittedGradeToProjects < ActiveRecord::Migration + def change + add_column :projects, :submitted_grade, :integer + end +end diff --git a/db/migrate/20200510125933_make_groups_lockable.rb b/db/migrate/20200510125933_make_groups_lockable.rb new file mode 100644 index 000000000..0b474acce --- /dev/null +++ b/db/migrate/20200510125933_make_groups_lockable.rb @@ -0,0 +1,5 @@ +class MakeGroupsLockable < ActiveRecord::Migration + def change + add_column :groups, :locked, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20200512143828_create_task_pins.rb b/db/migrate/20200512143828_create_task_pins.rb new file mode 100644 index 000000000..200c59596 --- /dev/null +++ b/db/migrate/20200512143828_create_task_pins.rb @@ -0,0 +1,13 @@ +class CreateTaskPins < ActiveRecord::Migration + def change + + create_table :task_pins do |t| + t.references :task, foreign_key: true, null: false + t.references :user, foreign_key: true, null: false + t.timestamps null: false + end + + add_index :task_pins, [:task_id, :user_id], { unique: true } + + end +end diff --git a/db/migrate/20200716054137_make_group_sets_lockable.rb b/db/migrate/20200716054137_make_group_sets_lockable.rb new file mode 100644 index 000000000..20f80f1bd --- /dev/null +++ b/db/migrate/20200716054137_make_group_sets_lockable.rb @@ -0,0 +1,5 @@ +class MakeGroupSetsLockable < ActiveRecord::Migration + def change + add_column :group_sets, :locked, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20200809035825_add_draft_learning_summary_task_to_unit.rb b/db/migrate/20200809035825_add_draft_learning_summary_task_to_unit.rb new file mode 100644 index 000000000..c675ccf0e --- /dev/null +++ b/db/migrate/20200809035825_add_draft_learning_summary_task_to_unit.rb @@ -0,0 +1,5 @@ +class AddDraftLearningSummaryTaskToUnit < ActiveRecord::Migration + def change + add_reference :units, :draft_task_definition, references: :task_definitions + end +end diff --git a/db/migrate/20200817020024_create_webcals.rb b/db/migrate/20200817020024_create_webcals.rb new file mode 100644 index 000000000..4d6f9da6f --- /dev/null +++ b/db/migrate/20200817020024_create_webcals.rb @@ -0,0 +1,12 @@ +class CreateWebcals < ActiveRecord::Migration + def change + create_table :webcals do |t| + + # Expected to be a 36 character GUID (string). + t.string :guid, null: false, limit: 36, index: { unique: true } + + t.boolean :include_start_dates, null: false, default: false + t.references :user, foreign_key: true, index: { unique: true } + end + end +end diff --git a/db/migrate/20200819010213_add_flags_to_toggle_sync.rb b/db/migrate/20200819010213_add_flags_to_toggle_sync.rb new file mode 100644 index 000000000..89f6e1206 --- /dev/null +++ b/db/migrate/20200819010213_add_flags_to_toggle_sync.rb @@ -0,0 +1,6 @@ +class AddFlagsToToggleSync < ActiveRecord::Migration + def change + add_column :units, :enable_sync_timetable, :boolean, default: true, null: false + add_column :units, :enable_sync_enrolments, :boolean, default: true, null: false + end +end diff --git a/db/migrate/20200901044853_add_uses_draft_learning_summary_boolean_to_project.rb b/db/migrate/20200901044853_add_uses_draft_learning_summary_boolean_to_project.rb new file mode 100644 index 000000000..af6c27855 --- /dev/null +++ b/db/migrate/20200901044853_add_uses_draft_learning_summary_boolean_to_project.rb @@ -0,0 +1,5 @@ +class AddUsesDraftLearningSummaryBooleanToProject < ActiveRecord::Migration + def change + add_column :projects, :uses_draft_learning_summary, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20200908115109_create_webcal_unit_exclusions.rb b/db/migrate/20200908115109_create_webcal_unit_exclusions.rb new file mode 100644 index 000000000..b18293cb9 --- /dev/null +++ b/db/migrate/20200908115109_create_webcal_unit_exclusions.rb @@ -0,0 +1,12 @@ +class CreateWebcalUnitExclusions < ActiveRecord::Migration + def change + + create_table :webcal_unit_exclusions do |t| + t.references :webcal, foreign_key: true, null: false + t.references :unit, foreign_key: true, null: false + end + + add_index :webcal_unit_exclusions, [:unit_id, :webcal_id], { unique: true } + + end +end diff --git a/db/migrate/20200909074930_add_reminders_to_webcal.rb b/db/migrate/20200909074930_add_reminders_to_webcal.rb new file mode 100644 index 000000000..4c72c22b6 --- /dev/null +++ b/db/migrate/20200909074930_add_reminders_to_webcal.rb @@ -0,0 +1,6 @@ +class AddRemindersToWebcal < ActiveRecord::Migration + def change + add_column :webcals, :reminder_time, :integer + add_column :webcals, :reminder_unit, :string + end +end diff --git a/db/migrate/20210216214046_adjust_extension_processing.rb b/db/migrate/20210216214046_adjust_extension_processing.rb new file mode 100644 index 000000000..32064151a --- /dev/null +++ b/db/migrate/20210216214046_adjust_extension_processing.rb @@ -0,0 +1,6 @@ +class AdjustExtensionProcessing < ActiveRecord::Migration + def change + add_column :units, :allow_student_extension_requests, :boolean, null: false, default: true + add_column :units, :extension_weeks_on_resubmit_request, :integer, null: false, default: 1 + end +end diff --git a/db/migrate/20210310000709_adjust_change_tutorial_permissions.rb b/db/migrate/20210310000709_adjust_change_tutorial_permissions.rb new file mode 100644 index 000000000..a5d3b6afd --- /dev/null +++ b/db/migrate/20210310000709_adjust_change_tutorial_permissions.rb @@ -0,0 +1,5 @@ +class AdjustChangeTutorialPermissions < ActiveRecord::Migration + def change + add_column :units, :allow_student_change_tutorial, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20210403000741_move_auth_token_out_of_users.rb b/db/migrate/20210403000741_move_auth_token_out_of_users.rb new file mode 100644 index 000000000..39f85815c --- /dev/null +++ b/db/migrate/20210403000741_move_auth_token_out_of_users.rb @@ -0,0 +1,20 @@ +class MoveAuthTokenOutOfUsers < ActiveRecord::Migration[4.2] + def up + create_table :auth_tokens do |t| + t.string :encrypted_authentication_token, null: false, limit: 255 + t.string :encrypted_authentication_token_iv, limit: 255 + t.datetime :auth_token_expiry, null: false + end + add_reference :auth_tokens, :user, index: true + + remove_column :users, :authentication_token + remove_column :users, :auth_token_expiry + end + + def down + add_column :users, :authentication_token, :string, limit: 255 + add_column :users, :auth_token_expiry, :datetime + + drop_table :auth_tokens + end +end diff --git a/db/migrate/20210412124724_remove_task_comments_is_new.rb b/db/migrate/20210412124724_remove_task_comments_is_new.rb new file mode 100644 index 000000000..0a857f9a5 --- /dev/null +++ b/db/migrate/20210412124724_remove_task_comments_is_new.rb @@ -0,0 +1,5 @@ +class RemoveTaskCommentsIsNew < ActiveRecord::Migration[6.1] + def change + remove_column :task_comments, :is_new, :boolean + end +end diff --git a/db/migrate/20210413044542_change_do_not_resubmit.rb b/db/migrate/20210413044542_change_do_not_resubmit.rb new file mode 100644 index 000000000..92c6ca800 --- /dev/null +++ b/db/migrate/20210413044542_change_do_not_resubmit.rb @@ -0,0 +1,11 @@ +class ChangeDoNotResubmit < ActiveRecord::Migration[4.2] + def change + if TaskStatus.db_count > 0 + dnr = TaskStatus.feedback_exceeded + TaskStatusComment.where(task_status: dnr).update_all(comment: 'Feedback Exceeded') + dnr.name = 'Feedback Exceeded' + dnr.save! + Rails.cache.delete("task_statuses/#{dnr.id}") + end + end +end diff --git a/db/migrate/20210624023700_add_overseer_images.rb b/db/migrate/20210624023700_add_overseer_images.rb new file mode 100644 index 000000000..172929a40 --- /dev/null +++ b/db/migrate/20210624023700_add_overseer_images.rb @@ -0,0 +1,18 @@ +class AddOverseerImages < ActiveRecord::Migration + def change + create_table :overseer_images do |t| + t.string :name, null: false + t.string :tag, null: false + t.timestamps null: false + end + + remove_column :units, :docker_image_name_tag, :string + remove_column :task_definitions, :docker_image_name_tag, :string + + add_column :units, :overseer_image_id, :integer + add_column :task_definitions, :overseer_image_id, :integer + + add_index :units, :overseer_image_id + add_index :task_definitions, :overseer_image_id + end +end diff --git a/db/migrate/20210727120922_add_overseer_assessment_link_to_comment.rb b/db/migrate/20210727120922_add_overseer_assessment_link_to_comment.rb new file mode 100644 index 000000000..a8cbc3d42 --- /dev/null +++ b/db/migrate/20210727120922_add_overseer_assessment_link_to_comment.rb @@ -0,0 +1,7 @@ +class AddOverseerAssessmentLinkToComment < ActiveRecord::Migration + def change + # Setup a relationship between comment and overseer assessment + add_column :task_comments, :overseer_assessment_id, :integer + add_index :task_comments, :overseer_assessment_id + end +end diff --git a/db/migrate/20210728004516_update_existing_assessment_comments.rb b/db/migrate/20210728004516_update_existing_assessment_comments.rb new file mode 100644 index 000000000..056b4e798 --- /dev/null +++ b/db/migrate/20210728004516_update_existing_assessment_comments.rb @@ -0,0 +1,8 @@ +class UpdateExistingAssessmentComments < ActiveRecord::Migration + def change + # Make sure existing assessment comments use new class + TaskComment.where(content_type: 'assessment').each do |tc| + tc.update(overseer_assessment_id: tc.task.overseer_assessments.last.id, type: 'AssessmentComment') + end + end +end diff --git a/db/migrate/20210910074614_switch_to_bigint.rb b/db/migrate/20210910074614_switch_to_bigint.rb new file mode 100644 index 000000000..7ab535664 --- /dev/null +++ b/db/migrate/20210910074614_switch_to_bigint.rb @@ -0,0 +1,191 @@ +class SwitchToBigint < ActiveRecord::Migration[6.1] + def change + remove_foreign_key "breaks", "teaching_periods" if ActiveRecord::Base.connection.foreign_key_exists?(:breaks, :teaching_periods) + remove_foreign_key "comments_read_receipts", "task_comments" if ActiveRecord::Base.connection.foreign_key_exists?(:comments_read_receipts, :task_comments) + remove_foreign_key "comments_read_receipts", "users" if ActiveRecord::Base.connection.foreign_key_exists?(:comments_read_receipts, :users) + remove_foreign_key "overseer_assessments", "tasks" if ActiveRecord::Base.connection.foreign_key_exists?(:overseer_assessments, :tasks) + remove_foreign_key "projects", "campuses" if ActiveRecord::Base.connection.foreign_key_exists?(:projects, :campuses) + remove_foreign_key "task_comments", "users", column: "recipient_id" if ActiveRecord::Base.connection.foreign_key_exists?(:task_comments, column: :recipient_id) + remove_foreign_key "task_definitions", "tutorial_streams" if ActiveRecord::Base.connection.foreign_key_exists?(:task_definitions, :tutorial_streams) + remove_foreign_key "task_pins", "tasks" if ActiveRecord::Base.connection.foreign_key_exists?(:task_pins, :tasks) + remove_foreign_key "task_pins", "users" if ActiveRecord::Base.connection.foreign_key_exists?(:task_pins, :users) + remove_foreign_key "tutorial_enrolments", "projects" if ActiveRecord::Base.connection.foreign_key_exists?(:tutorial_enrolments, :projects) + remove_foreign_key "tutorial_enrolments", "tutorials" if ActiveRecord::Base.connection.foreign_key_exists?(:tutorial_enrolments, :tutorials) + remove_foreign_key "tutorial_streams", "activity_types" if ActiveRecord::Base.connection.foreign_key_exists?(:tutorial_streams, :activity_types) + remove_foreign_key "tutorial_streams", "units" if ActiveRecord::Base.connection.foreign_key_exists?(:tutorial_streams, :units) + remove_foreign_key "tutorials", "campuses" if ActiveRecord::Base.connection.foreign_key_exists?(:tutorials, :campuses) + remove_foreign_key "tutorials", "tutorial_streams" if ActiveRecord::Base.connection.foreign_key_exists?(:tutorials, :tutorial_streams) + remove_foreign_key "units", "teaching_periods" if ActiveRecord::Base.connection.foreign_key_exists?(:units, :teaching_periods) + remove_foreign_key "webcal_unit_exclusions", "units" if ActiveRecord::Base.connection.foreign_key_exists?(:webcal_unit_exclusions, :units) + remove_foreign_key "webcal_unit_exclusions", "webcals" if ActiveRecord::Base.connection.foreign_key_exists?(:webcal_unit_exclusions, :webcals) + remove_foreign_key "webcals", "users" if ActiveRecord::Base.connection.foreign_key_exists?(:webcals, :users) + + # Change id columns + change_column :activity_types, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :auth_tokens, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :breaks, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :campuses, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :comments_read_receipts, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :group_memberships, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :group_sets, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :group_submissions, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :groups, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :learning_outcome_task_links, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :learning_outcomes, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :logins, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :overseer_assessments, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :overseer_images, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :plagiarism_match_links, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :projects, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :roles, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :task_comments, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :task_definitions, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :task_engagements, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :task_pins, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :task_statuses, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :task_submissions, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :tasks, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :teaching_periods, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :tutorial_enrolments, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :tutorial_streams, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :tutorials, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :unit_roles, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :units, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :users, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :webcal_unit_exclusions, :id, :bigint, unique: true, null: false, auto_increment: true + change_column :webcals, :id, :bigint, unique: true, null: false, auto_increment: true + + # Change foreign keys + change_column :auth_tokens, :user_id, :bigint + change_column :breaks, :teaching_period_id, :bigint + change_column :comments_read_receipts, :task_comment_id, :bigint, null: false + change_column :comments_read_receipts, :user_id, :bigint, null: false + change_column :group_memberships, :group_id, :bigint + change_column :group_memberships, :project_id, :bigint + change_column :group_sets, :unit_id, :bigint + change_column :group_submissions, :group_id, :bigint + change_column :group_submissions, :submitted_by_project_id, :bigint + change_column :group_submissions, :task_definition_id, :bigint + change_column :groups, :group_set_id, :bigint + change_column :groups, :tutorial_id, :bigint + change_column :learning_outcome_task_links, :task_definition_id, :bigint + change_column :learning_outcome_task_links, :task_id, :bigint + change_column :learning_outcome_task_links, :learning_outcome_id, :bigint + change_column :learning_outcomes, :unit_id, :bigint + change_column :logins, :user_id, :bigint + change_column :overseer_assessments, :task_id, :bigint + change_column :plagiarism_match_links, :task_id, :bigint + change_column :plagiarism_match_links, :other_task_id, :bigint + change_column :projects, :unit_id, :bigint + change_column :projects, :user_id, :bigint + change_column :projects, :campus_id, :bigint + change_column :task_comments, :task_id, :bigint + change_column :task_comments, :user_id, :bigint + change_column :task_comments, :recipient_id, :bigint + change_column :task_comments, :assessor_id, :bigint + change_column :task_comments, :task_status_id, :bigint + change_column :task_comments, :reply_to_id, :bigint + change_column :task_comments, :overseer_assessment_id, :bigint + change_column :task_definitions, :unit_id, :bigint + change_column :task_definitions, :group_set_id, :bigint + change_column :task_definitions, :tutorial_stream_id, :bigint + change_column :task_definitions, :overseer_image_id, :bigint + change_column :task_engagements, :task_id, :bigint + change_column :task_pins, :task_id, :bigint + change_column :task_pins, :user_id, :bigint + change_column :task_submissions, :task_id, :bigint + change_column :task_submissions, :assessor_id, :bigint + change_column :tasks, :task_definition_id, :bigint + change_column :tasks, :project_id, :bigint + change_column :tasks, :task_status_id, :bigint + change_column :tasks, :group_submission_id, :bigint + change_column :tutorial_enrolments, :project_id, :bigint + change_column :tutorial_enrolments, :tutorial_id, :bigint + change_column :tutorial_streams, :activity_type_id, :bigint + change_column :tutorial_streams, :unit_id, :bigint + change_column :tutorials, :unit_id, :bigint + change_column :tutorials, :unit_role_id, :bigint + change_column :tutorials, :campus_id, :bigint + change_column :tutorials, :tutorial_stream_id, :bigint + change_column :unit_roles, :user_id, :bigint + change_column :unit_roles, :tutorial_id, :bigint + change_column :unit_roles, :role_id, :bigint + change_column :unit_roles, :unit_id, :bigint + change_column :units, :teaching_period_id, :bigint + change_column :units, :main_convenor_id, :bigint + change_column :units, :draft_task_definition_id, :bigint + change_column :units, :overseer_image_id, :bigint + change_column :users, :role_id, :bigint, default: 0 + change_column :webcal_unit_exclusions, :webcal_id, :bigint, null: false + change_column :webcal_unit_exclusions, :unit_id, :bigint, null: false + change_column :webcals, :user_id, :bigint + + # Reinstate indexes (not as foreign keys) + # add_index :auth_tokens, :user_id + # add_index :breaks, :teaching_period_id + # add_index :comments_read_receipts, :task_comment_id + # add_index :comments_read_receipts, :user_id + add_index :group_memberships, :group_id + add_index :group_memberships, :project_id + # add_index :group_sets, :unit_id + add_index :group_submissions, :group_id + add_index :group_submissions, :submitted_by_project_id + add_index :group_submissions, :task_definition_id + add_index :groups, :group_set_id + add_index :groups, :tutorial_id + # add_index :learning_outcome_task_links, :task_definition_id + # add_index :learning_outcome_task_links, :task_id + add_index :learning_outcome_task_links, :learning_outcome_id + # add_index :learning_outcomes, :unit_id + # add_index :logins, :user_id + # add_index :overseer_assessments, :task_id + # add_index :plagiarism_match_links, :task_id + # add_index :plagiarism_match_links, :other_task_id + # add_index :projects, :unit_id + # add_index :projects, :user_id + # add_index :projects, :campus_id + # add_index :task_comments, :task_id + add_index :task_comments, :user_id + # add_index :task_comments, :recipient_id + add_index :task_comments, :assessor_id + add_index :task_comments, :task_status_id + # add_index :task_comments, :reply_to_id + # add_index :task_comments, :overseer_assessment_id + # add_index :task_definitions, :unit_id + add_index :task_definitions, :group_set_id + # add_index :task_definitions, :tutorial_stream_id + # add_index :task_definitions, :overseer_image_id + # add_index :task_engagements, :task_id + add_index :task_pins, :task_id + # add_index :task_pins, :user_id + # add_index :task_submissions, :task_id + add_index :task_submissions, :assessor_id + # add_index :tasks, :task_definition_id + # add_index :tasks, :project_id + # add_index :tasks, :task_status_id + # add_index :tasks, :group_submission_id + # add_index :tutorial_enrolments, :project_id + # add_index :tutorial_enrolments, :tutorial_id + # add_index :tutorial_streams, :activity_type_id + # add_index :tutorial_streams, :unit_id + # add_index :tutorials, :unit_id + # add_index :tutorials, :unit_role_id + # add_index :tutorials, :campus_id + # add_index :tutorials, :tutorial_stream_id + # add_index :unit_roles, :user_id + # add_index :unit_roles, :tutorial_id + # add_index :unit_roles, :role_id + # add_index :unit_roles, :unit_id + # add_index :units, :teaching_period_id + add_index :units, :main_convenor_id + add_index :units, :draft_task_definition_id + # add_index :units, :overseer_image_id + add_index :users, :role_id + # add_index :webcal_unit_exclusions, :webcal_id + add_index :webcal_unit_exclusions, :unit_id + # add_index :webcals, :user_id + + remove_index :learning_outcome_task_links, name: "index_learning_outcome_task_links_on_learning_outcome_id" + end + +end diff --git a/db/migrate/20211208231733_make_evidence_path_relative.rb b/db/migrate/20211208231733_make_evidence_path_relative.rb new file mode 100644 index 000000000..e15f161cf --- /dev/null +++ b/db/migrate/20211208231733_make_evidence_path_relative.rb @@ -0,0 +1,21 @@ +class MakeEvidencePathRelative < ActiveRecord::Migration[4.2] + def up + root = FileHelper.student_work_dir + + connection.exec_update(<<-EOQ, "SQL") + UPDATE tasks + SET portfolio_evidence = REPLACE(portfolio_evidence, '#{root}', '') + WHERE portfolio_evidence like '#{root}%' + EOQ + end + + def down + root = FileHelper.student_work_dir + + connection.exec_update(<<-EOQ, "SQL", []) + UPDATE tasks + SET portfolio_evidence = CONCAT('#{root}', portfolio_evidence) + WHERE portfolio_evidence not like '/%' + EOQ + end +end diff --git a/db/migrate/20220110052033_switch_to_rails_encryption.rb b/db/migrate/20220110052033_switch_to_rails_encryption.rb new file mode 100644 index 000000000..db333d19a --- /dev/null +++ b/db/migrate/20220110052033_switch_to_rails_encryption.rb @@ -0,0 +1,9 @@ +class SwitchToRailsEncryption < ActiveRecord::Migration[7.0] + def change + AuthToken.destroy_all + + remove_column :auth_tokens, :encrypted_authentication_token + remove_column :auth_tokens, :encrypted_authentication_token_iv + add_column :auth_tokens, :authentication_token, :string, null: false + end +end diff --git a/db/migrate/20220310092851_update_task_stats.rb b/db/migrate/20220310092851_update_task_stats.rb new file mode 100644 index 000000000..16d4d3346 --- /dev/null +++ b/db/migrate/20220310092851_update_task_stats.rb @@ -0,0 +1,8 @@ +class UpdateTaskStats < ActiveRecord::Migration[7.0] + def change + # Migrate all projects data - update the task stats + Project.find_in_batches do |projects| + projects.each { |project| project.update_task_stats } + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 2c7b3a411..747758f35 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1,412 +1,473 @@ -# encoding: UTF-8 # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170117233120) do +ActiveRecord::Schema.define(version: 2022_03_10_092851) do - # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + create_table "activity_types", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name", null: false + t.string "abbreviation", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["abbreviation"], name: "index_activity_types_on_abbreviation", unique: true + t.index ["name"], name: "index_activity_types_on_name", unique: true + end - create_table "badges", force: :cascade do |t| - t.string "name", limit: 255 - t.text "description" - t.string "large_image_url", limit: 255 - t.string "small_image_url", limit: 255 - t.integer "sub_task_definition_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "auth_tokens", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "auth_token_expiry", precision: 6, null: false + t.bigint "user_id" + t.string "authentication_token", null: false + t.index ["user_id"], name: "index_auth_tokens_on_user_id" end - create_table "comments_read_receipts", force: :cascade do |t| - t.integer "task_comment_id", null: false - t.integer "user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "breaks", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "start_date", precision: 6, null: false + t.integer "number_of_weeks", null: false + t.bigint "teaching_period_id" + t.index ["teaching_period_id"], name: "index_breaks_on_teaching_period_id" end - add_index "comments_read_receipts", ["task_comment_id", "user_id"], name: "index_comments_read_receipts_on_task_comment_id_and_user_id", unique: true, using: :btree - add_index "comments_read_receipts", ["task_comment_id"], name: "index_comments_read_receipts_on_task_comment_id", using: :btree - add_index "comments_read_receipts", ["user_id"], name: "index_comments_read_receipts_on_user_id", using: :btree + create_table "campuses", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name", null: false + t.integer "mode", null: false + t.string "abbreviation", null: false + t.boolean "active", null: false + t.index ["abbreviation"], name: "index_campuses_on_abbreviation", unique: true + t.index ["active"], name: "index_campuses_on_active" + t.index ["name"], name: "index_campuses_on_name", unique: true + end - create_table "group_memberships", force: :cascade do |t| - t.integer "group_id" - t.integer "project_id" - t.boolean "active", default: true - t.datetime "created_at" - t.datetime "updated_at" + create_table "comments_read_receipts", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "task_comment_id", null: false + t.bigint "user_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["task_comment_id", "user_id"], name: "index_comments_read_receipts_on_task_comment_id_and_user_id", unique: true + t.index ["task_comment_id"], name: "index_comments_read_receipts_on_task_comment_id" + t.index ["user_id"], name: "index_comments_read_receipts_on_user_id" end - create_table "group_sets", force: :cascade do |t| - t.integer "unit_id" - t.string "name", limit: 255 - t.boolean "allow_students_to_create_groups", default: true - t.boolean "allow_students_to_manage_groups", default: true - t.boolean "keep_groups_in_same_class", default: false - t.datetime "created_at" - t.datetime "updated_at" + create_table "discussion_comments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "time_started", precision: 6 + t.datetime "time_completed", precision: 6 + t.integer "number_of_prompts" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false end - add_index "group_sets", ["unit_id"], name: "index_group_sets_on_unit_id", using: :btree + create_table "group_memberships", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "group_id" + t.bigint "project_id" + t.boolean "active", default: true + t.datetime "created_at", precision: 6 + t.datetime "updated_at", precision: 6 + t.index ["group_id"], name: "index_group_memberships_on_group_id" + t.index ["project_id"], name: "index_group_memberships_on_project_id" + end - create_table "group_submissions", force: :cascade do |t| - t.integer "group_id" - t.string "notes", limit: 255 - t.integer "submitted_by_project_id" - t.datetime "created_at" - t.datetime "updated_at" - t.integer "task_definition_id" + create_table "group_sets", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "unit_id" + t.string "name" + t.boolean "allow_students_to_create_groups", default: true + t.boolean "allow_students_to_manage_groups", default: true + t.boolean "keep_groups_in_same_class", default: false + t.datetime "created_at", precision: 6 + t.datetime "updated_at", precision: 6 + t.integer "capacity" + t.boolean "locked", default: false, null: false + t.index ["unit_id"], name: "index_group_sets_on_unit_id" end - create_table "groups", force: :cascade do |t| - t.integer "group_set_id" - t.integer "tutorial_id" - t.string "name", limit: 255 - t.datetime "created_at" - t.datetime "updated_at" - t.integer "number", null: false + create_table "group_submissions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "group_id" + t.string "notes" + t.bigint "submitted_by_project_id" + t.datetime "created_at", precision: 6 + t.datetime "updated_at", precision: 6 + t.bigint "task_definition_id" + t.index ["group_id"], name: "index_group_submissions_on_group_id" + t.index ["submitted_by_project_id"], name: "index_group_submissions_on_submitted_by_project_id" + t.index ["task_definition_id"], name: "index_group_submissions_on_task_definition_id" end - create_table "helpdesk_schedules", force: :cascade do |t| - t.datetime "start_time" - t.integer "duration" - t.integer "day" - t.integer "user_id" - t.datetime "created_at" - t.datetime "updated_at" + create_table "groups", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "group_set_id" + t.bigint "tutorial_id" + t.string "name" + t.datetime "created_at", precision: 6 + t.datetime "updated_at", precision: 6 + t.integer "capacity_adjustment", default: 0, null: false + t.boolean "locked", default: false, null: false + t.index ["group_set_id"], name: "index_groups_on_group_set_id" + t.index ["tutorial_id"], name: "index_groups_on_tutorial_id" end - add_index "helpdesk_schedules", ["user_id"], name: "index_helpdesk_schedules_on_user_id", using: :btree + create_table "learning_outcome_task_links", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.text "description" + t.integer "rating" + t.bigint "task_definition_id" + t.bigint "task_id" + t.bigint "learning_outcome_id" + t.datetime "created_at", precision: 6 + t.datetime "updated_at", precision: 6 + t.index ["learning_outcome_id"], name: "learning_outcome_task_links_lo_index" + t.index ["task_definition_id"], name: "index_learning_outcome_task_links_on_task_definition_id" + t.index ["task_id"], name: "index_learning_outcome_task_links_on_task_id" + end - create_table "helpdesk_sessions", force: :cascade do |t| - t.integer "user_id", null: false - t.datetime "clock_on_time", null: false - t.datetime "clock_off_time", null: false - t.datetime "created_at" - t.datetime "updated_at" + create_table "learning_outcomes", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "unit_id" + t.integer "ilo_number" + t.string "name" + t.string "description", limit: 4096 + t.string "abbreviation" + t.index ["unit_id"], name: "index_learning_outcomes_on_unit_id" end - add_index "helpdesk_sessions", ["user_id"], name: "index_helpdesk_sessions_on_user_id", using: :btree + create_table "logins", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "timestamp", precision: 6 + t.bigint "user_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["user_id"], name: "index_logins_on_user_id" + end - create_table "helpdesk_tickets", force: :cascade do |t| - t.integer "project_id", null: false - t.integer "task_id" - t.string "description", limit: 2048 - t.boolean "is_resolved", default: false, null: false - t.datetime "created_at" - t.datetime "updated_at" - t.datetime "closed_at" - t.float "minutes_to_resolve" - t.boolean "is_closed", default: false + create_table "overseer_assessments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "task_id", null: false + t.string "submission_timestamp", null: false + t.string "result_task_status" + t.integer "status", default: 0, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["task_id", "submission_timestamp"], name: "index_overseer_assessments_on_task_id_and_submission_timestamp", unique: true + t.index ["task_id"], name: "index_overseer_assessments_on_task_id" end - add_index "helpdesk_tickets", ["project_id"], name: "index_helpdesk_tickets_on_project_id", using: :btree - add_index "helpdesk_tickets", ["task_id"], name: "index_helpdesk_tickets_on_task_id", using: :btree + create_table "overseer_images", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name", null: false + t.string "tag", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end - create_table "learning_outcome_task_links", force: :cascade do |t| - t.text "description" - t.integer "rating" - t.integer "task_definition_id" - t.integer "task_id" - t.integer "learning_outcome_id" - t.datetime "created_at" - t.datetime "updated_at" - end + create_table "plagiarism_match_links", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "task_id" + t.bigint "other_task_id" + t.integer "pct" + t.datetime "created_at", precision: 6 + t.datetime "updated_at", precision: 6 + t.string "plagiarism_report_url" + t.boolean "dismissed", default: false + t.index ["other_task_id"], name: "index_plagiarism_match_links_on_other_task_id" + t.index ["task_id"], name: "index_plagiarism_match_links_on_task_id" + end - add_index "learning_outcome_task_links", ["learning_outcome_id"], name: "learning_outcome_task_links_lo_index", using: :btree - add_index "learning_outcome_task_links", ["task_definition_id"], name: "index_learning_outcome_task_links_on_task_definition_id", using: :btree - add_index "learning_outcome_task_links", ["task_id"], name: "index_learning_outcome_task_links_on_task_id", using: :btree + create_table "projects", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "unit_id" + t.string "project_role" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.boolean "started" + t.string "progress" + t.string "status" + t.string "task_stats" + t.boolean "enrolled", default: true + t.integer "target_grade", default: 0 + t.boolean "compile_portfolio", default: false + t.date "portfolio_production_date" + t.integer "max_pct_similar", default: 0 + t.bigint "user_id" + t.integer "grade", default: 0 + t.string "grade_rationale", limit: 4096 + t.bigint "campus_id" + t.integer "submitted_grade" + t.boolean "uses_draft_learning_summary", default: false, null: false + t.index ["campus_id"], name: "index_projects_on_campus_id" + t.index ["enrolled"], name: "index_projects_on_enrolled" + t.index ["unit_id"], name: "index_projects_on_unit_id" + t.index ["user_id"], name: "index_projects_on_user_id" + end + + create_table "roles", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name" + t.text "description" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + create_table "task_comments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "task_id", null: false + t.bigint "user_id", null: false + t.string "comment", limit: 4096 + t.datetime "created_at", precision: 6, null: false + t.bigint "recipient_id" + t.string "content_type" + t.string "attachment_extension" + t.bigint "discussion_comment_id" + t.string "type" + t.datetime "time_discussion_started", precision: 6 + t.datetime "time_discussion_completed", precision: 6 + t.integer "number_of_prompts" + t.datetime "date_extension_assessed", precision: 6 + t.boolean "extension_granted" + t.bigint "assessor_id" + t.bigint "task_status_id" + t.integer "extension_weeks" + t.string "extension_response" + t.bigint "reply_to_id" + t.bigint "overseer_assessment_id" + t.index ["assessor_id"], name: "index_task_comments_on_assessor_id" + t.index ["discussion_comment_id"], name: "index_task_comments_on_discussion_comment_id" + t.index ["overseer_assessment_id"], name: "index_task_comments_on_overseer_assessment_id" + t.index ["recipient_id"], name: "fk_rails_1dbb49165b" + t.index ["reply_to_id"], name: "index_task_comments_on_reply_to_id" + t.index ["task_id"], name: "index_task_comments_on_task_id" + t.index ["task_status_id"], name: "index_task_comments_on_task_status_id" + t.index ["user_id"], name: "index_task_comments_on_user_id" + end + + create_table "task_definitions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "unit_id" + t.string "name" + t.string "description", limit: 4096 + t.decimal "weighting", precision: 10 + t.datetime "target_date", precision: 6, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "abbreviation" + t.string "upload_requirements", limit: 4096 + t.integer "target_grade", default: 0 + t.boolean "restrict_status_updates", default: false + t.string "plagiarism_checks", limit: 4096 + t.string "plagiarism_report_url" + t.boolean "plagiarism_updated", default: false + t.integer "plagiarism_warn_pct", default: 50 + t.bigint "group_set_id" + t.datetime "due_date", precision: 6 + t.datetime "start_date", precision: 6, null: false + t.boolean "is_graded", default: false + t.integer "max_quality_pts", default: 0 + t.bigint "tutorial_stream_id" + t.boolean "assessment_enabled", default: false + t.bigint "overseer_image_id" + t.index ["group_set_id"], name: "index_task_definitions_on_group_set_id" + t.index ["overseer_image_id"], name: "index_task_definitions_on_overseer_image_id" + t.index ["tutorial_stream_id"], name: "index_task_definitions_on_tutorial_stream_id" + t.index ["unit_id"], name: "index_task_definitions_on_unit_id" + end + + create_table "task_engagements", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "engagement_time", precision: 6 + t.string "engagement" + t.bigint "task_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["task_id"], name: "index_task_engagements_on_task_id" + end + + create_table "task_pins", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "task_id", null: false + t.bigint "user_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["task_id", "user_id"], name: "index_task_pins_on_task_id_and_user_id", unique: true + t.index ["task_id"], name: "index_task_pins_on_task_id" + t.index ["user_id"], name: "fk_rails_915df186ed" + end + + create_table "task_statuses", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name" + t.string "description" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + create_table "task_submissions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "submission_time", precision: 6 + t.datetime "assessment_time", precision: 6 + t.string "outcome" + t.bigint "task_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "assessor_id" + t.index ["assessor_id"], name: "index_task_submissions_on_assessor_id" + t.index ["task_id"], name: "index_task_submissions_on_task_id" + end + + create_table "tasks", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "task_definition_id" + t.bigint "project_id" + t.bigint "task_status_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.date "completion_date" + t.string "portfolio_evidence" + t.boolean "include_in_portfolio", default: true + t.datetime "file_uploaded_at", precision: 6 + t.integer "max_pct_similar", default: 0 + t.bigint "group_submission_id" + t.integer "contribution_pct", default: 100 + t.integer "times_assessed", default: 0 + t.datetime "submission_date", precision: 6 + t.datetime "assessment_date", precision: 6 + t.integer "grade" + t.integer "contribution_pts", default: 3 + t.integer "quality_pts", default: -1 + t.integer "extensions", default: 0, null: false + t.index ["group_submission_id"], name: "index_tasks_on_group_submission_id" + t.index ["project_id", "task_definition_id"], name: "tasks_uniq_proj_task_def", unique: true + t.index ["project_id"], name: "index_tasks_on_project_id" + t.index ["task_definition_id"], name: "index_tasks_on_task_definition_id" + t.index ["task_status_id"], name: "index_tasks_on_task_status_id" + end + + create_table "teaching_periods", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "period", null: false + t.datetime "start_date", precision: 6, null: false + t.datetime "end_date", precision: 6, null: false + t.integer "year", null: false + t.datetime "active_until", precision: 6, null: false + t.index ["period", "year"], name: "index_teaching_periods_on_period_and_year", unique: true + end + + create_table "tutorial_enrolments", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "project_id", null: false + t.bigint "tutorial_id", null: false + t.index ["project_id"], name: "index_tutorial_enrolments_on_project_id" + t.index ["tutorial_id", "project_id"], name: "index_tutorial_enrolments_on_tutorial_id_and_project_id", unique: true + t.index ["tutorial_id"], name: "index_tutorial_enrolments_on_tutorial_id" + end + + create_table "tutorial_streams", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name", null: false + t.string "abbreviation", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "activity_type_id", null: false + t.bigint "unit_id", null: false + t.index ["abbreviation", "unit_id"], name: "index_tutorial_streams_on_abbreviation_and_unit_id", unique: true + t.index ["abbreviation"], name: "index_tutorial_streams_on_abbreviation" + t.index ["activity_type_id"], name: "fk_rails_14ef80da76" + t.index ["name", "unit_id"], name: "index_tutorial_streams_on_name_and_unit_id", unique: true + t.index ["unit_id"], name: "index_tutorial_streams_on_unit_id" + end + + create_table "tutorials", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "unit_id" + t.string "meeting_day" + t.string "meeting_time" + t.string "meeting_location" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "code" + t.bigint "unit_role_id" + t.string "abbreviation" + t.integer "capacity", default: -1 + t.bigint "campus_id" + t.bigint "tutorial_stream_id" + t.index ["campus_id"], name: "index_tutorials_on_campus_id" + t.index ["tutorial_stream_id"], name: "index_tutorials_on_tutorial_stream_id" + t.index ["unit_id"], name: "index_tutorials_on_unit_id" + t.index ["unit_role_id"], name: "index_tutorials_on_unit_role_id" + end + + create_table "unit_roles", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "user_id" + t.bigint "tutorial_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "role_id" + t.bigint "unit_id" + t.index ["role_id"], name: "index_unit_roles_on_role_id" + t.index ["tutorial_id"], name: "index_unit_roles_on_tutorial_id" + t.index ["unit_id"], name: "index_unit_roles_on_unit_id" + t.index ["user_id"], name: "index_unit_roles_on_user_id" + end + + create_table "units", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "name" + t.string "description", limit: 4096 + t.datetime "start_date", precision: 6 + t.datetime "end_date", precision: 6 + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "code" + t.boolean "active", default: true + t.datetime "last_plagarism_scan", precision: 6 + t.bigint "teaching_period_id" + t.bigint "main_convenor_id" + t.boolean "auto_apply_extension_before_deadline", default: true, null: false + t.boolean "send_notifications", default: true, null: false + t.boolean "enable_sync_timetable", default: true, null: false + t.boolean "enable_sync_enrolments", default: true, null: false + t.bigint "draft_task_definition_id" + t.boolean "allow_student_extension_requests", default: true, null: false + t.integer "extension_weeks_on_resubmit_request", default: 1, null: false + t.boolean "allow_student_change_tutorial", default: true, null: false + t.boolean "assessment_enabled", default: true + t.bigint "overseer_image_id" + t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id" + t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id" + t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id" + t.index ["teaching_period_id"], name: "index_units_on_teaching_period_id" + end + + create_table "users", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at", precision: 6 + t.datetime "remember_created_at", precision: 6 + t.integer "sign_in_count", default: 0 + t.datetime "current_sign_in_at", precision: 6 + t.datetime "last_sign_in_at", precision: 6 + t.string "current_sign_in_ip" + t.string "last_sign_in_ip" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "first_name" + t.string "last_name" + t.string "username" + t.string "nickname" + t.string "unlock_token" + t.bigint "role_id", default: 0 + t.boolean "receive_task_notifications", default: true + t.boolean "receive_feedback_notifications", default: true + t.boolean "receive_portfolio_notifications", default: true + t.boolean "opt_in_to_research" + t.boolean "has_run_first_time_setup", default: false + t.string "login_id" + t.string "student_id" + t.index ["login_id"], name: "index_users_on_login_id", unique: true + t.index ["role_id"], name: "index_users_on_role_id" + end + + create_table "webcal_unit_exclusions", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.bigint "webcal_id", null: false + t.bigint "unit_id", null: false + t.index ["unit_id", "webcal_id"], name: "index_webcal_unit_exclusions_on_unit_id_and_webcal_id", unique: true + t.index ["unit_id"], name: "index_webcal_unit_exclusions_on_unit_id" + t.index ["webcal_id"], name: "fk_rails_d5fab02cb7" + end + + create_table "webcals", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| + t.string "guid", limit: 36, null: false + t.boolean "include_start_dates", default: false, null: false + t.bigint "user_id" + t.integer "reminder_time" + t.string "reminder_unit" + t.index ["guid"], name: "index_webcals_on_guid", unique: true + t.index ["user_id"], name: "index_webcals_on_user_id", unique: true + end - create_table "learning_outcomes", force: :cascade do |t| - t.integer "unit_id" - t.integer "ilo_number" - t.string "name", limit: 255 - t.string "description", limit: 4096 - t.string "abbreviation", limit: 255 - end - - add_index "learning_outcomes", ["unit_id"], name: "index_learning_outcomes_on_unit_id", using: :btree - - create_table "logins", force: :cascade do |t| - t.datetime "timestamp" - t.integer "user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - add_index "logins", ["user_id"], name: "index_logins_on_user_id", using: :btree - - create_table "plagiarism_match_links", force: :cascade do |t| - t.integer "task_id" - t.integer "other_task_id" - t.integer "pct" - t.datetime "created_at" - t.datetime "updated_at" - t.string "plagiarism_report_url", limit: 255 - t.boolean "dismissed", default: false - end - - add_index "plagiarism_match_links", ["other_task_id"], name: "index_plagiarism_match_links_on_other_task_id", using: :btree - add_index "plagiarism_match_links", ["task_id"], name: "index_plagiarism_match_links_on_task_id", using: :btree - - create_table "project_convenors", force: :cascade do |t| - t.integer "unit_id" - t.integer "user_id" - t.datetime "created_at" - t.datetime "updated_at" - end - - create_table "projects", force: :cascade do |t| - t.integer "unit_id" - t.string "project_role", limit: 255 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "started" - t.string "progress", limit: 255 - t.string "status", limit: 255 - t.string "task_stats", limit: 255 - t.boolean "enrolled", default: true - t.integer "target_grade", default: 0 - t.boolean "compile_portfolio", default: false - t.date "portfolio_production_date" - t.integer "max_pct_similar", default: 0 - t.integer "tutorial_id" - t.integer "user_id" - t.integer "grade", default: 0 - t.string "grade_rationale", limit: 4096 - end - - add_index "projects", ["enrolled"], name: "index_projects_on_enrolled", using: :btree - add_index "projects", ["tutorial_id"], name: "index_projects_on_tutorial_id", using: :btree - add_index "projects", ["unit_id"], name: "index_projects_on_unit_id", using: :btree - add_index "projects", ["user_id"], name: "index_projects_on_user_id", using: :btree - - create_table "roles", force: :cascade do |t| - t.string "name", limit: 255 - t.text "description" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "sub_task_definitions", force: :cascade do |t| - t.string "name", limit: 255 - t.text "description" - t.integer "badges_id" - t.integer "task_definitions_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "required", default: false, null: false - end - - add_index "sub_task_definitions", ["badges_id"], name: "index_sub_task_definitions_on_badges_id", using: :btree - - create_table "sub_tasks", force: :cascade do |t| - t.datetime "completion_date" - t.integer "sub_task_definition_id" - t.integer "task_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "task_comments", force: :cascade do |t| - t.integer "task_id", null: false - t.integer "user_id", null: false - t.string "comment", limit: 4096 - t.datetime "created_at", null: false - t.boolean "is_new", default: true - t.integer "recipient_id" - end - - add_index "task_comments", ["task_id"], name: "index_task_comments_on_task_id", using: :btree - - create_table "task_definitions", force: :cascade do |t| - t.integer "unit_id" - t.string "name", limit: 255 - t.string "description", limit: 4096 - t.decimal "weighting", precision: 10 - t.datetime "target_date", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "abbreviation", limit: 255 - t.string "upload_requirements", limit: 4096 - t.integer "target_grade", default: 0 - t.boolean "restrict_status_updates", default: false - t.string "plagiarism_checks", limit: 4096 - t.string "plagiarism_report_url", limit: 255 - t.boolean "plagiarism_updated", default: false - t.integer "plagiarism_warn_pct", default: 50 - t.integer "group_set_id" - t.datetime "due_date" - t.datetime "start_date", null: false - t.boolean "is_graded", default: false - t.integer "max_quality_pts", default: 0 - end - - add_index "task_definitions", ["unit_id"], name: "index_task_definitions_on_unit_id", using: :btree - - create_table "task_engagements", force: :cascade do |t| - t.datetime "engagement_time" - t.string "engagement", limit: 255 - t.integer "task_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - add_index "task_engagements", ["task_id"], name: "index_task_engagements_on_task_id", using: :btree - - create_table "task_statuses", force: :cascade do |t| - t.string "name", limit: 255 - t.string "description", limit: 255 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "task_submissions", force: :cascade do |t| - t.datetime "submission_time" - t.datetime "assessment_time" - t.string "outcome", limit: 255 - t.integer "task_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "assessor_id" - end - - add_index "task_submissions", ["task_id"], name: "index_task_submissions_on_task_id", using: :btree - - create_table "tasks", force: :cascade do |t| - t.integer "task_definition_id" - t.integer "project_id" - t.integer "task_status_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.date "completion_date" - t.string "portfolio_evidence", limit: 255 - t.boolean "include_in_portfolio", default: true - t.datetime "file_uploaded_at" - t.integer "max_pct_similar", default: 0 - t.integer "group_submission_id" - t.integer "contribution_pct", default: 100 - t.integer "times_assessed", default: 0 - t.datetime "submission_date" - t.datetime "assessment_date" - t.integer "grade" - t.integer "contribution_pts", default: 3 - t.integer "quality_pts", default: 0 - end - - add_index "tasks", ["group_submission_id"], name: "index_tasks_on_group_submission_id", using: :btree - add_index "tasks", ["project_id", "task_definition_id"], name: "tasks_uniq_proj_task_def", unique: true, using: :btree - add_index "tasks", ["project_id"], name: "index_tasks_on_project_id", using: :btree - add_index "tasks", ["task_definition_id"], name: "index_tasks_on_task_definition_id", using: :btree - add_index "tasks", ["task_status_id"], name: "index_tasks_on_task_status_id", using: :btree - - create_table "teams", force: :cascade do |t| - t.integer "unit_id" - t.integer "user_id" - t.string "meeting_day", limit: 255 - t.string "meeting_time", limit: 255 - t.string "meeting_location", limit: 255 - t.datetime "created_at" - t.datetime "updated_at" - t.string "official_name", limit: 255 - end - - add_index "teams", ["unit_id"], name: "index_teams_on_unit_id", using: :btree - add_index "teams", ["user_id"], name: "index_teams_on_user_id", using: :btree - - create_table "tutorials", force: :cascade do |t| - t.integer "unit_id" - t.string "meeting_day", limit: 255 - t.string "meeting_time", limit: 255 - t.string "meeting_location", limit: 255 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "code", limit: 255 - t.integer "unit_role_id" - t.string "abbreviation", limit: 255 - end - - add_index "tutorials", ["unit_id"], name: "index_tutorials_on_unit_id", using: :btree - add_index "tutorials", ["unit_role_id"], name: "index_tutorials_on_unit_role_id", using: :btree - - create_table "unit_roles", force: :cascade do |t| - t.integer "user_id" - t.integer "tutorial_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "role_id" - t.integer "unit_id" - end - - add_index "unit_roles", ["role_id"], name: "index_unit_roles_on_role_id", using: :btree - add_index "unit_roles", ["tutorial_id"], name: "index_unit_roles_on_tutorial_id", using: :btree - add_index "unit_roles", ["unit_id"], name: "index_unit_roles_on_unit_id", using: :btree - add_index "unit_roles", ["user_id"], name: "index_unit_roles_on_user_id", using: :btree - - create_table "units", force: :cascade do |t| - t.string "name", limit: 255 - t.string "description", limit: 4096 - t.datetime "start_date" - t.datetime "end_date" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "code", limit: 255 - t.boolean "active", default: true - t.datetime "last_plagarism_scan" - end - - create_table "user_roles", force: :cascade do |t| - t.integer "user_id" - t.integer "role_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - add_index "user_roles", ["role_id"], name: "index_user_roles_on_role_id", using: :btree - add_index "user_roles", ["user_id"], name: "index_user_roles_on_user_id", using: :btree - - create_table "users", force: :cascade do |t| - t.string "email", limit: 255, default: "", null: false - t.string "encrypted_password", limit: 255, default: "", null: false - t.string "reset_password_token", limit: 255 - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0 - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip", limit: 255 - t.string "last_sign_in_ip", limit: 255 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "first_name", limit: 255 - t.string "last_name", limit: 255 - t.string "username", limit: 255 - t.string "nickname", limit: 255 - t.string "authentication_token", limit: 255 - t.string "unlock_token", limit: 255 - t.datetime "auth_token_expiry" - t.integer "role_id", default: 0 - t.boolean "receive_task_notifications", default: true - t.boolean "receive_feedback_notifications", default: true - t.boolean "receive_portfolio_notifications", default: true - t.boolean "opt_in_to_research" - t.boolean "has_run_first_time_setup", default: false - t.string "login_id" - t.string "student_id" - end - - add_index "users", ["authentication_token"], name: "index_users_on_authentication_token", unique: true, using: :btree - add_index "users", ["login_id"], name: "index_users_on_login_id", unique: true, using: :btree - - add_foreign_key "comments_read_receipts", "task_comments" - add_foreign_key "comments_read_receipts", "users" - add_foreign_key "task_comments", "users", column: "recipient_id" end diff --git a/deployApi.Dockerfile b/deployApi.Dockerfile new file mode 100644 index 000000000..936a89b1c --- /dev/null +++ b/deployApi.Dockerfile @@ -0,0 +1,36 @@ +# +# deployApi.Dockerfile - the container used to host the API only +# +FROM ruby:3.1-buster + +# Setup dependencies +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y \ + ffmpeg \ + ghostscript \ + imagemagick \ + libmagic-dev \ + libmagickwand-dev \ + libmariadb-dev \ + tzdata + +# Setup the folder where we will deploy the code +WORKDIR /doubtfire + +# Copy doubtfire-api source +COPY . /doubtfire/ + +# Install bundler +RUN gem install bundler -v '~> 2.2.0' +RUN bundle config set --global without development test staging + +# Install the Gems +RUN bundle install + +EXPOSE 3000 + +# Set default to production +ENV RAILS_ENV production + +# Run migrate and server on launch +CMD bundle exec rake db:migrate && bundle exec rails s -b 0.0.0.0 diff --git a/deployAppSvr.Dockerfile b/deployAppSvr.Dockerfile new file mode 100644 index 000000000..78a2c2caf --- /dev/null +++ b/deployAppSvr.Dockerfile @@ -0,0 +1,47 @@ +# +# deployAppSrc.Dockerfile - the container used for back end processing +# +FROM ruby:3.1-buster + +# Setup dependencies +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y \ + ffmpeg \ + ghostscript \ + imagemagick \ + libmagic-dev \ + libmagickwand-dev \ + libmariadb-dev \ + python3-pygments \ + tzdata \ + cron \ + msmtp-mta bsd-mailx + +# Setup the folder where we will deploy the code +WORKDIR /doubtfire + +# Install LaTex +COPY ./.ci-setup /doubtfire/.ci-setup +RUN /doubtfire/.ci-setup/texlive-install.sh + +# Install bundler +RUN gem install bundler -v '~> 2.2.0' +RUN bundle config set --global without development test staging + +# Install the Gems +COPY ./Gemfile ./Gemfile.lock /doubtfire/ +RUN bundle install + +# Setup path +ENV PATH /tmp/texlive/bin/x86_64-linux:$PATH + +# Copy doubtfire-api source +COPY . /doubtfire/ + +# Crontab file copied to cron.d directory. +COPY ./.ci-setup/pdfGen/entry_point.sh /doubtfire/ +COPY ./.ci-setup/pdfGen/crontab /etc/cron.d/container_cronjob + +RUN touch /var/log/cron.log + +CMD /doubtfire/entry_point.sh diff --git a/docker-compose.yml b/docker-compose.yml index 8413bee64..22520ccaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,38 +1,73 @@ -version: '2' +version: '3' services: - api: - container_name: doubtfire-api + df-api: + container_name: df-api build: . - command: bash -c "if [ -f /doubtfire-api/tmp/pids/server.pid ]; then rm /doubtfire-api/tmp/pids/server.pid; fi && rails server -b 0.0.0.0" ports: - "3000:3000" volumes: - - ../doubtfire-api:/doubtfire-api - links: - - web + - ./:/doubtfire + - ../data/tmp:/doubtfire/tmp + - ../data/student-work:/student-work depends_on: - - db + - dev-db environment: - DATABASE_URL: 'postgresql://itig:d872$$dh@db:5432/doubtfire_dev' RAILS_ENV: 'development' - db: - container_name: doubtfire-db - image: postgres - ports: - - "5432:5432" - environment: - POSTGRES_PASSWORD: d872$$dh - POSTGRES_USER: itig + DF_STUDENT_WORK_DIR: /student-work + DF_INSTITUTION_HOST: http://localhost:3000 + DF_INSTITUTION_PRODUCT_NAME: OnTrack + DF_INSTITUTION_PRODUCT_VERSION: 0.0.1 + DF_INSTITUTION_NAME: Doubtfire + + DF_SECRET_KEY_BASE: test-secret-key-test-secret-key! + DF_SECRET_KEY_ATTR: test-secret-key-test-secret-key! + DF_SECRET_KEY_DEVISE: test-secret-key-test-secret-key! + + # Authentication method - can set to AAF or ldap + DF_AUTH_METHOD: database + DF_AAF_ISSUER_URL: https://rapid.test.aaf.edu.au + DF_AAF_AUDIENCE_URL: http://localhost:3000 + DF_AAF_CALLBACK_URL: http://localhost:3000/api/auth/jwt + DF_AAF_IDENTITY_PROVIDER_URL: https://signon-uat.deakin.edu.au/idp/shibboleth + DF_AAF_UNIQUE_URL: https://rapid.test.aaf.edu.au/jwt/authnrequest/research/Ag4EJJhjf0zXHqlKvKZEbg + DF_AAF_AUTH_SIGNOUT_URL: https://sync-uat.deakin.edu.au/auth/logout + DF_SECRET_KEY_AAF: v4~LMFLzzwRGZdju\5QBa@FiHIN9 + + # Database settings - for development env + DF_DEV_DB_ADAPTER: mysql2 + DF_DEV_DB_HOST: doubtfire-dev-db + DF_DEV_DB_DATABASE: doubtfire-dev + DF_DEV_DB_USERNAME: dfire + DF_DEV_DB_PASSWORD: pwd - web: - container_name: doubtfire-web - build: ../doubtfire-web - command: npm start + # Database settings - for test env + DF_TEST_DB_ADAPTER: mysql2 + DF_TEST_DB_HOST: doubtfire-dev-db + DF_TEST_DB_DATABASE: doubtfire-dev + DF_TEST_DB_USERNAME: dfire + DF_TEST_DB_PASSWORD: pwd + + # Database settings - for test env + DF_PRODUCTION_DB_ADAPTER: mysql2 + DF_PRODUCTION_DB_HOST: doubtfire-dev-db + DF_PRODUCTION_DB_DATABASE: doubtfire-dev + DF_PRODUCTION_DB_USERNAME: dfire + DF_PRODUCTION_DB_PASSWORD: pwd + + # Overseer + OVERSEER_ENABLED: 0 + # RABBITMQ_HOSTNAME: doubtfire-mq + # RABBITMQ_USERNAME: secure_credentials + # RABBITMQ_PASSWORD: secure_credentials + + dev-db: + container_name: doubtfire-dev-db + image: mariadb environment: - DF_DOCKER_MACHINE_IP: $DF_DOCKER_MACHINE_IP - ports: - - "8000:8000" - - "8080:8080" + MYSQL_ROOT_PASSWORD: db-root-password + MYSQL_DATABASE: doubtfire-dev + MYSQL_USER: dfire + MYSQL_PASSWORD: pwd volumes: - - ../doubtfire-web:/doubtfire-web + - ../data/database:/var/lib/mysql diff --git a/docker.sh b/docker.sh deleted file mode 100755 index a1eda9c5f..000000000 --- a/docker.sh +++ /dev/null @@ -1,403 +0,0 @@ -#!/bin/sh - -# -# Doubtfire Docker Script -# ======================= -# -# Provides commands to run Doubtfire via Docker without -# actually needing to know Docker commands (hopefully) -# - -# -# Resets the color -# -msg_reset () { - RESET='\033[0m' - printf "${RESET}\n" -} - -# -# Log an error -# -error () { - RED_FORE='\033[0;31m' - printf "${RED_FORE}ERROR: $1" - msg_reset -} - -# -# Log verbose message -# -verbose () { - if [ $VERBOSE_OUTPUT -eq 1 ]; then - return - fi - CYAN_FORE='\033[0;36m' - printf "${CYAN_FORE}INFO: $1" - msg_reset -} - -# -# Log message -# -msg () { - printf "$1\n" -} - -# -# Try and get the docker machine IP -# Returns the IP or else exits the script -# -get_docker_machine_ip () { - DF_DOCKER_MACHINE_IP=$(docker-machine ip doubtfire) - - if [ $? -ne 0 ]; then - verbose "Docker machine is not running. Attempting to start..." - docker-machine start doubtfire - if [ $? -ne 0 ]; then - error "Could not start Doubtfire docker machine!" - msg "Ensure you have created the docker machine first:" - msg " docker-machine create --driver virtualbox doubtfire" - exit 1 - fi - fi - - verbose "Running Docker daemon..." - eval "$(docker-machine env doubtfire)" -} - -# -# Returns whether or not the provided service is running -# -is_service_running () { - verbose "Checking if $1 is running..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire ps $1 | grep 'Up' &> /dev/null - if [ $? -ne 0 ]; then - verbose "Service $1 is not running!" - return 1 - fi - verbose "Service $1 is running" - return 0 -} - -# -# Returns whether or not all services are running -# -is_doubtfire_running () { - verbose "Checking if doubtfire is running..." - if is_service_running "db" && is_service_running "api" && is_service_running "db" ; then - verbose "Doubtfire is up and running" - return 0 - fi - verbose "Doubtfire is not running!" - return 1 -} - -# -# Tries and restores a previous instance of the docker -# -restore_doubtfire_services () { - msg "Attempting to restore Doubtfire services..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire up -d - if [ $? -ne 0 ]; then - verbose "Failed to restore Doubtfire." - return 1 - fi - verbose "Restored Doubtfire" - return 0 -} - -# -# Tries and restores a previous instance of the docker -# -build_doubtfire_images () { - case $1 in - 'api'|'web'|'db') - ;; - '') - msg "Building all Doubtfire images. This may take a while..." - ;; - *) - msg "Invalid image provided. Please provide one of api, web or db." - return 1 - ;; - esac - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire build $1 - if [ $? -ne 0 ]; then - error "Failed to build Doubtfire. Refer to logs above." - return 1 - fi - verbose "Built Doubtfire" - return 0 -} - -# -# Tries to create Doubtfire services -# -create_doubtfire_services () { - verbose "Attempting to create Doubtfire services..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire create - if [ $? -ne 0 ]; then - error "Failed to create services! Refer to docker-compose output above." - return 1 - fi - verbose "Created Doubtfire services" - return 0 -} - -# -# Starts the doubtfire database -# -start_doubtfire_database () { - verbose "Attempting to start Doubtfire database container..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire start db - if [ $? -ne 0 ]; then - error "Failed to start Doubtfire database! Refer to docker-compose output above." - return 1 - fi - verbose "Started Doubtfire Database" - return 0 -} - -# -# Tries a direct connect to the DF postgresql database -# -is_postgres_running () { - IS_RUNNING=$(DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire run db bash -c "PGPASSWORD=d872\\\$dh psql -h db -U itig -c \"select 'It is running'\" 2>/dev/null | grep -c \"It is running\"") - if [[ $IS_RUNNING == *"1"* ]]; then - return 0 - else - return 1 - fi -} - -# -# Populates doubtfire database -# -populate_doubtfire_database () { - msg "Ensuring database is running..." - # Check if database is running. If not start it - if ! is_service_running "db" && ! start_doubtfire_database; then - error "Cannot populate as service isn't running" - return 1 - fi - # Ensure we can actually connect to the DB before we ask to write to it - while ! is_postgres_running; do - verbose "Postgres not yet running... sleep 1" - sleep 1 - done - msg "Populating database with test data..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire run api rake db:populate - if [ $? -ne 0 ]; then - error "Failed to populate Doubtfire database! Refer to error above." - return 1 - fi - msg "Database populated!" - return 0 -} - -# -# Shows DF is running message -# -show_running_message () { - msg "Doubtfire is now running at:" - msg " Doubtfire API: http://$DF_DOCKER_MACHINE_IP:3000/api/docs/" - msg " Doubtfire Web: http://$DF_DOCKER_MACHINE_IP:8000/" - msg "It may take several moments for the URLs above to become active" -} - -# -# Starts doubtfire -# -start_doubtfire () ( - try_build_df_again () { - verbose "Attempting to recover by re-building Doubtfire images..." - if ! build_doubtfire_images; then - error "Cannot build Doubtfire images" - return 1 - fi - } - get_docker_machine_ip - msg "Starting doubtfire ..." - if is_doubtfire_running; then - msg "No need to start Doubtfire. It is already running." - show_running_message - return 0 - fi - # First, try to see if the services have already been created - # and if not create them - if ! restore_doubtfire_services && ! create_doubtfire_services; then - verbose "Problem creating services. Trying to rebuild images..." - if ! try_build_df_again; then - error "Failed to start Doubtfire - cannot create services" - return 1 - fi - fi - # Repopulate database - if ! populate_doubtfire_database; then - verbose "Problem populating. Trying to rebuild images..." - if ! try_build_df_again || ! restore_doubtfire_services; then - error "Failed to start Doubtfire - could not populate data and restore" - return 1 - fi - fi - show_running_message -) - -# -# Stop doubtfire -# -stop_doubtfire () { - get_docker_machine_ip - - if ! is_doubtfire_running; then - msg "No need to stop Doubtfire. It is already stopped." - return 1 - fi - - msg "Stopping doubtfire ..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire down - - if [ $? -ne 0 ]; then - error "Failed to stop Doubtfire! Check logs above" - return 1 - fi - - msg "Doubtfire is no longer running" -} - -# -# Restart doubtfire -# -restart_doubtfire () { - get_docker_machine_ip - - if ! is_doubtfire_running; then - msg "Cannot restart Doubtfire. It is not running" - return 1 - fi - - msg "Restarting doubtfire ..." - DF_DOCKER_MACHINE_IP=$DF_DOCKER_MACHINE_IP docker-compose -p doubtfire restart - - if [ $? -ne 0 ]; then - error "Failed to restart Doubtfire! Check logs above" - return 1 - fi - - msg "Doubtfire was restarted." - show_running_message -} - -# -# Attach to container -# -attach_to () { - get_docker_machine_ip - - case $1 in - 'api'|'web'|'db') - ;; - *) - msg "Invalid container provided. Please provide one of api, web or db." - return 1 - ;; - esac - - if ! is_doubtfire_running; then - msg "Cannot attach or ssh into to Doubtfire container. Doubtfire is not running" - return 1 - fi - - msg "Attaching to $1. Use ctrl+c to detach." - docker attach --sig-proxy=false doubtfire-$1 - msg "\nDeattached from $1." -} - -# -# Show help -# -show_help () { - msg "Run Doubtfire using Docker." - msg - msg "Usage:" - msg "./docker.sh [COMMAND] [ARGS...]" - msg "./docker.sh -h" - msg - msg "Options:" - msg " -v Show more output" - msg " -h Display help" - msg - msg "Commands:" - msg " start Start Doubtfire docker services" - msg " stop Stop Doubtfire docker services" - msg " restart Restart Doubtfire docker services" - msg " build (Re)build Doubtfire docker image(s)" - msg " populate Populate the API with test data" - msg " attach Attach to one of the api, web or db containers" - return 0 -} - -# -# Handle options -# -handle_options () { - COMMAND=$1 - VERBOSE_OUTPUT=1 - shift 1 - # Not a switch? - if [[ $1 != "-"* ]]; then - ARG_1=$1 - shift 1 - fi - while getopts ":vh" OPT; do - case $OPT in - v) - VERBOSE_OUTPUT=0 - ;; - h) - show_help - return $? - ;; - \?) - error "Invalid option: -$OPTARG" >&2 - ;; - esac - done - case $COMMAND in - "start") - start_doubtfire - return $? - ;; - "stop") - stop_doubtfire - return $? - ;; - "restart") - restart_doubtfire - return $? - ;; - "populate") - populate_doubtfire_database - return $? - ;; - "build") - build_doubtfire_images $ARG_1 - return $? - ;; - "attach") - attach_to $ARG_1 - return $? - ;; - "") - show_help - return $? - ;; - *) - msg "Invalid command provided" - return 1 - ;; - esac -} - -handle_options $* diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md new file mode 100644 index 000000000..b70119000 --- /dev/null +++ b/docs/pull_request_template.md @@ -0,0 +1,35 @@ +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +# How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration + +- [ ] Test A +- [ ] Test B + +# Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation if appropriate +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have created or extended unit tests to address my new additions +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules + +If you have any questions, please contact @macite or @jakerenzella. diff --git a/lib/assets/ontrack_receive_action.rb b/lib/assets/ontrack_receive_action.rb new file mode 100644 index 000000000..d597ebab7 --- /dev/null +++ b/lib/assets/ontrack_receive_action.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'yaml' + +def receive(_subscriber_instance, channel, _results_publisher, delivery_info, _properties, params) + params = JSON.parse(params) + logger.info "Receiving update for overseer assessment: #{params}" + logger.info "Acknowledge delivery of message" + + # Params must contain: + # overseer assessment id + + if params['overseer_assessment_id'].nil? + logger.error 'PARAM `overseer_assessment_id` is required' + channel.reject(delivery_info.delivery_tag) + return + end + + overseer_assessment_id = params['overseer_assessment_id'] + overseer_assessment = OverseerAssessment.find(overseer_assessment_id) + + unless overseer_assessment.present? + logger.error "No overseer_assessment found for id: #{overseer_assessment_id}" + channel.reject(delivery_info.delivery_tag) + return + end + + channel.ack(delivery_info.delivery_tag) + overseer_assessment.update_from_output + +rescue StandardError => e + logger.error e.inspect +ensure + overseer_assessment.save! unless overseer_assessment.nil? +end diff --git a/lib/helpers/database_populator.rb b/lib/helpers/database_populator.rb index b0c1b9f2d..82e7a4d6b 100644 --- a/lib/helpers/database_populator.rb +++ b/lib/helpers/database_populator.rb @@ -1,5 +1,6 @@ -require 'populator' -require 'faker' +if Rails.env.development? + require 'faker' +end require 'bcrypt' require 'json' require_all 'lib/helpers' @@ -15,9 +16,9 @@ class DatabasePopulator def initialize(scale = :small) # Set up our caches scale ||= :small - @user_cache = {} + @user_cache = nil + @echo = false @unit_cache = {} - @task_def_cache = {} # Set up the scale scale_data = { small: { @@ -29,11 +30,10 @@ def initialize(scale = :small) few_tutorials: 1, some_tutorials: 1, many_tutorials: 1, - max_tutorials: 4, - tickets_generated: 10 + max_tutorials: 4 }, large: { - min_students: 15, + min_students: 50, delta_students: 7, few_tasks: 10, some_tasks: 30, @@ -41,27 +41,111 @@ def initialize(scale = :small) few_tutorials: 1, some_tutorials: 2, many_tutorials: 4, - max_tutorials: 20, - tickets_generated: 50 + max_tutorials: 20 } } accepted_scale_types = scale_data.keys unless accepted_scale_types.include?(scale) throw "Invalid scale value '#{scale}'. Acceptable values are: #{accepted_scale_types.join(", ")}" - else - puts "-> Scale is set to #{scale}" end @scale = scale_data[scale] + + return if User.count > 1 + + echo_line "-> Scale is set to #{scale}" + + @user_cache = {} + @echo = true + # Fixed data contains all fixed units and users created - generate_user_roles - generate_task_statuses + generate_fixed_data() + + generate_teaching_periods() + generate_campuses + generate_activity_types end - def generate_admin - @user_data = { - acain: { first_name: 'Andrew', last_name: 'Cain', nickname: 'Macite', role_id: Role.admin_id } + def generate_teaching_periods + data = { + period: 'T1', + year: 2018, + start_date: Date.parse('2018-03-05'), + end_date: Date.parse('2018-05-25'), + active_until: Date.parse('2018-06-15') } + tp = TeachingPeriod.create!(data) + + tp.add_break Date.parse('2018-03-30'), 1 + + data = { + period: 'T2', + year: 2018, + start_date: Date.parse('2018-07-09'), + end_date: Date.parse('2018-09-28'), + active_until: Date.parse('2018-10-19') + } + tp = TeachingPeriod.create! data + + tp.add_break Date.parse('2018-08-13'), 1 + + data = { + period: 'T3', + year: 2018, + start_date: Date.parse('2018-11-05'), + end_date: Date.parse('2019-02-01'), + active_until: Date.parse('2019-02-15') + } + tp = TeachingPeriod.create! data + + tp.add_break Date.parse('2018-12-24'), 2 end + + def generate_campuses + data = { + name: 'Cloud', + mode: 'timetable', + abbreviation: 'C', + active: true + } + Campus.create! data + + data = { + name: 'Burwood', + mode: 'automatic', + abbreviation: 'B', + active: true + } + Campus.create! data + + data = { + name: 'Geelong', + mode: 'manual', + abbreviation: 'G', + active: true + } + Campus.create! data + end + + def generate_activity_types + data = { + name: 'Practical', + abbreviation: 'prac', + } + ActivityType.create! data + + data = { + name: 'Workshop', + abbreviation: 'wrkshop', + } + ActivityType.create! data + + data = { + name: 'Class', + abbreviation: 'cls', + } + ActivityType.create! data + end + # # Generate some users. Pass in an optional filter(s) for: # Role.admin, Role.convenor, Role.tutor, Role.student @@ -79,12 +163,12 @@ def generate_users(filter = nil) throw "Unaccepted filter for generate_users, should be one of #{accepted_to_str}" end - print "--> Generating users with role(s) #{filter.pluck(:name).join(', ')}" + echo "--> Generating users with role(s) #{filter.pluck(:name).join(', ')}" users_to_generate = @user_data.select { | user_key, profile | filter.pluck(:id).include? profile[:role_id] } # Create each user users_to_generate.each do |user_key, profile| - print '.' + echo '.' username = user_key.to_s profile[:email] ||= "#{username}@doubtfire.com" @@ -102,18 +186,18 @@ def generate_users(filter = nil) @user_cache[user_key] = user end - puts '!' + echo_line '!' end # # Generates some units # def generate_units - puts "--> Generating units" + echo_line "--> Generating units" if @user_cache.empty? # Must generate users first! - puts "---> No users generated. Generating users first..." + echo_line "---> No users generated. Generating users first..." generate_users() end @@ -125,17 +209,31 @@ def generate_units # Run through the unit_details and initialise their data @unit_data.each do | unit_key, unit_details | - puts "---> Generating unit #{unit_details[:code]}" + echo_line "---> Generating unit #{unit_details[:code]}" + + if unit_details[:teaching_period].present? + data = { + code: unit_details[:code], + name: unit_details[:name], + description: faker_random_sentence(10, 15), + teaching_period: unit_details[:teaching_period] + } + else + data = { + code: unit_details[:code], + name: unit_details[:name], + description: faker_random_sentence(10, 15), + start_date: Time.zone.now - 6.weeks, + end_date: 13.weeks.since(Time.zone.now - 6.weeks) + } + end + unit = Unit.create!( - code: unit_details[:code], - name: unit_details[:name], - description: Populator.words(10..15), - start_date: Time.zone.now - 6.weeks, - end_date: 13.weeks.since(Time.zone.now - 6.weeks) + data ) # Assign the convenors for this unit unit_details[:convenors].each do | user_key | - puts "----> Adding convenor #{user_key}" + echo_line "----> Adding convenor #{user_key}" unit.employ_staff(@user_cache[user_key], Role.convenor) end # Cache what we have @@ -143,10 +241,20 @@ def generate_units # Generate other unit-related stuff generate_tasks_for_unit(unit, unit_details) generate_and_align_ilos_for_unit(unit, unit_details) + generate_tutorial_streams_for(unit) generate_tutorials_and_enrol_students_for_unit(unit, unit_details) end end + def generate_tutorial_streams_for(unit) + rand(1...5).times { + activity_type = random_activity_type + name = "#{activity_type.name}-#{unit.tutorial_streams.where(activity_type: activity_type).count + 1}" + abbreviation = "#{activity_type.abbreviation}-#{unit.tutorial_streams.where(activity_type: activity_type).count + 1}" + unit.add_tutorial_stream(name, abbreviation, activity_type) + } + end + # # Random project helper # @@ -155,6 +263,22 @@ def random_project Project.find(id) end + # + # Random campus helper + # + def random_campus + id = Campus.pluck(:id).sample + Campus.find(id) + end + + # + # Random activity type helper + # + def random_activity_type + id = ActivityType.pluck(:id).sample + ActivityType.find(id) + end + # # Generated fixed data here for students and units # @@ -163,7 +287,7 @@ def generate_fixed_data @user_data = { acain: {first_name: "Andrew", last_name: "Cain", nickname: "Macite", role_id: Role.admin_id }, aconvenor: {first_name: "Clinton", last_name: "Woodward", nickname: "The Giant", role_id: Role.convenor_id }, - aadmin: {first_name: "Allan", last_name: "Jones", nickname: "P-Jiddy", role_id: Role.admin_id }, + ajones: {first_name: "Allan", last_name: "Jones", nickname: "P-Jiddy", role_id: Role.admin_id }, rwilson: {first_name: "Reuben", last_name: "Wilson", nickname: "Reubs", role_id: Role.convenor_id }, atutor: {first_name: "Akihiro", last_name: "Noguchi", nickname: "Animations", role_id: Role.tutor_id }, acummaudo: {first_name: "Alex", last_name: "Cummaudo", nickname: "DoubtfireDude", role_id: Role.convenor_id }, @@ -195,10 +319,11 @@ def generate_fixed_data code: "COS10001", name: "Introduction to Programming", convenors: [ :acain, :aconvenor ], + teaching_period: TeachingPeriod.first, tutors: [ { user: :acain, num: many_tutorials }, { user: :aconvenor, num: many_tutorials }, - { user: :aadmin, num: many_tutorials }, + { user: :ajones, num: many_tutorials }, { user: :rwilson, num: many_tutorials }, { user: :acummaudo, num: some_tutorials }, { user: :atutor, num: many_tutorials }, @@ -206,14 +331,12 @@ def generate_fixed_data { user: :angusmorton, num: some_tutorials }, { user: :cliff, num: some_tutorials }, ], - num_tasks: some_tasks, - ilos: rand(0..3), students: [ ] }, oop: { code: "COS20007", name: "Object Oriented Programming", - convenors: [ :acain, :aconvenor, :aadmin, :acummaudo ], + convenors: [ :acain, :aconvenor, :ajones, :acummaudo ], tutors: [ { user: "tutor_1", num: few_tutorials }, { user: :angusmorton, num: few_tutorials }, @@ -221,7 +344,7 @@ def generate_fixed_data { user: :joostfunkekupper, num: few_tutorials }, ], num_tasks: many_tasks, - ilos: rand(0..3), + ilos: Faker::Number.between(from: 0, to: 3), students: [ :cliff ] }, ai4g: { @@ -233,7 +356,7 @@ def generate_fixed_data { user: :cliff, num: few_tutorials }, ], num_tasks: few_tasks, - ilos: rand(0..3), + ilos: Faker::Number.between(from: 0, to: 3), students: [ :acummaudo ] }, gameprog: { @@ -244,100 +367,189 @@ def generate_fixed_data { user: :aconvenor, num: few_tutorials }, ], num_tasks: few_tasks, - ilos: rand(0..3), - students: [ :acain, :aadmin ] + ilos: Faker::Number.between(from: 0, to: 3), + students: [ :acain, :ajones ] }, } - puts "-> Defined #{@user_data.length} fixed users and #{@unit_data.length} units" + echo_line "-> Defined #{@user_data.length} fixed users and #{@unit_data.length} units" end - private - # - # Generate roles + # Generates tutorials for unit and enrols some students in them # - def generate_user_roles - print "-> Generating user roles" - roles = [ - { name: 'Student', description: "Students are able to be enrolled into units, and to submit progress for their unit projects." }, - { name: 'Tutor', description: "Tutors are able to supervise tutorial classes and provide feedback to students, they may also be students in other units" }, - { name: 'Convenor', description: "Convenors are able to create and manage units, as well as act as tutors and students." }, - { name: 'Admin', description: "Admin are able to create convenors, and act as convenors, tutors, and students in units." } - ] - - roles.each do |role| - Role.create!(name: role[:name], description: role[:description]) - print "." + def generate_tutorials_and_enrol_students_for_unit(unit, unit_details) + student_count = 0 + tutorial_count = 0 + + # Grab stuff from scale + max_tutorials = @scale[:max_tutorials] + min_students = @scale[:min_students] + delta_students = @scale[:delta_students] + + # Collection of weekdays to be used + weekdays = %w[Monday Tuesday Wednesday Thursday Friday] + + # Create tutorials and enrol students + unit_details[:tutors].each do | user_details | + # only up to 4 tutorials for small scale + break if tutorial_count > max_tutorials + + if @user_cache.present? + tutor = @user_cache[user_details[:user]] + else + tutor = User.find_by_username(user_details[:user]) + end + + echo_line "----> Enrolling tutor #{tutor.name} with #{user_details[:num]} tutorials" + tutor_unit_role = unit.employ_staff(tutor, Role.tutor) + + campus = random_campus + + user_details[:num].times do | count | + tutorial_count += 1 + tutorial_stream = unit.tutorial_streams.sample + #day, time, location, tutor_username, abbrev + tutorial = unit.add_tutorial( + "#{weekdays.sample}", + "#{8 + Faker::Number.between(from: 0, to: 11)}:#{['00', '30'].sample}", # Mon-Fri 8am-7:30pm + "#{['EN', 'BA'].sample}#{Faker::Number.between(from: 0, to: 6)}0#{Faker::Number.between(from: 0, to: 8)}", # EN###/BA### + tutor, + campus, + rand(10...20), + "LA1-#{tutorial_count.to_s.rjust(2, '0')}", + tutorial_stream + ) + + # Add a random number of students to the tutorial + num_students_in_tutorial = (min_students + Faker::Number.between(from: 0, to: delta_students - 1)) + echo "-----> Creating #{num_students_in_tutorial} projects under tutorial #{tutorial.abbreviation}" + num_students_in_tutorial.times do + student = find_or_create_student("student_#{student_count}") + project = unit.enrol_student(student, campus) + student_count += 1 + project.enrol_in(tutorial) + echo '.' + end + # Add fixed students to first tutorial + if count == 0 + unit_details[:students].each do | student_key | + unit.enrol_student(@user_cache[student_key], campus) + end + end + echo_line "!" + end end - puts "!" end - # - # Generate tasks statuses - # - def generate_task_statuses - print "-> Generating task statuses" - statuses = { - "Not Started": "You have not yet started this task.", - "Complete": "This task has been signed off by your tutor.", - "Need Help": "Some help is required in order to complete this task.", - "Working On It": "This task is currently being worked on.", - "Fix and Resubmit": "This task must be resubmitted after fixing some issues.", - "Do Not Resubmit": "This task must be fixed and included in your portfolio, but should not be resubmitted.", - "Redo": "This task needs to be redone.", - "Discuss": "Your work looks good, discuss it with your tutor to complete.", - "Ready to Mark": "This task is ready for the tutor to assess to provide feedback.", - "Demonstrate": "Your work looks good, demonstrate it to your tutor to complete.", - "Fail": "You did not successfully demonstrate the required learning in this task." - } - statuses.each do | name, desc | - print "." - TaskStatus.create(name: name, description: desc) + def self.assess_task(proj, task, tutor, status, complete_date) + alignments = [] + task.unit.learning_outcomes.each do |lo| + next if rand(0..10) < 7 + data = { + ilo_id: lo.id, + rating: rand(1..5), + rationale: "Simulated rationale text..." + } + alignments << data + end + + if task.group_task? && task.group.nil? + return + end + contributions = nil + + task.create_alignments_from_submission(alignments) unless alignments.nil? + task.create_submission_and_trigger_state_change(proj.student) #, propagate = true, contributions = contributions, trigger = trigger) + task.assess status, tutor, complete_date + + if task.task_definition.is_graded? + task.grade_task rand(-1..3) end - puts "!" + + if task.for_definition_with_quality? + task.update(quality_pts: rand(0.. task.task_definition.max_quality_pts )) + end + + pdf_path = task.final_pdf_path + if pdf_path && !File.exist?(pdf_path) + FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-student-submission.pdf'), pdf_path) + end + + task.portfolio_evidence_path = pdf_path + task.save + end + + def self.generate_portfolio(project) + portfolio_tmp_dir = project.portfolio_temp_path + FileUtils.mkdir_p(portfolio_tmp_dir) + + lsr_path = File.join(portfolio_tmp_dir, "000-document-LearningSummaryReport.pdf") + FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-learning-summary.pdf'), lsr_path) unless File.exist? lsr_path + project.compile_portfolio = true + project.create_portfolio + end + + private + + # Output + def echo *args + print(*args) if @echo + end + + def echo_line *args + puts(*args) if @echo end # # Generates tasks for the given unit # def generate_tasks_for_unit(unit, unit_details) - print "----> Generating #{unit_details[:num_tasks]} tasks" + echo "----> Generating #{unit_details[:num_tasks]} tasks" + + if File.exist? Rails.root.join('test_files',"#{unit.code}-Tasks.csv") + unit.import_tasks_from_csv File.open(Rails.root.join('test_files',"#{unit.code}-Tasks.csv")) + unit.import_task_files_from_zip Rails.root.join('test_files',"#{unit.code}-Tasks.zip") + return + end + unit_details[:num_tasks].times do |count| up_reqs = [] - rand(1..4).times.each_with_index do | file, idx | - up_reqs[idx] = { :key => "file#{idx}", :name => Populator.words(1..3).capitalize, :type => ["code", "document", "image"].sample } + Faker::Number.between(from: 1, to: 4).times.each_with_index do | file, idx | + up_reqs[idx] = {:key => "file#{idx}", :name => faker_random_sentence(1, 3).capitalize, :type => ["code", "document", "image"].sample } end target_date = unit.start_date + ((count + 1) % 12).weeks # Assignment 6 due week 6, etc. - start_date = target_date - rand(1.0..2.0).weeks + start_date = target_date - Faker::Number.between(from: 1.0, to: 2.0).weeks # Make sure at least 30% of the tasks are pass - target_grade = @task_def_cache.length > (unit_details[:num_tasks] / 3) ? rand(0..3) : 0 + target_grade = Faker::Number.between(from: 0, to: 3) task_def = TaskDefinition.create( name: "Assignment #{count + 1}", abbreviation: "A#{count + 1}", unit_id: unit.id, - description: Populator.words(5..10), - weighting: BigDecimal.new("2"), + description: faker_random_sentence(5, 10), + weighting: BigDecimal("2"), target_date: target_date, upload_requirements: up_reqs.to_json, start_date: start_date, target_grade: target_grade ) - @task_def_cache[task_def.id] = task_def - print "." + echo "." end - puts "!" + echo_line "!" end # # Generates ILOs and aligns ILOs to tasks for unit # def generate_and_align_ilos_for_unit(unit, unit_details) - if @task_def_cache.empty? - throw "Task definition cache is empty. Call generate_tasks_for_unit unit_key, first before calling generate_and_align_ilos_for_unit" + # Create the ILOs + echo "----> Adding #{unit_details[:ilos]} ILOs" + + if File.exist? Rails.root.join('test_files',"#{unit.code}-Outcomes.csv") + unit.import_outcomes_from_csv File.open(Rails.root.join('test_files',"#{unit.code}-Outcomes.csv")) + unit.import_task_alignment_from_csv File.open(Rails.root.join('test_files',"#{unit.code}-Alignment.csv")), nil + return end - # Create the ILOs - print "----> Adding #{unit_details[:ilos]} ILOs" ilo_cache = {} unit_details[:ilos].times do |index| ilo_number = index + 1 @@ -345,17 +557,17 @@ def generate_and_align_ilos_for_unit(unit, unit_details) unit_id: unit.id, ilo_number: ilo_number, abbreviation: "ILO#{ilo_number}", - name: Populator.words(1..4).capitalize, - description: Populator.words(10..15) + name: faker_random_sentence(1, 4).capitalize, + description: faker_random_sentence(10, 15) ) ilo_cache[ilo.id] = ilo - print "." + echo "." end - puts "!" + echo_line "!" # Align each of the ILOs to a task if unit_details[:ilos] > 0 - print "----> Aligning tasks to ILOs" + echo "----> Aligning tasks to ILOs" 20.times do ilo_id = unit.learning_outcomes.pluck('id').sample task_def_id = unit.task_definition_ids.sample @@ -364,67 +576,12 @@ def generate_and_align_ilos_for_unit(unit, unit_details) learning_outcome_id: ilo_id, task_id: nil ) - link.rating = rand(1..4) - link.description = Populator.words(5..10) + link.rating = Faker::Number.between(from: 1, to: 4) + link.description = faker_random_sentence(5, 10) link.save! - print '.' - end - puts '!' - end - end - - # - # Generates tutorials for unit and enrols some students in them - # - def generate_tutorials_and_enrol_students_for_unit(unit, unit_details) - student_count = 0 - tutorial_count = 0 - - # Grab stuff from scale - max_tutorials = @scale[:max_tutorials] - min_students = @scale[:min_students] - delta_students = @scale[:delta_students] - - # Collection of weekdays to be used - weekdays = %w[Monday Tuesday Wednesday Thursday Friday] - - # Create tutorials and enrol students - unit_details[:tutors].each do | user_details | - # only up to 4 tutorials for small scale - if tutorial_count > max_tutorials then break end - - tutor = @user_cache[user_details[:user]] - puts "----> Enrolling tutor #{tutor.name} with #{user_details[:num]} tutorials" - tutor_unit_role = unit.employ_staff(tutor, Role.tutor) - - user_details[:num].times do | count | - tutorial_count += 1 - #day, time, location, tutor_username, abbrev - tutorial = unit.add_tutorial( - "#{weekdays.sample}", - "#{8 + rand(12)}:#{['00', '30'].sample}", # Mon-Fri 8am-7:30pm - "#{['EN', 'BA'].sample}#{rand(7)}#{rand(1)}#{rand(9)}", # EN###/BA### - tutor, - "LA1-#{tutorial_count.to_s.rjust(2, '0')}" - ) - - # Add a random number of students to the tutorial - num_students_in_tutorial = (min_students + rand(delta_students)) - print "-----> Creating #{num_students_in_tutorial} projects under tutorial #{tutorial.abbreviation}" - num_students_in_tutorial.times do - student = find_or_create_student("student_#{student_count}") - project = unit.enrol_student(student, tutorial.id) - student_count += 1 - print '.' - end - # Add fixed students to first tutorial - if count == 0 - unit_details[:students].each do | student_key | - unit.enrol_student(@user_cache[student_key], tutorial.id) - end - end - puts "!" + echo '.' end + echo_line '!' end end end diff --git a/lib/helpers/faker_randomiser.rb b/lib/helpers/faker_randomiser.rb new file mode 100644 index 000000000..439b12300 --- /dev/null +++ b/lib/helpers/faker_randomiser.rb @@ -0,0 +1,4 @@ +# Generate fake sentences with minimum and maximum number of words, with an optional period in the end. +def faker_random_sentence(min_words=0, max_words=1, period=false) + Faker::Lorem.words(number: Faker::Number.between(from: min_words, to: max_words)).join(' ') + (period ? '.' : '') +end \ No newline at end of file diff --git a/lib/helpers/find_or_create_students.rb b/lib/helpers/find_or_create_students.rb index d00ddb59c..54a4f104d 100644 --- a/lib/helpers/find_or_create_students.rb +++ b/lib/helpers/find_or_create_students.rb @@ -4,7 +4,7 @@ def find_or_create_student(username) user_created = nil using_cache = !@user_cache.nil? - if !using_cache || !@user_cache.key?(username) + if using_cache && !@user_cache.key?(username) profile = { first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, @@ -19,6 +19,8 @@ def find_or_create_student(username) end user_created = User.create!(profile) @user_cache[username] = user_created if using_cache + else + user_created = User.find_by_username(username) end user_created || @user_cache[username] end diff --git a/lib/shell/check_plagiarism.sh b/lib/shell/check_plagiarism.sh new file mode 100755 index 000000000..43847f2f8 --- /dev/null +++ b/lib/shell/check_plagiarism.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +#Get path to script +APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'` +APP_PATH=`cd "$APP_PATH"; pwd` + +ROOT_PATH=`cd "$APP_PATH"/../..; pwd` + +cd "$ROOT_PATH" + +bundle exec rake submission:check_plagiarism diff --git a/lib/shell/generate_pdfs.sh b/lib/shell/generate_pdfs.sh new file mode 100755 index 000000000..348f673ae --- /dev/null +++ b/lib/shell/generate_pdfs.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +#Get path to script +APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'` +APP_PATH=`cd "$APP_PATH"; pwd` + +ROOT_PATH=`cd "$APP_PATH"/../..; pwd` + +cd "$ROOT_PATH" +bundle exec rake submission:generate_pdfs +bundle exec rake maintenance:cleanup + +#Delete tmp files that may not be cleaned up by image magick and ghostscript +find /tmp -maxdepth 1 -name magick* -type f -delete +find /tmp -maxdepth 1 -name gs_* -type f -delete diff --git a/lib/shell/send_weekly_emails.sh b/lib/shell/send_weekly_emails.sh new file mode 100755 index 000000000..5237ccd3b --- /dev/null +++ b/lib/shell/send_weekly_emails.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +#Get path to script +APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'` +APP_PATH=`cd "$APP_PATH"; pwd` + +ROOT_PATH=`cd "$APP_PATH"/../..; pwd` + +cd "$ROOT_PATH" + +bundle exec rake mailer:send_status_emails diff --git a/lib/shell/sync_enrolments.sh b/lib/shell/sync_enrolments.sh new file mode 100755 index 000000000..696914f1b --- /dev/null +++ b/lib/shell/sync_enrolments.sh @@ -0,0 +1,9 @@ +#Get path to script +APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'` +APP_PATH=`cd "$APP_PATH"; pwd` + +ROOT_PATH=`cd "$APP_PATH"/../..; pwd` + +cd "$ROOT_PATH" + +bundle exec rake db:sync_enrolments diff --git a/lib/tasks/checks.rake b/lib/tasks/checks.rake index dea834a70..a4737d749 100644 --- a/lib/tasks/checks.rake +++ b/lib/tasks/checks.rake @@ -8,25 +8,25 @@ namespace :submission do # # Returns the file that indicates if this rake process is already executing... # - def rake_executing_marker_file + def rake_plagiarism_executing_marker_file File.join(Doubtfire::Application.config.student_work_dir, 'rake.plagiarism.running') end - def is_executing? - tmp_file = rake_executing_marker_file + def is_executing_plagiarism? + tmp_file = rake_plagiarism_executing_marker_file File.exist?(tmp_file) end - def start_executing - FileUtils.touch(rake_executing_marker_file) + def start_executing_plagiarism + FileUtils.touch(rake_plagiarism_executing_marker_file) end - def end_executing - FileUtils.rm(rake_executing_marker_file) + def end_executing_plagiarism + FileUtils.rm(rake_plagiarism_executing_marker_file) end task :simulate_plagiarism, [:num_links] => [:skip_prod, :environment] do |t, args| - if is_executing? + if is_executing_plagiarism? puts 'Skip plagiarism check -- already executing' logger.info 'Skip plagiarism check -- already executing' else @@ -56,11 +56,11 @@ namespace :submission do end task check_plagiarism: :environment do - if is_executing? + if is_executing_plagiarism? puts 'Skip plagiarism check -- already executing' logger.info 'Skip plagiarism check -- already executing' else - start_executing + start_executing_plagiarism begin logger.info 'Starting plagiarism check' @@ -78,7 +78,7 @@ namespace :submission do puts 'Failed with error' puts e.message.to_s ensure - end_executing + end_executing_plagiarism end end end diff --git a/lib/tasks/compress_pdfs.rake b/lib/tasks/compress_pdfs.rake index 60afa81c9..53edda796 100644 --- a/lib/tasks/compress_pdfs.rake +++ b/lib/tasks/compress_pdfs.rake @@ -11,9 +11,9 @@ namespace :submission do Unit.where('active').each do |u| u.tasks.where('portfolio_evidence is not NULL').each do |t| - if File.exist?(t.portfolio_evidence) && File.size?(t.portfolio_evidence) >= 2_200_000 - puts "Compressing #{t.portfolio_evidence}" - FileHelper.compress_pdf(t.portfolio_evidence) + if File.exist?(t.portfolio_evidence_path) && File.size?(t.portfolio_evidence_path) >= 2_200_000 + puts "Compressing #{t.portfolio_evidence_path}" + FileHelper.compress_pdf(t.portfolio_evidence_path) end end end @@ -21,19 +21,6 @@ namespace :submission do logger.info 'End compress pdf' end - task compress_done: :environment do - Unit.where('active').each do |u| - u.tasks.where('portfolio_evidence is not NULL').each do |t| - done_file = t.zip_file_path_for_done_task - puts "Checking #{done_file}" - next unless done_file && File.exist?(done_file) && File.size?(done_file) >= 2_200_000 - puts "Compressing #{t.portfolio_evidence}" - t.move_done_to_new - t.compress_new_to_done - end - end - end - task recreate_large_pdfs: :environment do if is_executing? puts 'Skip recreate large pdfs -- already executing' @@ -46,7 +33,7 @@ namespace :submission do u.tasks.where('portfolio_evidence is not NULL').each do |t| pdf_file = t.final_pdf_path next unless pdf_file && File.exist?(pdf_file) && File.size?(pdf_file) >= 2_200_000 - puts " Recreating #{t.portfolio_evidence} was #{File.size?(pdf_file)}" + puts " Recreating #{t.portfolio_evidence_path} was #{File.size?(pdf_file)}" t.move_done_to_new t.convert_submission_to_pdf puts " ... now #{File.size?(pdf_file)}" diff --git a/lib/tasks/demo.rake b/lib/tasks/demo.rake deleted file mode 100644 index 879d3d6c6..000000000 --- a/lib/tasks/demo.rake +++ /dev/null @@ -1,41 +0,0 @@ -namespace :db do - desc 'Initialise the app with an empty database and only minimal users (the superuser)' - task demo: [:skip_prod, :environment] do - require 'populator' - require 'faker' - require 'bcrypt' - - # Clear the database - [Unit, Project, TaskDefinition, Task, TaskStatus, Tutorial, UnitRole, User, ProjectConvenor].each(&:delete_all) - - TaskStatus.create(name: 'Not Started', description: 'You have not yet started this task.') - TaskStatus.create(name: 'Complete', description: 'This task has been signed off by your tutor.') - TaskStatus.create(name: 'Need Help', description: 'Some help is required in order to complete this task.') - TaskStatus.create(name: 'Working On It', description: 'This task is currently being worked on.') - TaskStatus.create(name: 'Fix and Resubmit', description: 'This task must be resubmitted after fixing some issues.') - TaskStatus.create(name: 'Do Not Resubmit', description: 'This task must be fixed and included in your portfolio, but should not be resubmitted.') - TaskStatus.create(name: 'Redo', description: 'This task needs to be redone.') - TaskStatus.create(name: 'Discuss', description: 'Your work looks good, discuss it with your tutor to complete.') - TaskStatus.create(name: 'Ready to Mark', description: 'This task is ready for the tutor to assess to provide feedback.') - TaskStatus.create(name: 'Demonstrate', description: 'Your work looks good, demonstrate it to your tutor to complete.') - TaskStatus.create(name: 'Fail', description: 'You did not successfully demonstrate the required learning in this task.') - - admins = { - admin: { first: 'Admin', last: 'Admin', nickname: 'Superuser' } - } - - admins.each do |username, info| - # Create superuser - User.populate(1) do |superuser| - superuser.username = username.to_s - superuser.nickname = info[:nickname] - superuser.email = "#{username}@swin.edu.au" - superuser.encrypted_password = BCrypt::Password.create('demopassword') - superuser.first_name = info[:first] - superuser.last_name = info[:last] - superuser.sign_in_count = 0 - superuser.system_role = 'superuser' - end - end - end -end diff --git a/lib/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake index cae592d4c..b2e77dc1b 100644 --- a/lib/tasks/generate_pdfs.rake +++ b/lib/tasks/generate_pdfs.rake @@ -9,12 +9,28 @@ namespace :submission do end def is_executing? - tmp_file = rake_executing_marker_file - File.exist?(tmp_file) + pid_file = rake_executing_marker_file + return false unless File.exist?(pid_file) + + # Check that the pid matches something running... + begin + pid = File.read(pid_file).to_i + raise Errno::ESRCH if pid == 0 + Process.getpgid( pid ) + true + rescue Errno::ESRCH + # clean up old running file + end_executing + false + end end def start_executing - FileUtils.touch(rake_executing_marker_file) + pid_file = rake_executing_marker_file + FileUtils.touch(pid_file) + File.open pid_file, "w" do |f| + f.write Process.pid + end end def end_executing @@ -24,13 +40,16 @@ namespace :submission do task generate_pdfs: :environment do if is_executing? logger.error 'Skip generate pdf -- already executing' + puts 'Skip generate pdf -- already executing' else start_executing + my_source = PortfolioEvidence.move_to_pid_folder + end_executing begin - logger.info 'Starting generate pdf' + logger.info "Starting generate pdf - #{Process.pid}" - PortfolioEvidence.process_new_to_pdf + PortfolioEvidence.process_new_to_pdf(my_source) projects_to_compile = Project.where(compile_portfolio: true) projects_to_compile.each do |project| @@ -52,8 +71,10 @@ namespace :submission do end end ensure - logger.info 'Ending generate pdf' - end_executing + logger.info "Ending generate pdf - #{Process.pid}" + if Dir.entries(my_source).count == 2 # . and .. + FileUtils.rmdir my_source + end end end end @@ -71,8 +92,8 @@ namespace :submission do Unit.where('active').each do |u| u.tasks.where('portfolio_evidence is not NULL').each do |t| - unless FileHelper.pdf_valid?(t.portfolio_evidence) - puts t.portfolio_evidence + unless FileHelper.pdf_valid?(t.portfolio_evidence_path) + puts t.portfolio_evidence_path end end end diff --git a/lib/tasks/init.rake b/lib/tasks/init.rake index 0376fbcd4..98cd909ab 100644 --- a/lib/tasks/init.rake +++ b/lib/tasks/init.rake @@ -1,8 +1,80 @@ -require_all 'lib/helpers' namespace :db do + + # + # Generate roles + # + def generate_user_roles + return if Role.count > 0 + puts "-> Generating user roles" + roles = [ + { name: 'Student', description: "Students are able to be enrolled into units, and to submit progress for their unit projects." }, + { name: 'Tutor', description: "Tutors are able to supervise tutorial classes and provide feedback to students, they may also be students in other units" }, + { name: 'Convenor', description: "Convenors are able to create and manage units, as well as act as tutors and students." }, + { name: 'Admin', description: "Admin are able to create convenors, and act as convenors, tutors, and students in units." } + ] + + roles.each do |role| + Role.create!(name: role[:name], description: role[:description]) + print "." + end + puts "!" + end + + # + # Generate tasks statuses + # + def generate_task_statuses + return if TaskStatus.db_count > 0 + puts "-> Generating task statuses" + statuses = { + "Not Started": "You have not yet started this task.", + "Complete": "This task has been signed off by your tutor.", + "Need Help": "Some help is required in order to complete this task.", + "Working On It": "This task is currently being worked on.", + "Fix and Resubmit": "This task must be resubmitted after fixing some issues.", + "Feedback Exceeded": "This task must be fixed and included in your portfolio, but no additional feedback will be provided.", + "Redo": "This task needs to be redone.", + "Discuss": "Your work looks good, discuss it with your tutor to complete.", + "Ready for Feedback": "This task is ready for the tutor to assess to provide feedback.", + "Demonstrate": "Your work looks good, demonstrate it to your tutor to complete.", + "Fail": "You did not successfully demonstrate the required learning in this task.", + "Time Exceeded": "You did not submit or complete the task before the appropriate deadline." + } + statuses.each do | name, desc | + print "." + TaskStatus.create(name: name, description: desc) + end + puts "!" + end + + desc 'Initialise the app with an empty database and only minimal users (the superuser)' - task init: [:skip_prod, :drop, :setup, :environment] do - dbpop = DatabasePopulator.new ENV['SCALE'] - dbpop.generate_admin + task init: [:environment] do + generate_user_roles + generate_task_statuses + + if User.count == 0 + puts "Creating admin user" + username = :aadmin + profile = { + email: "#{username}@doubtfire.com", + username: username, + login_id: username, + first_name: 'Admin', + last_name: 'Admin', + nickname: 'Admin', + role_id: Role.admin_id + } + profile[:email] ||= + profile[:username] ||= username + profile[:login_id] ||= username + + if AuthenticationHelpers.db_auth? + profile[:password] = 'password' + profile[:password_confirmation] = 'password' + end + + user = User.create!(profile) + end end end diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake new file mode 100644 index 000000000..4ffbb7730 --- /dev/null +++ b/lib/tasks/maintenance.rake @@ -0,0 +1,38 @@ +require_all 'lib/helpers' + +namespace :maintenance do + desc 'Cleanup temporary files' + task cleanup: [:environment] do + path = FileHelper.tmp_file_dir + + if Rails.env.development? + time_offset = 1.minute + else + time_offset = 3.hours + end + + Dir.foreach(path) do |item| + fname = "#{path}#{item}" + next if File.directory?(fname) + if File.mtime(fname) < DateTime.now - time_offset + begin + File.delete(fname) + rescue + puts "Failed to remove temporary file: #{fname}" + end + end + end + + AuthToken.destroy_old_tokens + end + + desc 'Export auth tokens for migration from 5.x to 6.x' + task export_auth_tokens: [:environment] do + User.all. + map { |u| { token: u.auth_token, user: u.id, expiry: u.auth_token_expiry } }. + select { |d| d[:token].present? }. + each do |d| + puts "AuthToken.create!(authentication_token: '#{d[:token].strip}', auth_token_expiry: DateTime.parse('#{d[:expiry]}'), user_id: '#{d[:user]}')" + end + end +end diff --git a/lib/tasks/populate.rake b/lib/tasks/populate.rake index 9bb2c4c88..bcdcc0644 100644 --- a/lib/tasks/populate.rake +++ b/lib/tasks/populate.rake @@ -7,42 +7,11 @@ namespace :db do tutes = unit.tutorials for student_count in 0..2000 student = find_or_create_student("student_#{student_count}") - proj = unit.enrol_student(student, tutes[student_count % tutes.count]) + tute = tutes[student_count % tutes.count] + proj = unit.enrol_student(student, tute.campus, tute) end end - def assess_task(current_user, task, tutor, status, complete_date) - alignments = [] - sum_ratings = 0 - task.unit.learning_outcomes.each do |lo| - data = { - ilo_id: lo.id, - rating: rand(0..5), - rationale: "Simulated rationale text..." - } - sum_ratings += data[:rating] - alignments << data - end - - if task.group_task? - raise "Cant support group tasks yet in simulation :(" - end - contributions = nil - trigger = - - task.create_alignments_from_submission(current_user, alignments) unless alignments.nil? - task.create_submission_and_trigger_state_change(current_user) #, propagate = true, contributions = contributions, trigger = trigger) - task.assess status, tutor, complete_date - - pdf_path = task.final_pdf_path - if pdf_path - FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-student-submission.pdf'), pdf_path) - end - - task.portfolio_evidence = pdf_path - task.save - end - desc 'Mark off some of the due tasks' task simulate_signoff: [:skip_prod, :environment] do Unit.all.each do |unit| @@ -56,10 +25,7 @@ namespace :db do p.tasks.destroy_all p.remove_portfolio - # Determine who is assessing their work... - tutor = p.main_tutor - - p.target_grade = rand(0..3) + p.target_grade = rand(GradeHelper::RANGE) case rand(1..100) when 0..5 @@ -98,6 +64,7 @@ namespace :db do i = 0 assigned_task_defs.order('target_date').each do |at| task = p.task_for_task_definition(at) + tutor = p.tutor_for(at) # if its more than three week past kept up to date... if kept_up_to_date >= task.target_date + 2.weeks complete_date = unit.start_date + i * time_to_complete_task + rand(7..14).days @@ -106,7 +73,7 @@ namespace :db do elsif complete_date > Time.zone.now complete_date = Time.zone.now end - assess_task(proj, task, tutor, TaskStatus.complete, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.complete, complete_date) elsif kept_up_to_date >= task.target_date + 1.week complete_date = unit.start_date + i * time_to_complete_task + rand(7..14).days if complete_date < unit.start_date + 1.week @@ -118,21 +85,21 @@ namespace :db do # 1 to 3 case rand(1..100) when 0..50 - assess_task(proj, task, tutor, TaskStatus.complete, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.complete, complete_date) when 51..75 - assess_task(proj, task, tutor, TaskStatus.discuss, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.discuss, complete_date) when 76..90 - assess_task(proj, task, tutor, TaskStatus.demonstrate, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.demonstrate, complete_date) when 91..95 - assess_task(proj, task, tutor, TaskStatus.fix_and_resubmit, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.fix_and_resubmit, complete_date) when 96..97 - assess_task(proj, task, tutor, TaskStatus.working_on_it, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.working_on_it, complete_date) when 97 - assess_task(proj, task, tutor, TaskStatus.do_not_resubmit, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.do_not_resubmit, complete_date) when 98..99 - assess_task(proj, task, tutor, TaskStatus.redo, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.redo, complete_date) else - assess_task(proj, task, tutor, TaskStatus.ready_to_mark, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.ready_for_feedback, complete_date) end else complete_date = unit.start_date + i * time_to_complete_task + rand(7..10).days @@ -145,24 +112,36 @@ namespace :db do # 1 to 3 case rand(1..100) when 0..3 - assess_task(proj, task, tutor, TaskStatus.complete, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.complete, complete_date) when 4..60 - assess_task(proj, task, tutor, TaskStatus.ready_to_mark, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.ready_for_feedback, complete_date) when 61..70 - assess_task(proj, task, tutor, TaskStatus.discuss, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.discuss, complete_date) when 71..80 - assess_task(proj, task, tutor, TaskStatus.demonstrate, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.demonstrate, complete_date) when 81..90 - assess_task(proj, task, tutor, TaskStatus.fix_and_resubmit, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.fix_and_resubmit, complete_date) when 91..98 - assess_task(proj, task, tutor, TaskStatus.working_on_it, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.working_on_it, complete_date) when 99 - assess_task(proj, task, tutor, TaskStatus.redo, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.redo, complete_date) else - assess_task(proj, task, tutor, TaskStatus.ready_to_mark, complete_date) + DatabasePopulator.assess_task(proj, task, tutor, TaskStatus.ready_for_feedback, complete_date) end end + if rand(1..100) < 20 + c = task.add_text_comment(p.student, "Test comment text") + c.created_at = complete_date + c.save + end + + if rand(1..100) < 20 + c = task.add_text_comment(tutor, "Looks good") + c.created_at = complete_date + c.save + end + i += 1 end @@ -170,13 +149,15 @@ namespace :db do next_assigned_tasks.each do |at| task = p.task_for_task_definition(at) + tutor = p.tutor_for(at) + # 1 to 3 case rand(1..100) when 0..60 task.assess tatus.working_on_it, tutor, Time.zone.now when 60..75 task.assess TaskStatus.need_help, tutor, Time.zone.now - + pdf_path = task.final_pdf_path if pdf_path FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-student-submission.pdf'), pdf_path) @@ -185,13 +166,7 @@ namespace :db do end if rand(0..99) > 70 - portfolio_tmp_dir = p.portfolio_temp_path - FileUtils.mkdir_p(portfolio_tmp_dir) - - lsr_path = File.join(portfolio_tmp_dir, "000-document-LearningSummaryReport.pdf") - FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-learning-summary.pdf'), lsr_path) unless File.exists? lsr_path - p.compile_portfolio = true - p.create_portfolio + DatabasePopulator.generate_portfolio p end p.save @@ -200,23 +175,14 @@ namespace :db do end desc 'Clear the database and fill with test data' - task populate: [:skip_prod, :setup, :migrate] do + task populate: [:skip_prod, :drop, :setup, :migrate, :init] do scale = ENV['SCALE'] ? ENV['SCALE'].to_sym : :small extended = ENV['EXTENDED'] == 'true' dbpop = DatabasePopulator.new scale - dbpop.generate_fixed_data dbpop.generate_users dbpop.generate_units - # Run simulate signoff? - unless extended - puts '-> Would you like to simulate student progress? This may take a while... [y/n]' - end - if extended || STDIN.gets.chomp.casecmp('y').zero? - puts '-> Simulating signoff...' - Rake::Task['db:simulate_signoff'].execute - end puts '-> Done.' end end diff --git a/lib/tasks/register_q_assessment_results_subscriber.rake b/lib/tasks/register_q_assessment_results_subscriber.rake new file mode 100644 index 000000000..b244b49b4 --- /dev/null +++ b/lib/tasks/register_q_assessment_results_subscriber.rake @@ -0,0 +1,18 @@ +# lib/tasks/register_subscribers.rake +# See: http://nithinbekal.com/posts/safe-rake-tasks/ + +require_relative '../assets/ontrack_receive_action.rb' + +desc 'Start listening for responses from Overseer, and update associated tasks' +task register_q_assessment_results_subscriber: [:environment] do + sm_instance = Doubtfire::Application.config.sm_instance + + if sm_instance.nil? + puts "ServiceManager is not initialised yet." + return + end + + sm_instance.clients[:ontrack].action = method(:receive) + sm_instance.clients[:ontrack].start_subscriber + puts "Bye!" +end diff --git a/lib/tasks/skip_prod.rake b/lib/tasks/skip_prod.rake index 48c8079f9..e1c09410a 100644 --- a/lib/tasks/skip_prod.rake +++ b/lib/tasks/skip_prod.rake @@ -3,9 +3,14 @@ desc 'Raises exception if used in production' task skip_prod: [:environment] do - raise 'You cannot run this in production' if Rails.env.production? + if Rails.env.production? + puts "Are you sure you want to run this on production? (Yes to confirm): " + response = STDIN.gets.chomp + + raise 'You chose not to run this in production' unless response == 'Yes' + end end ['db:drop', 'db:reset', 'db:seed'].each do |t| Rake::Task[t].enhance ['skip_prod'] -end \ No newline at end of file +end diff --git a/lib/tasks/sync.rake b/lib/tasks/sync.rake new file mode 100644 index 000000000..d1e03fa14 --- /dev/null +++ b/lib/tasks/sync.rake @@ -0,0 +1,13 @@ +require_all 'lib/helpers' + +namespace :db do + desc 'Synchronise enrolments in the active units within the current teaching period' + task sync_enrolments: [:environment] do + TeachingPeriod.where('? >= start_date', Time.zone.now + 2.weeks).where('? <= end_date', Time.zone.now).each do |tp| + tp.units.each do |unit| + unit.sync_enrolments + sleep(1) + end + end + end +end diff --git a/overseer_example.env b/overseer_example.env new file mode 100644 index 000000000..a15771b2d --- /dev/null +++ b/overseer_example.env @@ -0,0 +1,11 @@ +# Copy and rename the copy of this file to `.env`. + +# Set the next key to true to enable overseer on frontend and backend. +OVERSEER_ENABLED=false +RABBITMQ_HOSTNAME=1.2.3.4 +RABBITMQ_USERNAME=secure_credentials +RABBITMQ_PASSWORD=secure_credentials +EXCHANGE_NAME=rabbit_exchange_name +DURABLE_QUEUE_NAME=queue_name +BINDING_KEYS=queue_binding_keys_name_seperated_by_comma +DEFAULT_BINDING_KEY=default_binding_key_name diff --git a/public/404.html b/public/404.html deleted file mode 100644 index 9a48320a5..000000000 --- a/public/404.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - The page you were looking for doesn't exist (404) - - - - - -
    -

    The page you were looking for doesn't exist.

    -

    You may have mistyped the address or the page may have moved.

    -
    - - diff --git a/public/422.html b/public/422.html deleted file mode 100644 index 83660ab18..000000000 --- a/public/422.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - The change you wanted was rejected (422) - - - - - -
    -

    The change you wanted was rejected.

    -

    Maybe you tried to change something you didn't have access to.

    -
    - - diff --git a/public/500.html b/public/500.html deleted file mode 100644 index f3648a0db..000000000 --- a/public/500.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - We're sorry, but something went wrong (500) - - - - - -
    -

    We're sorry, but something went wrong.

    -
    - - diff --git a/public/assets/fonts/bootstrap/glyphicons-halflings-regular.eot b/public/_-fonts-glyphicons-halflings-regular.eot similarity index 100% rename from public/assets/fonts/bootstrap/glyphicons-halflings-regular.eot rename to public/_-fonts-glyphicons-halflings-regular.eot diff --git a/public/assets/fonts/bootstrap/glyphicons-halflings-regular.svg b/public/_-fonts-glyphicons-halflings-regular.svg similarity index 100% rename from public/assets/fonts/bootstrap/glyphicons-halflings-regular.svg rename to public/_-fonts-glyphicons-halflings-regular.svg diff --git a/public/assets/fonts/bootstrap/glyphicons-halflings-regular.ttf b/public/_-fonts-glyphicons-halflings-regular.ttf similarity index 100% rename from public/assets/fonts/bootstrap/glyphicons-halflings-regular.ttf rename to public/_-fonts-glyphicons-halflings-regular.ttf diff --git a/public/assets/fonts/bootstrap/glyphicons-halflings-regular.woff b/public/_-fonts-glyphicons-halflings-regular.woff similarity index 100% rename from public/assets/fonts/bootstrap/glyphicons-halflings-regular.woff rename to public/_-fonts-glyphicons-halflings-regular.woff diff --git a/public/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2 b/public/_-fonts-glyphicons-halflings-regular.woff2 similarity index 100% rename from public/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2 rename to public/_-fonts-glyphicons-halflings-regular.woff2 diff --git a/public/api/docs/css/print.css b/public/api/docs/css/print.css deleted file mode 100755 index d4c1e7045..000000000 --- a/public/api/docs/css/print.css +++ /dev/null @@ -1,1362 +0,0 @@ -/* Original style from softwaremaniacs.org (c) Ivan Sagalaev */ -.swagger-section pre code { - display: block; - padding: 0.5em; - background: #F0F0F0; -} -.swagger-section pre code, -.swagger-section pre .subst, -.swagger-section pre .tag .title, -.swagger-section pre .lisp .title, -.swagger-section pre .clojure .built_in, -.swagger-section pre .nginx .title { - color: black; -} -.swagger-section pre .string, -.swagger-section pre .title, -.swagger-section pre .constant, -.swagger-section pre .parent, -.swagger-section pre .tag .value, -.swagger-section pre .rules .value, -.swagger-section pre .rules .value .number, -.swagger-section pre .preprocessor, -.swagger-section pre .ruby .symbol, -.swagger-section pre .ruby .symbol .string, -.swagger-section pre .aggregate, -.swagger-section pre .template_tag, -.swagger-section pre .django .variable, -.swagger-section pre .smalltalk .class, -.swagger-section pre .addition, -.swagger-section pre .flow, -.swagger-section pre .stream, -.swagger-section pre .bash .variable, -.swagger-section pre .apache .tag, -.swagger-section pre .apache .cbracket, -.swagger-section pre .tex .command, -.swagger-section pre .tex .special, -.swagger-section pre .erlang_repl .function_or_atom, -.swagger-section pre .markdown .header { - color: #800; -} -.swagger-section pre .comment, -.swagger-section pre .annotation, -.swagger-section pre .template_comment, -.swagger-section pre .diff .header, -.swagger-section pre .chunk, -.swagger-section pre .markdown .blockquote { - color: #888; -} -.swagger-section pre .number, -.swagger-section pre .date, -.swagger-section pre .regexp, -.swagger-section pre .literal, -.swagger-section pre .smalltalk .symbol, -.swagger-section pre .smalltalk .char, -.swagger-section pre .go .constant, -.swagger-section pre .change, -.swagger-section pre .markdown .bullet, -.swagger-section pre .markdown .link_url { - color: #080; -} -.swagger-section pre .label, -.swagger-section pre .javadoc, -.swagger-section pre .ruby .string, -.swagger-section pre .decorator, -.swagger-section pre .filter .argument, -.swagger-section pre .localvars, -.swagger-section pre .array, -.swagger-section pre .attr_selector, -.swagger-section pre .important, -.swagger-section pre .pseudo, -.swagger-section pre .pi, -.swagger-section pre .doctype, -.swagger-section pre .deletion, -.swagger-section pre .envvar, -.swagger-section pre .shebang, -.swagger-section pre .apache .sqbracket, -.swagger-section pre .nginx .built_in, -.swagger-section pre .tex .formula, -.swagger-section pre .erlang_repl .reserved, -.swagger-section pre .prompt, -.swagger-section pre .markdown .link_label, -.swagger-section pre .vhdl .attribute, -.swagger-section pre .clojure .attribute, -.swagger-section pre .coffeescript .property { - color: #88F; -} -.swagger-section pre .keyword, -.swagger-section pre .id, -.swagger-section pre .phpdoc, -.swagger-section pre .title, -.swagger-section pre .built_in, -.swagger-section pre .aggregate, -.swagger-section pre .css .tag, -.swagger-section pre .javadoctag, -.swagger-section pre .phpdoc, -.swagger-section pre .yardoctag, -.swagger-section pre .smalltalk .class, -.swagger-section pre .winutils, -.swagger-section pre .bash .variable, -.swagger-section pre .apache .tag, -.swagger-section pre .go .typename, -.swagger-section pre .tex .command, -.swagger-section pre .markdown .strong, -.swagger-section pre .request, -.swagger-section pre .status { - font-weight: bold; -} -.swagger-section pre .markdown .emphasis { - font-style: italic; -} -.swagger-section pre .nginx .built_in { - font-weight: normal; -} -.swagger-section pre .coffeescript .javascript, -.swagger-section pre .javascript .xml, -.swagger-section pre .tex .formula, -.swagger-section pre .xml .javascript, -.swagger-section pre .xml .vbscript, -.swagger-section pre .xml .css, -.swagger-section pre .xml .cdata { - opacity: 0.5; -} -.swagger-section .hljs { - display: block; - overflow-x: auto; - padding: 0.5em; - background: #F0F0F0; -} -.swagger-section .hljs, -.swagger-section .hljs-subst { - color: #444; -} -.swagger-section .hljs-keyword, -.swagger-section .hljs-attribute, -.swagger-section .hljs-selector-tag, -.swagger-section .hljs-meta-keyword, -.swagger-section .hljs-doctag, -.swagger-section .hljs-name { - font-weight: bold; -} -.swagger-section .hljs-built_in, -.swagger-section .hljs-literal, -.swagger-section .hljs-bullet, -.swagger-section .hljs-code, -.swagger-section .hljs-addition { - color: #1F811F; -} -.swagger-section .hljs-regexp, -.swagger-section .hljs-symbol, -.swagger-section .hljs-variable, -.swagger-section .hljs-template-variable, -.swagger-section .hljs-link, -.swagger-section .hljs-selector-attr, -.swagger-section .hljs-selector-pseudo { - color: #BC6060; -} -.swagger-section .hljs-type, -.swagger-section .hljs-string, -.swagger-section .hljs-number, -.swagger-section .hljs-selector-id, -.swagger-section .hljs-selector-class, -.swagger-section .hljs-quote, -.swagger-section .hljs-template-tag, -.swagger-section .hljs-deletion { - color: #880000; -} -.swagger-section .hljs-title, -.swagger-section .hljs-section { - color: #880000; - font-weight: bold; -} -.swagger-section .hljs-comment { - color: #888888; -} -.swagger-section .hljs-meta { - color: #2B6EA1; -} -.swagger-section .hljs-emphasis { - font-style: italic; -} -.swagger-section .hljs-strong { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap { - line-height: 1; - font-family: "Droid Sans", sans-serif; - min-width: 760px; - max-width: 960px; - margin-left: auto; - margin-right: auto; - /* JSONEditor specific styling */ -} -.swagger-section .swagger-ui-wrap b, -.swagger-section .swagger-ui-wrap strong { - font-family: "Droid Sans", sans-serif; - font-weight: bold; -} -.swagger-section .swagger-ui-wrap q, -.swagger-section .swagger-ui-wrap blockquote { - quotes: none; -} -.swagger-section .swagger-ui-wrap p { - line-height: 1.4em; - padding: 0 0 10px; - color: #333333; -} -.swagger-section .swagger-ui-wrap q:before, -.swagger-section .swagger-ui-wrap q:after, -.swagger-section .swagger-ui-wrap blockquote:before, -.swagger-section .swagger-ui-wrap blockquote:after { - content: none; -} -.swagger-section .swagger-ui-wrap .heading_with_menu h1, -.swagger-section .swagger-ui-wrap .heading_with_menu h2, -.swagger-section .swagger-ui-wrap .heading_with_menu h3, -.swagger-section .swagger-ui-wrap .heading_with_menu h4, -.swagger-section .swagger-ui-wrap .heading_with_menu h5, -.swagger-section .swagger-ui-wrap .heading_with_menu h6 { - display: block; - clear: none; - float: left; - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; - width: 60%; -} -.swagger-section .swagger-ui-wrap table { - border-collapse: collapse; - border-spacing: 0; -} -.swagger-section .swagger-ui-wrap table thead tr th { - padding: 5px; - font-size: 0.9em; - color: #666666; - border-bottom: 1px solid #999999; -} -.swagger-section .swagger-ui-wrap table tbody tr:last-child td { - border-bottom: none; -} -.swagger-section .swagger-ui-wrap table tbody tr.offset { - background-color: #f0f0f0; -} -.swagger-section .swagger-ui-wrap table tbody tr td { - padding: 6px; - font-size: 0.9em; - border-bottom: 1px solid #cccccc; - vertical-align: top; - line-height: 1.3em; -} -.swagger-section .swagger-ui-wrap ol { - margin: 0px 0 10px; - padding: 0 0 0 18px; - list-style-type: decimal; -} -.swagger-section .swagger-ui-wrap ol li { - padding: 5px 0px; - font-size: 0.9em; - color: #333333; -} -.swagger-section .swagger-ui-wrap ol, -.swagger-section .swagger-ui-wrap ul { - list-style: none; -} -.swagger-section .swagger-ui-wrap h1 a, -.swagger-section .swagger-ui-wrap h2 a, -.swagger-section .swagger-ui-wrap h3 a, -.swagger-section .swagger-ui-wrap h4 a, -.swagger-section .swagger-ui-wrap h5 a, -.swagger-section .swagger-ui-wrap h6 a { - text-decoration: none; -} -.swagger-section .swagger-ui-wrap h1 a:hover, -.swagger-section .swagger-ui-wrap h2 a:hover, -.swagger-section .swagger-ui-wrap h3 a:hover, -.swagger-section .swagger-ui-wrap h4 a:hover, -.swagger-section .swagger-ui-wrap h5 a:hover, -.swagger-section .swagger-ui-wrap h6 a:hover { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap h1 span.divider, -.swagger-section .swagger-ui-wrap h2 span.divider, -.swagger-section .swagger-ui-wrap h3 span.divider, -.swagger-section .swagger-ui-wrap h4 span.divider, -.swagger-section .swagger-ui-wrap h5 span.divider, -.swagger-section .swagger-ui-wrap h6 span.divider { - color: #aaaaaa; -} -.swagger-section .swagger-ui-wrap a { - color: #547f00; -} -.swagger-section .swagger-ui-wrap a img { - border: none; -} -.swagger-section .swagger-ui-wrap article, -.swagger-section .swagger-ui-wrap aside, -.swagger-section .swagger-ui-wrap details, -.swagger-section .swagger-ui-wrap figcaption, -.swagger-section .swagger-ui-wrap figure, -.swagger-section .swagger-ui-wrap footer, -.swagger-section .swagger-ui-wrap header, -.swagger-section .swagger-ui-wrap hgroup, -.swagger-section .swagger-ui-wrap menu, -.swagger-section .swagger-ui-wrap nav, -.swagger-section .swagger-ui-wrap section, -.swagger-section .swagger-ui-wrap summary { - display: block; -} -.swagger-section .swagger-ui-wrap pre { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - background-color: #fcf6db; - border: 1px solid #e5e0c6; - padding: 10px; -} -.swagger-section .swagger-ui-wrap pre code { - line-height: 1.6em; - background: none; -} -.swagger-section .swagger-ui-wrap .content > .content-type > div > label { - clear: both; - display: block; - color: #0F6AB4; - font-size: 1.1em; - margin: 0; - padding: 15px 0 5px; -} -.swagger-section .swagger-ui-wrap .content pre { - font-size: 12px; - margin-top: 5px; - padding: 5px; -} -.swagger-section .swagger-ui-wrap .icon-btn { - cursor: pointer; -} -.swagger-section .swagger-ui-wrap .info_title { - padding-bottom: 10px; - font-weight: bold; - font-size: 25px; -} -.swagger-section .swagger-ui-wrap .footer { - margin-top: 20px; -} -.swagger-section .swagger-ui-wrap p.big, -.swagger-section .swagger-ui-wrap div.big p { - font-size: 1em; - margin-bottom: 10px; -} -.swagger-section .swagger-ui-wrap form.fullwidth ol li.string input, -.swagger-section .swagger-ui-wrap form.fullwidth ol li.url input, -.swagger-section .swagger-ui-wrap form.fullwidth ol li.text textarea, -.swagger-section .swagger-ui-wrap form.fullwidth ol li.numeric input { - width: 500px !important; -} -.swagger-section .swagger-ui-wrap .info_license { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_tos { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .message-fail { - color: #cc0000; -} -.swagger-section .swagger-ui-wrap .info_url { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_email { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_name { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_description { - padding-bottom: 10px; - font-size: 15px; -} -.swagger-section .swagger-ui-wrap .markdown ol li, -.swagger-section .swagger-ui-wrap .markdown ul li { - padding: 3px 0px; - line-height: 1.4em; - color: #333333; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.string input, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.url input, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.numeric input { - display: block; - padding: 4px; - width: auto; - clear: both; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.string input.title, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.url input.title, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.numeric input.title { - font-size: 1.3em; -} -.swagger-section .swagger-ui-wrap table.fullwidth { - width: 100%; -} -.swagger-section .swagger-ui-wrap .model-signature { - font-family: "Droid Sans", sans-serif; - font-size: 1em; - line-height: 1.5em; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-nav a { - text-decoration: none; - color: #AAA; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-nav a:hover { - text-decoration: underline; - color: black; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-nav .selected { - color: black; - text-decoration: none; -} -.swagger-section .swagger-ui-wrap .model-signature .propType { - color: #5555aa; -} -.swagger-section .swagger-ui-wrap .model-signature pre:hover { - background-color: #ffffdd; -} -.swagger-section .swagger-ui-wrap .model-signature pre { - font-size: .85em; - line-height: 1.2em; - overflow: auto; - max-height: 200px; - cursor: pointer; -} -.swagger-section .swagger-ui-wrap .model-signature ul.signature-nav { - display: block; - min-width: 230px; - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap .model-signature ul.signature-nav li:last-child { - padding-right: 0; - border-right: none; -} -.swagger-section .swagger-ui-wrap .model-signature ul.signature-nav li { - float: left; - margin: 0 5px 5px 0; - padding: 2px 5px 2px 0; - border-right: 1px solid #ddd; -} -.swagger-section .swagger-ui-wrap .model-signature .propOpt { - color: #555; -} -.swagger-section .swagger-ui-wrap .model-signature .snippet small { - font-size: 0.75em; -} -.swagger-section .swagger-ui-wrap .model-signature .propOptKey { - font-style: italic; -} -.swagger-section .swagger-ui-wrap .model-signature .description .strong { - font-weight: bold; - color: #000; - font-size: .9em; -} -.swagger-section .swagger-ui-wrap .model-signature .description div { - font-size: 0.9em; - line-height: 1.5em; - margin-left: 1em; -} -.swagger-section .swagger-ui-wrap .model-signature .description .stronger { - font-weight: bold; - color: #000; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propWrap .optionsWrapper { - border-spacing: 0; - position: absolute; - background-color: #ffffff; - border: 1px solid #bbbbbb; - display: none; - font-size: 11px; - max-width: 400px; - line-height: 30px; - color: black; - padding: 5px; - margin-left: 10px; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propWrap .optionsWrapper th { - text-align: center; - background-color: #eeeeee; - border: 1px solid #bbbbbb; - font-size: 11px; - color: #666666; - font-weight: bold; - padding: 5px; - line-height: 15px; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propWrap .optionsWrapper .optionName { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propDesc.markdown > p:first-child, -.swagger-section .swagger-ui-wrap .model-signature .description .propDesc.markdown > p:last-child { - display: inline; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propDesc.markdown > p:not(:first-child):before { - display: block; - content: ''; -} -.swagger-section .swagger-ui-wrap .model-signature .description span:last-of-type.propDesc.markdown > p:only-child { - margin-right: -3px; -} -.swagger-section .swagger-ui-wrap .model-signature .propName { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-container { - clear: both; -} -.swagger-section .swagger-ui-wrap .body-textarea { - width: 300px; - height: 100px; - border: 1px solid #aaa; -} -.swagger-section .swagger-ui-wrap .markdown p code, -.swagger-section .swagger-ui-wrap .markdown li code { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - background-color: #f0f0f0; - color: black; - padding: 1px 3px; -} -.swagger-section .swagger-ui-wrap .required { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .editor_holder { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap .editor_holder label { - font-weight: normal!important; - /* JSONEditor uses bold by default for all labels, we revert that back to normal to not give the impression that by default fields are required */ -} -.swagger-section .swagger-ui-wrap .editor_holder label.required { - font-weight: bold!important; -} -.swagger-section .swagger-ui-wrap input.parameter { - width: 300px; - border: 1px solid #aaa; -} -.swagger-section .swagger-ui-wrap h1 { - color: black; - font-size: 1.5em; - line-height: 1.3em; - padding: 10px 0 10px 0; - font-family: "Droid Sans", sans-serif; - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .heading_with_menu { - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap .heading_with_menu ul { - display: block; - clear: none; - float: right; - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; - margin-top: 10px; -} -.swagger-section .swagger-ui-wrap h2 { - color: black; - font-size: 1.3em; - padding: 10px 0 10px 0; -} -.swagger-section .swagger-ui-wrap h2 a { - color: black; -} -.swagger-section .swagger-ui-wrap h2 span.sub { - font-size: 0.7em; - color: #999999; - font-style: italic; -} -.swagger-section .swagger-ui-wrap h2 span.sub a { - color: #777777; -} -.swagger-section .swagger-ui-wrap span.weak { - color: #666666; -} -.swagger-section .swagger-ui-wrap .message-success { - color: #89BF04; -} -.swagger-section .swagger-ui-wrap caption, -.swagger-section .swagger-ui-wrap th, -.swagger-section .swagger-ui-wrap td { - text-align: left; - font-weight: normal; - vertical-align: middle; -} -.swagger-section .swagger-ui-wrap .code { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.text textarea { - font-family: "Droid Sans", sans-serif; - height: 250px; - padding: 4px; - display: block; - clear: both; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.select select { - display: block; - clear: both; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.boolean { - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.boolean label { - display: block; - float: left; - clear: none; - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.boolean input { - display: block; - float: left; - clear: none; - margin: 0 5px 0 0; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.required label { - color: black; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li label { - display: block; - clear: both; - width: auto; - padding: 0 0 3px; - color: #666666; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li label abbr { - padding-left: 3px; - color: #888888; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li p.inline-hints { - margin-left: 0; - font-style: italic; - font-size: 0.9em; - margin: 0; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.buttons { - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap span.blank, -.swagger-section .swagger-ui-wrap span.empty { - color: #888888; - font-style: italic; -} -.swagger-section .swagger-ui-wrap .markdown h3 { - color: #547f00; -} -.swagger-section .swagger-ui-wrap .markdown h4 { - color: #666666; -} -.swagger-section .swagger-ui-wrap .markdown pre { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - background-color: #fcf6db; - border: 1px solid #e5e0c6; - padding: 10px; - margin: 0 0 10px 0; -} -.swagger-section .swagger-ui-wrap .markdown pre code { - line-height: 1.6em; - overflow: auto; -} -.swagger-section .swagger-ui-wrap div.gist { - margin: 20px 0 25px 0 !important; -} -.swagger-section .swagger-ui-wrap ul#resources { - font-family: "Droid Sans", sans-serif; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource { - border-bottom: 1px solid #dddddd; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource:hover div.heading h2 a, -.swagger-section .swagger-ui-wrap ul#resources li.resource.active div.heading h2 a { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource:hover div.heading ul.options li a, -.swagger-section .swagger-ui-wrap ul#resources li.resource.active div.heading ul.options li a { - color: #555555; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource:last-child { - border-bottom: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading { - border: 1px solid transparent; - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options { - overflow: hidden; - padding: 0; - display: block; - clear: none; - float: right; - margin: 14px 10px 0 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li { - float: left; - clear: none; - margin: 0; - padding: 2px 10px; - border-right: 1px solid #dddddd; - color: #666666; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a { - color: #aaaaaa; - text-decoration: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a:hover { - text-decoration: underline; - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a:hover, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a:active, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a.active { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li.last { - padding-right: 0; - border-right: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 { - color: #999999; - padding-left: 0; - display: block; - clear: none; - float: left; - font-family: "Droid Sans", sans-serif; - font-weight: bold; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 a { - color: #999999; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 a:hover { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation { - float: none; - clear: both; - overflow: hidden; - display: block; - margin: 0 0 10px; - padding: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading { - float: none; - clear: both; - overflow: hidden; - display: block; - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 { - display: block; - clear: none; - float: left; - width: auto; - margin: 0; - padding: 0; - line-height: 1.1em; - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path { - padding-left: 10px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path a { - color: black; - text-decoration: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path a.toggleOperation.deprecated { - text-decoration: line-through; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path a:hover { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.http_method a { - text-transform: uppercase; - text-decoration: none; - color: white; - display: inline-block; - width: 50px; - font-size: 0.7em; - text-align: center; - padding: 7px 0 4px; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - -o-border-radius: 2px; - -ms-border-radius: 2px; - -khtml-border-radius: 2px; - border-radius: 2px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span { - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options { - overflow: hidden; - padding: 0; - display: block; - clear: none; - float: right; - margin: 6px 10px 0 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options li { - float: left; - clear: none; - margin: 0; - padding: 2px 10px; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options li a { - text-decoration: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options li.access { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content { - border-top: none; - padding: 10px; - -moz-border-radius-bottomleft: 6px; - -webkit-border-bottom-left-radius: 6px; - -o-border-bottom-left-radius: 6px; - -ms-border-bottom-left-radius: 6px; - -khtml-border-bottom-left-radius: 6px; - border-bottom-left-radius: 6px; - -moz-border-radius-bottomright: 6px; - -webkit-border-bottom-right-radius: 6px; - -o-border-bottom-right-radius: 6px; - -ms-border-bottom-right-radius: 6px; - -khtml-border-bottom-right-radius: 6px; - border-bottom-right-radius: 6px; - margin: 0 0 20px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content h4 { - font-size: 1.1em; - margin: 0; - padding: 15px 0 5px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header { - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header a { - padding: 4px 0 0 10px; - display: inline-block; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header input.submit { - display: block; - clear: none; - float: left; - padding: 6px 8px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header span.response_throbber { - background-image: url('../images/throbber.gif'); - width: 128px; - height: 16px; - display: block; - clear: none; - float: right; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content form input[type='text'].error { - outline: 2px solid black; - outline-color: #cc0000; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content form select[name='parameterContentType'] { - max-width: 300px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.response div.block pre { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - padding: 10px; - font-size: 0.9em; - max-height: 400px; - overflow-y: auto; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading { - background-color: #f9f2e9; - border: 1px solid #f0e0ca; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading h3 span.http_method a { - background-color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #f0e0ca; - color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li a { - color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content { - background-color: #faf5ee; - border: 1px solid #f0e0ca; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content h4 { - color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content div.sandbox_header a { - color: #dcb67f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading { - background-color: #fcffcd; - border: 1px solid black; - border-color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading h3 span.http_method a { - text-transform: uppercase; - background-color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #ffd20f; - color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li a { - color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content { - background-color: #fcffcd; - border: 1px solid black; - border-color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content h4 { - color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content div.sandbox_header a { - color: #6fc992; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading { - background-color: #f5e8e8; - border: 1px solid #e8c6c7; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading h3 span.http_method a { - text-transform: uppercase; - background-color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #e8c6c7; - color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li a { - color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content { - background-color: #f7eded; - border: 1px solid #e8c6c7; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content h4 { - color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content div.sandbox_header a { - color: #c8787a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading { - background-color: #e7f6ec; - border: 1px solid #c3e8d1; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading h3 span.http_method a { - background-color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #c3e8d1; - color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li a { - color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content { - background-color: #ebf7f0; - border: 1px solid #c3e8d1; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content h4 { - color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content div.sandbox_header a { - color: #6fc992; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading { - background-color: #FCE9E3; - border: 1px solid #F5D5C3; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading h3 span.http_method a { - background-color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #f0cecb; - color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li a { - color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content { - background-color: #faf0ef; - border: 1px solid #f0cecb; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content h4 { - color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content div.sandbox_header a { - color: #dcb67f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading { - background-color: #e7f0f7; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading h3 span.http_method a { - background-color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #c3d9ec; - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li a { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content { - background-color: #ebf3f9; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content h4 { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content div.sandbox_header a { - color: #6fa5d2; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading { - background-color: #e7f0f7; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading h3 span.http_method a { - background-color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #c3d9ec; - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading ul.options li a { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.content { - background-color: #ebf3f9; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.content h4 { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.content div.sandbox_header a { - color: #6fa5d2; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content { - border-top: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li.last { - padding-right: 0; - border-right: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li a:hover, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li a:active, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li a.active { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap p#colophon { - margin: 0 15px 40px 15px; - padding: 10px 0; - font-size: 0.8em; - border-top: 1px solid #dddddd; - font-family: "Droid Sans", sans-serif; - color: #999999; - font-style: italic; -} -.swagger-section .swagger-ui-wrap p#colophon a { - text-decoration: none; - color: #547f00; -} -.swagger-section .swagger-ui-wrap h3 { - color: black; - font-size: 1.1em; - padding: 10px 0 10px 0; -} -.swagger-section .swagger-ui-wrap .markdown ol, -.swagger-section .swagger-ui-wrap .markdown ul { - font-family: "Droid Sans", sans-serif; - margin: 5px 0 10px; - padding: 0 0 0 18px; - list-style-type: disc; -} -.swagger-section .swagger-ui-wrap form.form_box { - background-color: #ebf3f9; - border: 1px solid #c3d9ec; - padding: 10px; -} -.swagger-section .swagger-ui-wrap form.form_box label { - color: #0f6ab4 !important; -} -.swagger-section .swagger-ui-wrap form.form_box input[type=submit] { - display: block; - padding: 10px; -} -.swagger-section .swagger-ui-wrap form.form_box p.weak { - font-size: 0.8em; -} -.swagger-section .swagger-ui-wrap form.form_box p { - font-size: 0.9em; - padding: 0 0 15px; - color: #7e7b6d; -} -.swagger-section .swagger-ui-wrap form.form_box p a { - color: #646257; -} -.swagger-section .swagger-ui-wrap form.form_box p strong { - color: black; -} -.swagger-section .swagger-ui-wrap .operation-status td.markdown > p:last-child { - padding-bottom: 0; -} -.swagger-section .title { - font-style: bold; -} -.swagger-section .secondary_form { - display: none; -} -.swagger-section .main_image { - display: block; - margin-left: auto; - margin-right: auto; -} -.swagger-section .oauth_body { - margin-left: 100px; - margin-right: 100px; -} -.swagger-section .oauth_submit { - text-align: center; - display: inline-block; -} -.swagger-section .authorize-wrapper { - margin: 15px 0 10px; -} -.swagger-section .authorize-wrapper_operation { - float: right; -} -.swagger-section .authorize__btn:hover { - text-decoration: underline; - cursor: pointer; -} -.swagger-section .authorize__btn_operation:hover .authorize-scopes { - display: block; -} -.swagger-section .authorize-scopes { - position: absolute; - margin-top: 20px; - background: #FFF; - border: 1px solid #ccc; - border-radius: 5px; - display: none; - font-size: 13px; - max-width: 300px; - line-height: 30px; - color: black; - padding: 5px; -} -.swagger-section .authorize-scopes .authorize__scope { - text-decoration: none; -} -.swagger-section .authorize__btn_operation { - height: 18px; - vertical-align: middle; - display: inline-block; - background: url(../images/explorer_icons.png) no-repeat; -} -.swagger-section .authorize__btn_operation_login { - background-position: 0 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section .authorize__btn_operation_logout { - background-position: -30px 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section #auth_container { - color: #fff; - display: inline-block; - border: none; - padding: 5px; - width: 87px; - height: 13px; -} -.swagger-section #auth_container .authorize__btn { - color: #fff; -} -.swagger-section .auth_container { - padding: 0 0 10px; - margin-bottom: 5px; - border-bottom: solid 1px #CCC; - font-size: 0.9em; -} -.swagger-section .auth_container .auth__title { - color: #547f00; - font-size: 1.2em; -} -.swagger-section .auth_container .basic_auth__label { - display: inline-block; - width: 60px; -} -.swagger-section .auth_container .auth__description { - color: #999999; - margin-bottom: 5px; -} -.swagger-section .auth_container .auth__button { - margin-top: 10px; - height: 30px; -} -.swagger-section .auth_container .key_auth__field { - margin: 5px 0; -} -.swagger-section .auth_container .key_auth__label { - display: inline-block; - width: 60px; -} -.swagger-section .api-popup-dialog { - position: absolute; - display: none; -} -.swagger-section .api-popup-dialog-wrapper { - z-index: 1000; - width: 500px; - background: #FFF; - padding: 20px; - border: 1px solid #ccc; - border-radius: 5px; - font-size: 13px; - color: #777; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} -.swagger-section .api-popup-dialog-shadow { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.2; - background-color: gray; - z-index: 900; -} -.swagger-section .api-popup-dialog .api-popup-title { - font-size: 24px; - padding: 10px 0; -} -.swagger-section .api-popup-dialog .api-popup-title { - font-size: 24px; - padding: 10px 0; -} -.swagger-section .api-popup-dialog .error-msg { - padding-left: 5px; - padding-bottom: 5px; -} -.swagger-section .api-popup-dialog .api-popup-content { - max-height: 500px; - overflow-y: auto; -} -.swagger-section .api-popup-dialog .api-popup-authbtn { - height: 30px; -} -.swagger-section .api-popup-dialog .api-popup-cancel { - height: 30px; -} -.swagger-section .api-popup-scopes { - padding: 10px 20px; -} -.swagger-section .api-popup-scopes li { - padding: 5px 0; - line-height: 20px; -} -.swagger-section .api-popup-scopes li input { - position: relative; - top: 2px; -} -.swagger-section .api-popup-scopes .api-scope-desc { - padding-left: 20px; - font-style: italic; -} -.swagger-section .api-popup-actions { - padding-top: 10px; -} -#header { - display: none; -} -.swagger-section .swagger-ui-wrap .model-signature pre { - max-height: none; -} -.swagger-section .swagger-ui-wrap .body-textarea { - width: 100px; -} -.swagger-section .swagger-ui-wrap input.parameter { - width: 100px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options { - display: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints { - display: block !important; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content { - display: block !important; -} diff --git a/public/api/docs/css/reset.css b/public/api/docs/css/reset.css deleted file mode 100755 index b2b078943..000000000 --- a/public/api/docs/css/reset.css +++ /dev/null @@ -1,125 +0,0 @@ -/* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 */ -html, -body, -div, -span, -applet, -object, -iframe, -h1, -h2, -h3, -h4, -h5, -h6, -p, -blockquote, -pre, -a, -abbr, -acronym, -address, -big, -cite, -code, -del, -dfn, -em, -img, -ins, -kbd, -q, -s, -samp, -small, -strike, -strong, -sub, -sup, -tt, -var, -b, -u, -i, -center, -dl, -dt, -dd, -ol, -ul, -li, -fieldset, -form, -label, -legend, -table, -caption, -tbody, -tfoot, -thead, -tr, -th, -td, -article, -aside, -canvas, -details, -embed, -figure, -figcaption, -footer, -header, -hgroup, -menu, -nav, -output, -ruby, -section, -summary, -time, -mark, -audio, -video { - margin: 0; - padding: 0; - border: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; -} -/* HTML5 display-role reset for older browsers */ -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -menu, -nav, -section { - display: block; -} -body { - line-height: 1; -} -ol, -ul { - list-style: none; -} -blockquote, -q { - quotes: none; -} -blockquote:before, -blockquote:after, -q:before, -q:after { - content: ''; - content: none; -} -table { - border-collapse: collapse; - border-spacing: 0; -} diff --git a/public/api/docs/css/screen.css b/public/api/docs/css/screen.css deleted file mode 100755 index 9d680e2d9..000000000 --- a/public/api/docs/css/screen.css +++ /dev/null @@ -1,1489 +0,0 @@ -/* Original style from softwaremaniacs.org (c) Ivan Sagalaev */ -.swagger-section pre code { - display: block; - padding: 0.5em; - background: #F0F0F0; -} -.swagger-section pre code, -.swagger-section pre .subst, -.swagger-section pre .tag .title, -.swagger-section pre .lisp .title, -.swagger-section pre .clojure .built_in, -.swagger-section pre .nginx .title { - color: black; -} -.swagger-section pre .string, -.swagger-section pre .title, -.swagger-section pre .constant, -.swagger-section pre .parent, -.swagger-section pre .tag .value, -.swagger-section pre .rules .value, -.swagger-section pre .rules .value .number, -.swagger-section pre .preprocessor, -.swagger-section pre .ruby .symbol, -.swagger-section pre .ruby .symbol .string, -.swagger-section pre .aggregate, -.swagger-section pre .template_tag, -.swagger-section pre .django .variable, -.swagger-section pre .smalltalk .class, -.swagger-section pre .addition, -.swagger-section pre .flow, -.swagger-section pre .stream, -.swagger-section pre .bash .variable, -.swagger-section pre .apache .tag, -.swagger-section pre .apache .cbracket, -.swagger-section pre .tex .command, -.swagger-section pre .tex .special, -.swagger-section pre .erlang_repl .function_or_atom, -.swagger-section pre .markdown .header { - color: #800; -} -.swagger-section pre .comment, -.swagger-section pre .annotation, -.swagger-section pre .template_comment, -.swagger-section pre .diff .header, -.swagger-section pre .chunk, -.swagger-section pre .markdown .blockquote { - color: #888; -} -.swagger-section pre .number, -.swagger-section pre .date, -.swagger-section pre .regexp, -.swagger-section pre .literal, -.swagger-section pre .smalltalk .symbol, -.swagger-section pre .smalltalk .char, -.swagger-section pre .go .constant, -.swagger-section pre .change, -.swagger-section pre .markdown .bullet, -.swagger-section pre .markdown .link_url { - color: #080; -} -.swagger-section pre .label, -.swagger-section pre .javadoc, -.swagger-section pre .ruby .string, -.swagger-section pre .decorator, -.swagger-section pre .filter .argument, -.swagger-section pre .localvars, -.swagger-section pre .array, -.swagger-section pre .attr_selector, -.swagger-section pre .important, -.swagger-section pre .pseudo, -.swagger-section pre .pi, -.swagger-section pre .doctype, -.swagger-section pre .deletion, -.swagger-section pre .envvar, -.swagger-section pre .shebang, -.swagger-section pre .apache .sqbracket, -.swagger-section pre .nginx .built_in, -.swagger-section pre .tex .formula, -.swagger-section pre .erlang_repl .reserved, -.swagger-section pre .prompt, -.swagger-section pre .markdown .link_label, -.swagger-section pre .vhdl .attribute, -.swagger-section pre .clojure .attribute, -.swagger-section pre .coffeescript .property { - color: #88F; -} -.swagger-section pre .keyword, -.swagger-section pre .id, -.swagger-section pre .phpdoc, -.swagger-section pre .title, -.swagger-section pre .built_in, -.swagger-section pre .aggregate, -.swagger-section pre .css .tag, -.swagger-section pre .javadoctag, -.swagger-section pre .phpdoc, -.swagger-section pre .yardoctag, -.swagger-section pre .smalltalk .class, -.swagger-section pre .winutils, -.swagger-section pre .bash .variable, -.swagger-section pre .apache .tag, -.swagger-section pre .go .typename, -.swagger-section pre .tex .command, -.swagger-section pre .markdown .strong, -.swagger-section pre .request, -.swagger-section pre .status { - font-weight: bold; -} -.swagger-section pre .markdown .emphasis { - font-style: italic; -} -.swagger-section pre .nginx .built_in { - font-weight: normal; -} -.swagger-section pre .coffeescript .javascript, -.swagger-section pre .javascript .xml, -.swagger-section pre .tex .formula, -.swagger-section pre .xml .javascript, -.swagger-section pre .xml .vbscript, -.swagger-section pre .xml .css, -.swagger-section pre .xml .cdata { - opacity: 0.5; -} -.swagger-section .hljs { - display: block; - overflow-x: auto; - padding: 0.5em; - background: #F0F0F0; -} -.swagger-section .hljs, -.swagger-section .hljs-subst { - color: #444; -} -.swagger-section .hljs-keyword, -.swagger-section .hljs-attribute, -.swagger-section .hljs-selector-tag, -.swagger-section .hljs-meta-keyword, -.swagger-section .hljs-doctag, -.swagger-section .hljs-name { - font-weight: bold; -} -.swagger-section .hljs-built_in, -.swagger-section .hljs-literal, -.swagger-section .hljs-bullet, -.swagger-section .hljs-code, -.swagger-section .hljs-addition { - color: #1F811F; -} -.swagger-section .hljs-regexp, -.swagger-section .hljs-symbol, -.swagger-section .hljs-variable, -.swagger-section .hljs-template-variable, -.swagger-section .hljs-link, -.swagger-section .hljs-selector-attr, -.swagger-section .hljs-selector-pseudo { - color: #BC6060; -} -.swagger-section .hljs-type, -.swagger-section .hljs-string, -.swagger-section .hljs-number, -.swagger-section .hljs-selector-id, -.swagger-section .hljs-selector-class, -.swagger-section .hljs-quote, -.swagger-section .hljs-template-tag, -.swagger-section .hljs-deletion { - color: #880000; -} -.swagger-section .hljs-title, -.swagger-section .hljs-section { - color: #880000; - font-weight: bold; -} -.swagger-section .hljs-comment { - color: #888888; -} -.swagger-section .hljs-meta { - color: #2B6EA1; -} -.swagger-section .hljs-emphasis { - font-style: italic; -} -.swagger-section .hljs-strong { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap { - line-height: 1; - font-family: "Droid Sans", sans-serif; - min-width: 760px; - max-width: 960px; - margin-left: auto; - margin-right: auto; - /* JSONEditor specific styling */ -} -.swagger-section .swagger-ui-wrap b, -.swagger-section .swagger-ui-wrap strong { - font-family: "Droid Sans", sans-serif; - font-weight: bold; -} -.swagger-section .swagger-ui-wrap q, -.swagger-section .swagger-ui-wrap blockquote { - quotes: none; -} -.swagger-section .swagger-ui-wrap p { - line-height: 1.4em; - padding: 0 0 10px; - color: #333333; -} -.swagger-section .swagger-ui-wrap q:before, -.swagger-section .swagger-ui-wrap q:after, -.swagger-section .swagger-ui-wrap blockquote:before, -.swagger-section .swagger-ui-wrap blockquote:after { - content: none; -} -.swagger-section .swagger-ui-wrap .heading_with_menu h1, -.swagger-section .swagger-ui-wrap .heading_with_menu h2, -.swagger-section .swagger-ui-wrap .heading_with_menu h3, -.swagger-section .swagger-ui-wrap .heading_with_menu h4, -.swagger-section .swagger-ui-wrap .heading_with_menu h5, -.swagger-section .swagger-ui-wrap .heading_with_menu h6 { - display: block; - clear: none; - float: left; - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; - width: 60%; -} -.swagger-section .swagger-ui-wrap table { - border-collapse: collapse; - border-spacing: 0; -} -.swagger-section .swagger-ui-wrap table thead tr th { - padding: 5px; - font-size: 0.9em; - color: #666666; - border-bottom: 1px solid #999999; -} -.swagger-section .swagger-ui-wrap table tbody tr:last-child td { - border-bottom: none; -} -.swagger-section .swagger-ui-wrap table tbody tr.offset { - background-color: #f0f0f0; -} -.swagger-section .swagger-ui-wrap table tbody tr td { - padding: 6px; - font-size: 0.9em; - border-bottom: 1px solid #cccccc; - vertical-align: top; - line-height: 1.3em; -} -.swagger-section .swagger-ui-wrap ol { - margin: 0px 0 10px; - padding: 0 0 0 18px; - list-style-type: decimal; -} -.swagger-section .swagger-ui-wrap ol li { - padding: 5px 0px; - font-size: 0.9em; - color: #333333; -} -.swagger-section .swagger-ui-wrap ol, -.swagger-section .swagger-ui-wrap ul { - list-style: none; -} -.swagger-section .swagger-ui-wrap h1 a, -.swagger-section .swagger-ui-wrap h2 a, -.swagger-section .swagger-ui-wrap h3 a, -.swagger-section .swagger-ui-wrap h4 a, -.swagger-section .swagger-ui-wrap h5 a, -.swagger-section .swagger-ui-wrap h6 a { - text-decoration: none; -} -.swagger-section .swagger-ui-wrap h1 a:hover, -.swagger-section .swagger-ui-wrap h2 a:hover, -.swagger-section .swagger-ui-wrap h3 a:hover, -.swagger-section .swagger-ui-wrap h4 a:hover, -.swagger-section .swagger-ui-wrap h5 a:hover, -.swagger-section .swagger-ui-wrap h6 a:hover { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap h1 span.divider, -.swagger-section .swagger-ui-wrap h2 span.divider, -.swagger-section .swagger-ui-wrap h3 span.divider, -.swagger-section .swagger-ui-wrap h4 span.divider, -.swagger-section .swagger-ui-wrap h5 span.divider, -.swagger-section .swagger-ui-wrap h6 span.divider { - color: #aaaaaa; -} -.swagger-section .swagger-ui-wrap a { - color: #547f00; -} -.swagger-section .swagger-ui-wrap a img { - border: none; -} -.swagger-section .swagger-ui-wrap article, -.swagger-section .swagger-ui-wrap aside, -.swagger-section .swagger-ui-wrap details, -.swagger-section .swagger-ui-wrap figcaption, -.swagger-section .swagger-ui-wrap figure, -.swagger-section .swagger-ui-wrap footer, -.swagger-section .swagger-ui-wrap header, -.swagger-section .swagger-ui-wrap hgroup, -.swagger-section .swagger-ui-wrap menu, -.swagger-section .swagger-ui-wrap nav, -.swagger-section .swagger-ui-wrap section, -.swagger-section .swagger-ui-wrap summary { - display: block; -} -.swagger-section .swagger-ui-wrap pre { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - background-color: #fcf6db; - border: 1px solid #e5e0c6; - padding: 10px; -} -.swagger-section .swagger-ui-wrap pre code { - line-height: 1.6em; - background: none; -} -.swagger-section .swagger-ui-wrap .content > .content-type > div > label { - clear: both; - display: block; - color: #0F6AB4; - font-size: 1.1em; - margin: 0; - padding: 15px 0 5px; -} -.swagger-section .swagger-ui-wrap .content pre { - font-size: 12px; - margin-top: 5px; - padding: 5px; -} -.swagger-section .swagger-ui-wrap .icon-btn { - cursor: pointer; -} -.swagger-section .swagger-ui-wrap .info_title { - padding-bottom: 10px; - font-weight: bold; - font-size: 25px; -} -.swagger-section .swagger-ui-wrap .footer { - margin-top: 20px; -} -.swagger-section .swagger-ui-wrap p.big, -.swagger-section .swagger-ui-wrap div.big p { - font-size: 1em; - margin-bottom: 10px; -} -.swagger-section .swagger-ui-wrap form.fullwidth ol li.string input, -.swagger-section .swagger-ui-wrap form.fullwidth ol li.url input, -.swagger-section .swagger-ui-wrap form.fullwidth ol li.text textarea, -.swagger-section .swagger-ui-wrap form.fullwidth ol li.numeric input { - width: 500px !important; -} -.swagger-section .swagger-ui-wrap .info_license { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_tos { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .message-fail { - color: #cc0000; -} -.swagger-section .swagger-ui-wrap .info_url { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_email { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_name { - padding-bottom: 5px; -} -.swagger-section .swagger-ui-wrap .info_description { - padding-bottom: 10px; - font-size: 15px; -} -.swagger-section .swagger-ui-wrap .markdown ol li, -.swagger-section .swagger-ui-wrap .markdown ul li { - padding: 3px 0px; - line-height: 1.4em; - color: #333333; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.string input, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.url input, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.numeric input { - display: block; - padding: 4px; - width: auto; - clear: both; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.string input.title, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.url input.title, -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.numeric input.title { - font-size: 1.3em; -} -.swagger-section .swagger-ui-wrap table.fullwidth { - width: 100%; -} -.swagger-section .swagger-ui-wrap .model-signature { - font-family: "Droid Sans", sans-serif; - font-size: 1em; - line-height: 1.5em; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-nav a { - text-decoration: none; - color: #AAA; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-nav a:hover { - text-decoration: underline; - color: black; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-nav .selected { - color: black; - text-decoration: none; -} -.swagger-section .swagger-ui-wrap .model-signature .propType { - color: #5555aa; -} -.swagger-section .swagger-ui-wrap .model-signature pre:hover { - background-color: #ffffdd; -} -.swagger-section .swagger-ui-wrap .model-signature pre { - font-size: .85em; - line-height: 1.2em; - overflow: auto; - max-height: 200px; - cursor: pointer; -} -.swagger-section .swagger-ui-wrap .model-signature ul.signature-nav { - display: block; - min-width: 230px; - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap .model-signature ul.signature-nav li:last-child { - padding-right: 0; - border-right: none; -} -.swagger-section .swagger-ui-wrap .model-signature ul.signature-nav li { - float: left; - margin: 0 5px 5px 0; - padding: 2px 5px 2px 0; - border-right: 1px solid #ddd; -} -.swagger-section .swagger-ui-wrap .model-signature .propOpt { - color: #555; -} -.swagger-section .swagger-ui-wrap .model-signature .snippet small { - font-size: 0.75em; -} -.swagger-section .swagger-ui-wrap .model-signature .propOptKey { - font-style: italic; -} -.swagger-section .swagger-ui-wrap .model-signature .description .strong { - font-weight: bold; - color: #000; - font-size: .9em; -} -.swagger-section .swagger-ui-wrap .model-signature .description div { - font-size: 0.9em; - line-height: 1.5em; - margin-left: 1em; -} -.swagger-section .swagger-ui-wrap .model-signature .description .stronger { - font-weight: bold; - color: #000; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propWrap .optionsWrapper { - border-spacing: 0; - position: absolute; - background-color: #ffffff; - border: 1px solid #bbbbbb; - display: none; - font-size: 11px; - max-width: 400px; - line-height: 30px; - color: black; - padding: 5px; - margin-left: 10px; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propWrap .optionsWrapper th { - text-align: center; - background-color: #eeeeee; - border: 1px solid #bbbbbb; - font-size: 11px; - color: #666666; - font-weight: bold; - padding: 5px; - line-height: 15px; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propWrap .optionsWrapper .optionName { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propDesc.markdown > p:first-child, -.swagger-section .swagger-ui-wrap .model-signature .description .propDesc.markdown > p:last-child { - display: inline; -} -.swagger-section .swagger-ui-wrap .model-signature .description .propDesc.markdown > p:not(:first-child):before { - display: block; - content: ''; -} -.swagger-section .swagger-ui-wrap .model-signature .description span:last-of-type.propDesc.markdown > p:only-child { - margin-right: -3px; -} -.swagger-section .swagger-ui-wrap .model-signature .propName { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .model-signature .signature-container { - clear: both; -} -.swagger-section .swagger-ui-wrap .body-textarea { - width: 300px; - height: 100px; - border: 1px solid #aaa; -} -.swagger-section .swagger-ui-wrap .markdown p code, -.swagger-section .swagger-ui-wrap .markdown li code { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - background-color: #f0f0f0; - color: black; - padding: 1px 3px; -} -.swagger-section .swagger-ui-wrap .required { - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .editor_holder { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap .editor_holder label { - font-weight: normal!important; - /* JSONEditor uses bold by default for all labels, we revert that back to normal to not give the impression that by default fields are required */ -} -.swagger-section .swagger-ui-wrap .editor_holder label.required { - font-weight: bold!important; -} -.swagger-section .swagger-ui-wrap input.parameter { - width: 300px; - border: 1px solid #aaa; -} -.swagger-section .swagger-ui-wrap h1 { - color: black; - font-size: 1.5em; - line-height: 1.3em; - padding: 10px 0 10px 0; - font-family: "Droid Sans", sans-serif; - font-weight: bold; -} -.swagger-section .swagger-ui-wrap .heading_with_menu { - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap .heading_with_menu ul { - display: block; - clear: none; - float: right; - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; - margin-top: 10px; -} -.swagger-section .swagger-ui-wrap h2 { - color: black; - font-size: 1.3em; - padding: 10px 0 10px 0; -} -.swagger-section .swagger-ui-wrap h2 a { - color: black; -} -.swagger-section .swagger-ui-wrap h2 span.sub { - font-size: 0.7em; - color: #999999; - font-style: italic; -} -.swagger-section .swagger-ui-wrap h2 span.sub a { - color: #777777; -} -.swagger-section .swagger-ui-wrap span.weak { - color: #666666; -} -.swagger-section .swagger-ui-wrap .message-success { - color: #89BF04; -} -.swagger-section .swagger-ui-wrap caption, -.swagger-section .swagger-ui-wrap th, -.swagger-section .swagger-ui-wrap td { - text-align: left; - font-weight: normal; - vertical-align: middle; -} -.swagger-section .swagger-ui-wrap .code { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.text textarea { - font-family: "Droid Sans", sans-serif; - height: 250px; - padding: 4px; - display: block; - clear: both; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.select select { - display: block; - clear: both; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.boolean { - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.boolean label { - display: block; - float: left; - clear: none; - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.boolean input { - display: block; - float: left; - clear: none; - margin: 0 5px 0 0; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li.required label { - color: black; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li label { - display: block; - clear: both; - width: auto; - padding: 0 0 3px; - color: #666666; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li label abbr { - padding-left: 3px; - color: #888888; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.inputs ol li p.inline-hints { - margin-left: 0; - font-style: italic; - font-size: 0.9em; - margin: 0; -} -.swagger-section .swagger-ui-wrap form.formtastic fieldset.buttons { - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap span.blank, -.swagger-section .swagger-ui-wrap span.empty { - color: #888888; - font-style: italic; -} -.swagger-section .swagger-ui-wrap .markdown h3 { - color: #547f00; -} -.swagger-section .swagger-ui-wrap .markdown h4 { - color: #666666; -} -.swagger-section .swagger-ui-wrap .markdown pre { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - background-color: #fcf6db; - border: 1px solid #e5e0c6; - padding: 10px; - margin: 0 0 10px 0; -} -.swagger-section .swagger-ui-wrap .markdown pre code { - line-height: 1.6em; - overflow: auto; -} -.swagger-section .swagger-ui-wrap div.gist { - margin: 20px 0 25px 0 !important; -} -.swagger-section .swagger-ui-wrap ul#resources { - font-family: "Droid Sans", sans-serif; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource { - border-bottom: 1px solid #dddddd; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource:hover div.heading h2 a, -.swagger-section .swagger-ui-wrap ul#resources li.resource.active div.heading h2 a { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource:hover div.heading ul.options li a, -.swagger-section .swagger-ui-wrap ul#resources li.resource.active div.heading ul.options li a { - color: #555555; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource:last-child { - border-bottom: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading { - border: 1px solid transparent; - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options { - overflow: hidden; - padding: 0; - display: block; - clear: none; - float: right; - margin: 14px 10px 0 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li { - float: left; - clear: none; - margin: 0; - padding: 2px 10px; - border-right: 1px solid #dddddd; - color: #666666; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a { - color: #aaaaaa; - text-decoration: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a:hover { - text-decoration: underline; - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a:hover, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a:active, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li a.active { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options li.last { - padding-right: 0; - border-right: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading ul.options.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 { - color: #999999; - padding-left: 0; - display: block; - clear: none; - float: left; - font-family: "Droid Sans", sans-serif; - font-weight: bold; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 a { - color: #999999; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 a:hover { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation { - float: none; - clear: both; - overflow: hidden; - display: block; - margin: 0 0 10px; - padding: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading { - float: none; - clear: both; - overflow: hidden; - display: block; - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 { - display: block; - clear: none; - float: left; - width: auto; - margin: 0; - padding: 0; - line-height: 1.1em; - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path { - padding-left: 10px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path a { - color: black; - text-decoration: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path a.toggleOperation.deprecated { - text-decoration: line-through; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.path a:hover { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span.http_method a { - text-transform: uppercase; - text-decoration: none; - color: white; - display: inline-block; - width: 50px; - font-size: 0.7em; - text-align: center; - padding: 7px 0 4px; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - -o-border-radius: 2px; - -ms-border-radius: 2px; - -khtml-border-radius: 2px; - border-radius: 2px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading h3 span { - margin: 0; - padding: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options { - overflow: hidden; - padding: 0; - display: block; - clear: none; - float: right; - margin: 6px 10px 0 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options li { - float: left; - clear: none; - margin: 0; - padding: 2px 10px; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options li a { - text-decoration: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.heading ul.options li.access { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content { - border-top: none; - padding: 10px; - -moz-border-radius-bottomleft: 6px; - -webkit-border-bottom-left-radius: 6px; - -o-border-bottom-left-radius: 6px; - -ms-border-bottom-left-radius: 6px; - -khtml-border-bottom-left-radius: 6px; - border-bottom-left-radius: 6px; - -moz-border-radius-bottomright: 6px; - -webkit-border-bottom-right-radius: 6px; - -o-border-bottom-right-radius: 6px; - -ms-border-bottom-right-radius: 6px; - -khtml-border-bottom-right-radius: 6px; - border-bottom-right-radius: 6px; - margin: 0 0 20px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content h4 { - font-size: 1.1em; - margin: 0; - padding: 15px 0 5px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header { - float: none; - clear: both; - overflow: hidden; - display: block; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header a { - padding: 4px 0 0 10px; - display: inline-block; - font-size: 0.9em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header input.submit { - display: block; - clear: none; - float: left; - padding: 6px 8px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.sandbox_header span.response_throbber { - background-image: url('../images/throbber.gif'); - width: 128px; - height: 16px; - display: block; - clear: none; - float: right; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content form input[type='text'].error { - outline: 2px solid black; - outline-color: #cc0000; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content form select[name='parameterContentType'] { - max-width: 300px; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation div.content div.response div.block pre { - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - padding: 10px; - font-size: 0.9em; - max-height: 400px; - overflow-y: auto; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading { - background-color: #f9f2e9; - border: 1px solid #f0e0ca; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading h3 span.http_method a { - background-color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #f0e0ca; - color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li a { - color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content { - background-color: #faf5ee; - border: 1px solid #f0e0ca; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content h4 { - color: #c5862b; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content div.sandbox_header a { - color: #dcb67f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading { - background-color: #fcffcd; - border: 1px solid black; - border-color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading h3 span.http_method a { - text-transform: uppercase; - background-color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #ffd20f; - color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li a { - color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content { - background-color: #fcffcd; - border: 1px solid black; - border-color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content h4 { - color: #ffd20f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content div.sandbox_header a { - color: #6fc992; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading { - background-color: #f5e8e8; - border: 1px solid #e8c6c7; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading h3 span.http_method a { - text-transform: uppercase; - background-color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #e8c6c7; - color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li a { - color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content { - background-color: #f7eded; - border: 1px solid #e8c6c7; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content h4 { - color: #a41e22; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content div.sandbox_header a { - color: #c8787a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading { - background-color: #e7f6ec; - border: 1px solid #c3e8d1; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading h3 span.http_method a { - background-color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #c3e8d1; - color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li a { - color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content { - background-color: #ebf7f0; - border: 1px solid #c3e8d1; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content h4 { - color: #10a54a; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content div.sandbox_header a { - color: #6fc992; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading { - background-color: #FCE9E3; - border: 1px solid #F5D5C3; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading h3 span.http_method a { - background-color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #f0cecb; - color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li a { - color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content { - background-color: #faf0ef; - border: 1px solid #f0cecb; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content h4 { - color: #D38042; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content div.sandbox_header a { - color: #dcb67f; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading { - background-color: #e7f0f7; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading h3 span.http_method a { - background-color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #c3d9ec; - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li a { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content { - background-color: #ebf3f9; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content h4 { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content div.sandbox_header a { - color: #6fa5d2; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading { - background-color: #e7f0f7; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading h3 span.http_method a { - background-color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading ul.options li { - border-right: 1px solid #dddddd; - border-right-color: #c3d9ec; - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.heading ul.options li a { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.content { - background-color: #ebf3f9; - border: 1px solid #c3d9ec; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.content h4 { - color: #0f6ab4; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.options div.content div.sandbox_header a { - color: #6fa5d2; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.content, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.content { - border-top: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li:last-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.get div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.post div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.head div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.put div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.patch div.heading ul.options li.last, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations li.operation.delete div.heading ul.options li.last { - padding-right: 0; - border-right: none; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li a:hover, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li a:active, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li a.active { - text-decoration: underline; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations ul.options li.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations:first-child, -.swagger-section .swagger-ui-wrap ul#resources li.resource ul.endpoints li.endpoint ul.operations.first { - padding-left: 0; -} -.swagger-section .swagger-ui-wrap p#colophon { - margin: 0 15px 40px 15px; - padding: 10px 0; - font-size: 0.8em; - border-top: 1px solid #dddddd; - font-family: "Droid Sans", sans-serif; - color: #999999; - font-style: italic; -} -.swagger-section .swagger-ui-wrap p#colophon a { - text-decoration: none; - color: #547f00; -} -.swagger-section .swagger-ui-wrap h3 { - color: black; - font-size: 1.1em; - padding: 10px 0 10px 0; -} -.swagger-section .swagger-ui-wrap .markdown ol, -.swagger-section .swagger-ui-wrap .markdown ul { - font-family: "Droid Sans", sans-serif; - margin: 5px 0 10px; - padding: 0 0 0 18px; - list-style-type: disc; -} -.swagger-section .swagger-ui-wrap form.form_box { - background-color: #ebf3f9; - border: 1px solid #c3d9ec; - padding: 10px; -} -.swagger-section .swagger-ui-wrap form.form_box label { - color: #0f6ab4 !important; -} -.swagger-section .swagger-ui-wrap form.form_box input[type=submit] { - display: block; - padding: 10px; -} -.swagger-section .swagger-ui-wrap form.form_box p.weak { - font-size: 0.8em; -} -.swagger-section .swagger-ui-wrap form.form_box p { - font-size: 0.9em; - padding: 0 0 15px; - color: #7e7b6d; -} -.swagger-section .swagger-ui-wrap form.form_box p a { - color: #646257; -} -.swagger-section .swagger-ui-wrap form.form_box p strong { - color: black; -} -.swagger-section .swagger-ui-wrap .operation-status td.markdown > p:last-child { - padding-bottom: 0; -} -.swagger-section .title { - font-style: bold; -} -.swagger-section .secondary_form { - display: none; -} -.swagger-section .main_image { - display: block; - margin-left: auto; - margin-right: auto; -} -.swagger-section .oauth_body { - margin-left: 100px; - margin-right: 100px; -} -.swagger-section .oauth_submit { - text-align: center; - display: inline-block; -} -.swagger-section .authorize-wrapper { - margin: 15px 0 10px; -} -.swagger-section .authorize-wrapper_operation { - float: right; -} -.swagger-section .authorize__btn:hover { - text-decoration: underline; - cursor: pointer; -} -.swagger-section .authorize__btn_operation:hover .authorize-scopes { - display: block; -} -.swagger-section .authorize-scopes { - position: absolute; - margin-top: 20px; - background: #FFF; - border: 1px solid #ccc; - border-radius: 5px; - display: none; - font-size: 13px; - max-width: 300px; - line-height: 30px; - color: black; - padding: 5px; -} -.swagger-section .authorize-scopes .authorize__scope { - text-decoration: none; -} -.swagger-section .authorize__btn_operation { - height: 18px; - vertical-align: middle; - display: inline-block; - background: url(../images/explorer_icons.png) no-repeat; -} -.swagger-section .authorize__btn_operation_login { - background-position: 0 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section .authorize__btn_operation_logout { - background-position: -30px 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section #auth_container { - color: #fff; - display: inline-block; - border: none; - padding: 5px; - width: 87px; - height: 13px; -} -.swagger-section #auth_container .authorize__btn { - color: #fff; -} -.swagger-section .auth_container { - padding: 0 0 10px; - margin-bottom: 5px; - border-bottom: solid 1px #CCC; - font-size: 0.9em; -} -.swagger-section .auth_container .auth__title { - color: #547f00; - font-size: 1.2em; -} -.swagger-section .auth_container .basic_auth__label { - display: inline-block; - width: 60px; -} -.swagger-section .auth_container .auth__description { - color: #999999; - margin-bottom: 5px; -} -.swagger-section .auth_container .auth__button { - margin-top: 10px; - height: 30px; -} -.swagger-section .auth_container .key_auth__field { - margin: 5px 0; -} -.swagger-section .auth_container .key_auth__label { - display: inline-block; - width: 60px; -} -.swagger-section .api-popup-dialog { - position: absolute; - display: none; -} -.swagger-section .api-popup-dialog-wrapper { - z-index: 1000; - width: 500px; - background: #FFF; - padding: 20px; - border: 1px solid #ccc; - border-radius: 5px; - font-size: 13px; - color: #777; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} -.swagger-section .api-popup-dialog-shadow { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.2; - background-color: gray; - z-index: 900; -} -.swagger-section .api-popup-dialog .api-popup-title { - font-size: 24px; - padding: 10px 0; -} -.swagger-section .api-popup-dialog .api-popup-title { - font-size: 24px; - padding: 10px 0; -} -.swagger-section .api-popup-dialog .error-msg { - padding-left: 5px; - padding-bottom: 5px; -} -.swagger-section .api-popup-dialog .api-popup-content { - max-height: 500px; - overflow-y: auto; -} -.swagger-section .api-popup-dialog .api-popup-authbtn { - height: 30px; -} -.swagger-section .api-popup-dialog .api-popup-cancel { - height: 30px; -} -.swagger-section .api-popup-scopes { - padding: 10px 20px; -} -.swagger-section .api-popup-scopes li { - padding: 5px 0; - line-height: 20px; -} -.swagger-section .api-popup-scopes li input { - position: relative; - top: 2px; -} -.swagger-section .api-popup-scopes .api-scope-desc { - padding-left: 20px; - font-style: italic; -} -.swagger-section .api-popup-actions { - padding-top: 10px; -} -.swagger-section .access { - float: right; -} -.swagger-section .auth { - float: right; -} -.swagger-section .api-ic { - height: 18px; - vertical-align: middle; - display: inline-block; - background: url(../images/explorer_icons.png) no-repeat; -} -.swagger-section .api-ic .api_information_panel { - position: relative; - margin-top: 20px; - margin-left: -5px; - background: #FFF; - border: 1px solid #ccc; - border-radius: 5px; - display: none; - font-size: 13px; - max-width: 300px; - line-height: 30px; - color: black; - padding: 5px; -} -.swagger-section .api-ic .api_information_panel p .api-msg-enabled { - color: green; -} -.swagger-section .api-ic .api_information_panel p .api-msg-disabled { - color: red; -} -.swagger-section .api-ic:hover .api_information_panel { - position: absolute; - display: block; -} -.swagger-section .ic-info { - background-position: 0 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section .ic-warning { - background-position: -60px 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section .ic-error { - background-position: -30px 0; - width: 18px; - margin-top: -6px; - margin-left: 4px; -} -.swagger-section .ic-off { - background-position: -90px 0; - width: 58px; - margin-top: -4px; - cursor: pointer; -} -.swagger-section .ic-on { - background-position: -160px 0; - width: 58px; - margin-top: -4px; - cursor: pointer; -} -.swagger-section #header { - background-color: #89bf04; - padding: 9px 14px 19px 14px; - height: 23px; - min-width: 775px; -} -.swagger-section #input_baseUrl { - width: 400px; -} -.swagger-section #api_selector { - display: block; - clear: none; - float: right; -} -.swagger-section #api_selector .input { - display: inline-block; - clear: none; - margin: 0 10px 0 0; -} -.swagger-section #api_selector input { - font-size: 0.9em; - padding: 3px; - margin: 0; -} -.swagger-section #input_apiKey { - width: 200px; -} -.swagger-section #explore, -.swagger-section #auth_container .authorize__btn { - display: block; - text-decoration: none; - font-weight: bold; - padding: 6px 8px; - font-size: 0.9em; - color: white; - background-color: #547f00; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - -o-border-radius: 4px; - -ms-border-radius: 4px; - -khtml-border-radius: 4px; - border-radius: 4px; -} -.swagger-section #explore:hover, -.swagger-section #auth_container .authorize__btn:hover { - background-color: #547f00; -} -.swagger-section #header #logo { - font-size: 1.5em; - font-weight: bold; - text-decoration: none; - color: white; -} -.swagger-section #header #logo .logo__img { - display: block; - float: left; - margin-top: 2px; -} -.swagger-section #header #logo .logo__title { - display: inline-block; - padding: 5px 0 0 10px; -} -.swagger-section #content_message { - margin: 10px 15px; - font-style: italic; - color: #999999; -} -.swagger-section #message-bar { - min-height: 30px; - text-align: center; - padding-top: 10px; -} -.swagger-section .swagger-collapse:before { - content: "-"; -} -.swagger-section .swagger-expand:before { - content: "+"; -} -.swagger-section .error { - outline-color: #cc0000; - background-color: #f2dede; -} diff --git a/public/api/docs/css/style.css b/public/api/docs/css/style.css deleted file mode 100755 index fc21a31db..000000000 --- a/public/api/docs/css/style.css +++ /dev/null @@ -1,250 +0,0 @@ -.swagger-section #header a#logo { - font-size: 1.5em; - font-weight: bold; - text-decoration: none; - background: transparent url(../images/logo.png) no-repeat left center; - padding: 20px 0 20px 40px; -} -#text-head { - font-size: 80px; - font-family: 'Roboto', sans-serif; - color: #ffffff; - float: right; - margin-right: 20%; -} -.navbar-fixed-top .navbar-nav { - height: auto; -} -.navbar-fixed-top .navbar-brand { - height: auto; -} -.navbar-header { - height: auto; -} -.navbar-inverse { - background-color: #000; - border-color: #000; -} -#navbar-brand { - margin-left: 20%; -} -.navtext { - font-size: 10px; -} -.h1, -h1 { - font-size: 60px; -} -.navbar-default .navbar-header .navbar-brand { - color: #a2dfee; -} -/* tag titles */ -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 a { - color: #393939; - font-family: 'Arvo', serif; - font-size: 1.5em; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 a:hover { - color: black; -} -.swagger-section .swagger-ui-wrap ul#resources li.resource div.heading h2 { - color: #525252; - padding-left: 0px; - display: block; - clear: none; - float: left; - font-family: 'Arvo', serif; - font-weight: bold; -} -.navbar-default .navbar-collapse, -.navbar-default .navbar-form { - border-color: #0A0A0A; -} -.container1 { - width: 1500px; - margin: auto; - margin-top: 0; - background-image: url('../images/shield.png'); - background-repeat: no-repeat; - background-position: -40px -20px; - margin-bottom: 210px; -} -.container-inner { - width: 1200px; - margin: auto; - background-color: rgba(223, 227, 228, 0.75); - padding-bottom: 40px; - padding-top: 40px; - border-radius: 15px; -} -.header-content { - padding: 0; - width: 1000px; -} -.title1 { - font-size: 80px; - font-family: 'Vollkorn', serif; - color: #404040; - text-align: center; - padding-top: 40px; - padding-bottom: 100px; -} -#icon { - margin-top: -18px; -} -.subtext { - font-size: 25px; - font-style: italic; - color: #08b; - text-align: right; - padding-right: 250px; -} -.bg-primary { - background-color: #00468b; -} -.navbar-default .nav > li > a, -.navbar-default .nav > li > a:focus { - color: #08b; -} -.navbar-default .nav > li > a, -.navbar-default .nav > li > a:hover { - color: #08b; -} -.navbar-default .nav > li > a, -.navbar-default .nav > li > a:focus:hover { - color: #08b; -} -.text-faded { - font-size: 25px; - font-family: 'Vollkorn', serif; -} -.section-heading { - font-family: 'Vollkorn', serif; - font-size: 45px; - padding-bottom: 10px; -} -hr { - border-color: #00468b; - padding-bottom: 10px; -} -.description { - margin-top: 20px; - padding-bottom: 200px; -} -.description li { - font-family: 'Vollkorn', serif; - font-size: 25px; - color: #525252; - margin-left: 28%; - padding-top: 5px; -} -.gap { - margin-top: 200px; -} -.troubleshootingtext { - color: rgba(255, 255, 255, 0.7); - padding-left: 30%; -} -.troubleshootingtext li { - list-style-type: circle; - font-size: 25px; - padding-bottom: 5px; -} -.overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1000; -} -.block.response_body.json:hover { - cursor: pointer; -} -.backdrop { - color: blue; -} -#myModal { - height: 100%; -} -.modal-backdrop { - bottom: 0; - position: fixed; -} -.curl { - padding: 10px; - font-family: "Anonymous Pro", "Menlo", "Consolas", "Bitstream Vera Sans Mono", "Courier New", monospace; - font-size: 0.9em; - max-height: 400px; - margin-top: 5px; - overflow-y: auto; - background-color: #fcf6db; - border: 1px solid #e5e0c6; - border-radius: 4px; -} -.curl_title { - font-size: 1.1em; - margin: 0; - padding: 15px 0 5px; - font-family: 'Open Sans', 'Helvetica Neue', Arial, sans-serif; - font-weight: 500; - line-height: 1.1; -} -.footer { - display: none; -} -.swagger-section .swagger-ui-wrap h2 { - padding: 0; -} -h2 { - margin: 0; - margin-bottom: 5px; -} -.markdown p { - font-size: 15px; - font-family: 'Arvo', serif; -} -.swagger-section .swagger-ui-wrap .code { - font-size: 15px; - font-family: 'Arvo', serif; -} -.swagger-section .swagger-ui-wrap b { - font-family: 'Arvo', serif; -} -#signin:hover { - cursor: pointer; -} -.dropdown-menu { - padding: 15px; -} -.navbar-right .dropdown-menu { - left: 0; - right: auto; -} -#signinbutton { - width: 100%; - height: 32px; - font-size: 13px; - font-weight: bold; - color: #08b; -} -.navbar-default .nav > li .details { - color: #000000; - text-transform: none; - font-size: 15px; - font-weight: normal; - font-family: 'Open Sans', sans-serif; - font-style: italic; - line-height: 20px; - top: -2px; -} -.navbar-default .nav > li .details:hover { - color: black; -} -#signout { - width: 100%; - height: 32px; - font-size: 13px; - font-weight: bold; - color: #08b; -} diff --git a/public/api/docs/css/typography.css b/public/api/docs/css/typography.css deleted file mode 100755 index efb785fab..000000000 --- a/public/api/docs/css/typography.css +++ /dev/null @@ -1,14 +0,0 @@ -/* Google Font's Droid Sans */ -@font-face { - font-family: 'Droid Sans'; - font-style: normal; - font-weight: 400; - src: local('Droid Sans'), local('DroidSans'), url('../fonts/DroidSans.ttf'), format('truetype'); -} -/* Google Font's Droid Sans Bold */ -@font-face { - font-family: 'Droid Sans'; - font-style: normal; - font-weight: 700; - src: local('Droid Sans Bold'), local('DroidSans-Bold'), url('../fonts/DroidSans-Bold.ttf'), format('truetype'); -} diff --git a/public/api/docs/fonts/DroidSans-Bold.ttf b/public/api/docs/fonts/DroidSans-Bold.ttf deleted file mode 100755 index 036c4d135..000000000 Binary files a/public/api/docs/fonts/DroidSans-Bold.ttf and /dev/null differ diff --git a/public/api/docs/fonts/DroidSans.ttf b/public/api/docs/fonts/DroidSans.ttf deleted file mode 100755 index e517a0c5b..000000000 Binary files a/public/api/docs/fonts/DroidSans.ttf and /dev/null differ diff --git a/public/api/docs/images/collapse.gif b/public/api/docs/images/collapse.gif deleted file mode 100755 index 8843e8ce5..000000000 Binary files a/public/api/docs/images/collapse.gif and /dev/null differ diff --git a/public/api/docs/images/expand.gif b/public/api/docs/images/expand.gif deleted file mode 100755 index 477bf1371..000000000 Binary files a/public/api/docs/images/expand.gif and /dev/null differ diff --git a/public/api/docs/images/explorer_icons.png b/public/api/docs/images/explorer_icons.png deleted file mode 100755 index ed9d2fffb..000000000 Binary files a/public/api/docs/images/explorer_icons.png and /dev/null differ diff --git a/public/api/docs/images/favicon-16x16.png b/public/api/docs/images/favicon-16x16.png deleted file mode 100755 index 66b1a5bfb..000000000 Binary files a/public/api/docs/images/favicon-16x16.png and /dev/null differ diff --git a/public/api/docs/images/favicon-32x32.png b/public/api/docs/images/favicon-32x32.png deleted file mode 100755 index 32f319f89..000000000 Binary files a/public/api/docs/images/favicon-32x32.png and /dev/null differ diff --git a/public/api/docs/images/favicon.ico b/public/api/docs/images/favicon.ico deleted file mode 100755 index 8b60bcf06..000000000 Binary files a/public/api/docs/images/favicon.ico and /dev/null differ diff --git a/public/api/docs/images/logo_small.png b/public/api/docs/images/logo_small.png deleted file mode 100755 index 5496a6557..000000000 Binary files a/public/api/docs/images/logo_small.png and /dev/null differ diff --git a/public/api/docs/images/pet_store_api.png b/public/api/docs/images/pet_store_api.png deleted file mode 100755 index f9f9cd4ae..000000000 Binary files a/public/api/docs/images/pet_store_api.png and /dev/null differ diff --git a/public/api/docs/images/throbber.gif b/public/api/docs/images/throbber.gif deleted file mode 100755 index 063938892..000000000 Binary files a/public/api/docs/images/throbber.gif and /dev/null differ diff --git a/public/api/docs/images/wordnik_api.png b/public/api/docs/images/wordnik_api.png deleted file mode 100755 index dca4f1455..000000000 Binary files a/public/api/docs/images/wordnik_api.png and /dev/null differ diff --git a/public/api/docs/index.html b/public/api/docs/index.html deleted file mode 100755 index 214f601a1..000000000 --- a/public/api/docs/index.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - - Swagger UI - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     
    -
    - - diff --git a/public/api/docs/lang/en.js b/public/api/docs/lang/en.js deleted file mode 100755 index 918313665..000000000 --- a/public/api/docs/lang/en.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Warning: Deprecated", - "Implementation Notes":"Implementation Notes", - "Response Class":"Response Class", - "Status":"Status", - "Parameters":"Parameters", - "Parameter":"Parameter", - "Value":"Value", - "Description":"Description", - "Parameter Type":"Parameter Type", - "Data Type":"Data Type", - "Response Messages":"Response Messages", - "HTTP Status Code":"HTTP Status Code", - "Reason":"Reason", - "Response Model":"Response Model", - "Request URL":"Request URL", - "Response Body":"Response Body", - "Response Code":"Response Code", - "Response Headers":"Response Headers", - "Hide Response":"Hide Response", - "Headers":"Headers", - "Try it out!":"Try it out!", - "Show/Hide":"Show/Hide", - "List Operations":"List Operations", - "Expand Operations":"Expand Operations", - "Raw":"Raw", - "can't parse JSON. Raw result":"can't parse JSON. Raw result", - "Example Value":"Example Value", - "Model Schema":"Model Schema", - "Model":"Model", - "Click to set as parameter value":"Click to set as parameter value", - "apply":"apply", - "Username":"Username", - "Password":"Password", - "Terms of service":"Terms of service", - "Created by":"Created by", - "See more at":"See more at", - "Contact the developer":"Contact the developer", - "api version":"api version", - "Response Content Type":"Response Content Type", - "Parameter content type:":"Parameter content type:", - "fetching resource":"fetching resource", - "fetching resource list":"fetching resource list", - "Explore":"Explore", - "Show Swagger Petstore Example Apis":"Show Swagger Petstore Example Apis", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Can't read from server. It may not have the appropriate access-control-origin settings.", - "Please specify the protocol for":"Please specify the protocol for", - "Can't read swagger JSON from":"Can't read swagger JSON from", - "Finished Loading Resource Information. Rendering Swagger UI":"Finished Loading Resource Information. Rendering Swagger UI", - "Unable to read api":"Unable to read api", - "from path":"from path", - "server returned":"server returned" -}); diff --git a/public/api/docs/lang/es.js b/public/api/docs/lang/es.js deleted file mode 100755 index 13fa015e6..000000000 --- a/public/api/docs/lang/es.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Advertencia: Obsoleto", - "Implementation Notes":"Notas de implementación", - "Response Class":"Clase de la Respuesta", - "Status":"Status", - "Parameters":"Parámetros", - "Parameter":"Parámetro", - "Value":"Valor", - "Description":"Descripción", - "Parameter Type":"Tipo del Parámetro", - "Data Type":"Tipo del Dato", - "Response Messages":"Mensajes de la Respuesta", - "HTTP Status Code":"Código de Status HTTP", - "Reason":"Razón", - "Response Model":"Modelo de la Respuesta", - "Request URL":"URL de la Solicitud", - "Response Body":"Cuerpo de la Respuesta", - "Response Code":"Código de la Respuesta", - "Response Headers":"Encabezados de la Respuesta", - "Hide Response":"Ocultar Respuesta", - "Try it out!":"Pruébalo!", - "Show/Hide":"Mostrar/Ocultar", - "List Operations":"Listar Operaciones", - "Expand Operations":"Expandir Operaciones", - "Raw":"Crudo", - "can't parse JSON. Raw result":"no puede parsear el JSON. Resultado crudo", - "Example Value":"Valor de Ejemplo", - "Model Schema":"Esquema del Modelo", - "Model":"Modelo", - "apply":"aplicar", - "Username":"Nombre de usuario", - "Password":"Contraseña", - "Terms of service":"Términos de Servicio", - "Created by":"Creado por", - "See more at":"Ver más en", - "Contact the developer":"Contactar al desarrollador", - "api version":"versión de la api", - "Response Content Type":"Tipo de Contenido (Content Type) de la Respuesta", - "fetching resource":"buscando recurso", - "fetching resource list":"buscando lista del recurso", - "Explore":"Explorar", - "Show Swagger Petstore Example Apis":"Mostrar Api Ejemplo de Swagger Petstore", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"No se puede leer del servidor. Tal vez no tiene la configuración de control de acceso de origen (access-control-origin) apropiado.", - "Please specify the protocol for":"Por favor, especificar el protocola para", - "Can't read swagger JSON from":"No se puede leer el JSON de swagger desde", - "Finished Loading Resource Information. Rendering Swagger UI":"Finalizada la carga del recurso de Información. Mostrando Swagger UI", - "Unable to read api":"No se puede leer la api", - "from path":"desde ruta", - "server returned":"el servidor retornó" -}); diff --git a/public/api/docs/lang/fr.js b/public/api/docs/lang/fr.js deleted file mode 100755 index 388dff14b..000000000 --- a/public/api/docs/lang/fr.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Avertissement : Obsolète", - "Implementation Notes":"Notes d'implémentation", - "Response Class":"Classe de la réponse", - "Status":"Statut", - "Parameters":"Paramètres", - "Parameter":"Paramètre", - "Value":"Valeur", - "Description":"Description", - "Parameter Type":"Type du paramètre", - "Data Type":"Type de données", - "Response Messages":"Messages de la réponse", - "HTTP Status Code":"Code de statut HTTP", - "Reason":"Raison", - "Response Model":"Modèle de réponse", - "Request URL":"URL appelée", - "Response Body":"Corps de la réponse", - "Response Code":"Code de la réponse", - "Response Headers":"En-têtes de la réponse", - "Hide Response":"Cacher la réponse", - "Headers":"En-têtes", - "Try it out!":"Testez !", - "Show/Hide":"Afficher/Masquer", - "List Operations":"Liste des opérations", - "Expand Operations":"Développer les opérations", - "Raw":"Brut", - "can't parse JSON. Raw result":"impossible de décoder le JSON. Résultat brut", - "Example Value":"Exemple la valeur", - "Model Schema":"Définition du modèle", - "Model":"Modèle", - "apply":"appliquer", - "Username":"Nom d'utilisateur", - "Password":"Mot de passe", - "Terms of service":"Conditions de service", - "Created by":"Créé par", - "See more at":"Voir plus sur", - "Contact the developer":"Contacter le développeur", - "api version":"version de l'api", - "Response Content Type":"Content Type de la réponse", - "fetching resource":"récupération de la ressource", - "fetching resource list":"récupération de la liste de ressources", - "Explore":"Explorer", - "Show Swagger Petstore Example Apis":"Montrer les Apis de l'exemple Petstore de Swagger", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Impossible de lire à partir du serveur. Il se peut que les réglages access-control-origin ne soient pas appropriés.", - "Please specify the protocol for":"Veuillez spécifier un protocole pour", - "Can't read swagger JSON from":"Impossible de lire le JSON swagger à partir de", - "Finished Loading Resource Information. Rendering Swagger UI":"Chargement des informations terminé. Affichage de Swagger UI", - "Unable to read api":"Impossible de lire l'api", - "from path":"à partir du chemin", - "server returned":"réponse du serveur" -}); diff --git a/public/api/docs/lang/geo.js b/public/api/docs/lang/geo.js deleted file mode 100755 index 609c20d9c..000000000 --- a/public/api/docs/lang/geo.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"ყურადღება: აღარ გამოიყენება", - "Implementation Notes":"იმპლემენტაციის აღწერა", - "Response Class":"რესპონს კლასი", - "Status":"სტატუსი", - "Parameters":"პარამეტრები", - "Parameter":"პარამეტრი", - "Value":"მნიშვნელობა", - "Description":"აღწერა", - "Parameter Type":"პარამეტრის ტიპი", - "Data Type":"მონაცემის ტიპი", - "Response Messages":"პასუხი", - "HTTP Status Code":"HTTP სტატუსი", - "Reason":"მიზეზი", - "Response Model":"რესპონს მოდელი", - "Request URL":"მოთხოვნის URL", - "Response Body":"პასუხის სხეული", - "Response Code":"პასუხის კოდი", - "Response Headers":"პასუხის ჰედერები", - "Hide Response":"დამალე პასუხი", - "Headers":"ჰედერები", - "Try it out!":"ცადე !", - "Show/Hide":"გამოჩენა/დამალვა", - "List Operations":"ოპერაციების სია", - "Expand Operations":"ოპერაციები ვრცლად", - "Raw":"ნედლი", - "can't parse JSON. Raw result":"JSON-ის დამუშავება ვერ მოხერხდა. ნედლი პასუხი", - "Example Value":"მაგალითი", - "Model Schema":"მოდელის სტრუქტურა", - "Model":"მოდელი", - "Click to set as parameter value":"პარამეტრისთვის მნიშვნელობის მისანიჭებლად, დააკლიკე", - "apply":"გამოყენება", - "Username":"მოხმარებელი", - "Password":"პაროლი", - "Terms of service":"მომსახურების პირობები", - "Created by":"შექმნა", - "See more at":"ნახე ვრცლად", - "Contact the developer":"დაუკავშირდი დეველოპერს", - "api version":"api ვერსია", - "Response Content Type":"პასუხის კონტენტის ტიპი", - "Parameter content type:":"პარამეტრის კონტენტის ტიპი:", - "fetching resource":"რესურსების მიღება", - "fetching resource list":"რესურსების სიის მიღება", - "Explore":"ნახვა", - "Show Swagger Petstore Example Apis":"ნახე Swagger Petstore სამაგალითო Api", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"სერვერთან დაკავშირება ვერ ხერხდება. შეამოწმეთ access-control-origin.", - "Please specify the protocol for":"მიუთითეთ პროტოკოლი", - "Can't read swagger JSON from":"swagger JSON წაკითხვა ვერ მოხერხდა", - "Finished Loading Resource Information. Rendering Swagger UI":"რესურსების ჩატვირთვა სრულდება. Swagger UI რენდერდება", - "Unable to read api":"api წაკითხვა ვერ მოხერხდა", - "from path":"მისამართიდან", - "server returned":"სერვერმა დააბრუნა" -}); diff --git a/public/api/docs/lang/it.js b/public/api/docs/lang/it.js deleted file mode 100755 index 8529c2a90..000000000 --- a/public/api/docs/lang/it.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Attenzione: Deprecato", - "Implementation Notes":"Note di implementazione", - "Response Class":"Classe della risposta", - "Status":"Stato", - "Parameters":"Parametri", - "Parameter":"Parametro", - "Value":"Valore", - "Description":"Descrizione", - "Parameter Type":"Tipo di parametro", - "Data Type":"Tipo di dato", - "Response Messages":"Messaggi della risposta", - "HTTP Status Code":"Codice stato HTTP", - "Reason":"Motivo", - "Response Model":"Modello di risposta", - "Request URL":"URL della richiesta", - "Response Body":"Corpo della risposta", - "Response Code":"Oggetto della risposta", - "Response Headers":"Intestazioni della risposta", - "Hide Response":"Nascondi risposta", - "Try it out!":"Provalo!", - "Show/Hide":"Mostra/Nascondi", - "List Operations":"Mostra operazioni", - "Expand Operations":"Espandi operazioni", - "Raw":"Grezzo (raw)", - "can't parse JSON. Raw result":"non è possibile parsare il JSON. Risultato grezzo (raw).", - "Model Schema":"Schema del modello", - "Model":"Modello", - "apply":"applica", - "Username":"Nome utente", - "Password":"Password", - "Terms of service":"Condizioni del servizio", - "Created by":"Creato da", - "See more at":"Informazioni aggiuntive:", - "Contact the developer":"Contatta lo sviluppatore", - "api version":"versione api", - "Response Content Type":"Tipo di contenuto (content type) della risposta", - "fetching resource":"recuperando la risorsa", - "fetching resource list":"recuperando lista risorse", - "Explore":"Esplora", - "Show Swagger Petstore Example Apis":"Mostra le api di esempio di Swagger Petstore", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Non è possibile leggere dal server. Potrebbe non avere le impostazioni di controllo accesso origine (access-control-origin) appropriate.", - "Please specify the protocol for":"Si prega di specificare il protocollo per", - "Can't read swagger JSON from":"Impossibile leggere JSON swagger da:", - "Finished Loading Resource Information. Rendering Swagger UI":"Lettura informazioni risorse termianta. Swagger UI viene mostrata", - "Unable to read api":"Impossibile leggere la api", - "from path":"da cartella", - "server returned":"il server ha restituito" -}); diff --git a/public/api/docs/lang/ja.js b/public/api/docs/lang/ja.js deleted file mode 100755 index 3207bfc0b..000000000 --- a/public/api/docs/lang/ja.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"警告: 廃止予定", - "Implementation Notes":"実装メモ", - "Response Class":"レスポンスクラス", - "Status":"ステータス", - "Parameters":"パラメータ群", - "Parameter":"パラメータ", - "Value":"値", - "Description":"説明", - "Parameter Type":"パラメータタイプ", - "Data Type":"データタイプ", - "Response Messages":"レスポンスメッセージ", - "HTTP Status Code":"HTTPステータスコード", - "Reason":"理由", - "Response Model":"レスポンスモデル", - "Request URL":"リクエストURL", - "Response Body":"レスポンスボディ", - "Response Code":"レスポンスコード", - "Response Headers":"レスポンスヘッダ", - "Hide Response":"レスポンスを隠す", - "Headers":"ヘッダ", - "Try it out!":"実際に実行!", - "Show/Hide":"表示/非表示", - "List Operations":"操作一覧", - "Expand Operations":"操作の展開", - "Raw":"Raw", - "can't parse JSON. Raw result":"JSONへ解釈できません. 未加工の結果", - "Model Schema":"モデルスキーマ", - "Model":"モデル", - "apply":"実行", - "Username":"ユーザ名", - "Password":"パスワード", - "Terms of service":"サービス利用規約", - "Created by":"Created by", - "See more at":"See more at", - "Contact the developer":"開発者に連絡", - "api version":"APIバージョン", - "Response Content Type":"レスポンス コンテンツタイプ", - "fetching resource":"リソースの取得", - "fetching resource list":"リソース一覧の取得", - "Explore":"Explore", - "Show Swagger Petstore Example Apis":"SwaggerペットストアAPIの表示", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"サーバから読み込めません. 適切なaccess-control-origin設定を持っていない可能性があります.", - "Please specify the protocol for":"プロトコルを指定してください", - "Can't read swagger JSON from":"次からswagger JSONを読み込めません", - "Finished Loading Resource Information. Rendering Swagger UI":"リソース情報の読み込みが完了しました. Swagger UIを描画しています", - "Unable to read api":"APIを読み込めません", - "from path":"次のパスから", - "server returned":"サーバからの返答" -}); diff --git a/public/api/docs/lang/ko-kr.js b/public/api/docs/lang/ko-kr.js deleted file mode 100755 index 03c7626d7..000000000 --- a/public/api/docs/lang/ko-kr.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"경고:폐기예정됨", - "Implementation Notes":"구현 노트", - "Response Class":"응답 클래스", - "Status":"상태", - "Parameters":"매개변수들", - "Parameter":"매개변수", - "Value":"값", - "Description":"설명", - "Parameter Type":"매개변수 타입", - "Data Type":"데이터 타입", - "Response Messages":"응답 메세지", - "HTTP Status Code":"HTTP 상태 코드", - "Reason":"원인", - "Response Model":"응답 모델", - "Request URL":"요청 URL", - "Response Body":"응답 본문", - "Response Code":"응답 코드", - "Response Headers":"응답 헤더", - "Hide Response":"응답 숨기기", - "Headers":"헤더", - "Try it out!":"써보기!", - "Show/Hide":"보이기/숨기기", - "List Operations":"목록 작업", - "Expand Operations":"전개 작업", - "Raw":"원본", - "can't parse JSON. Raw result":"JSON을 파싱할수 없음. 원본결과:", - "Model Schema":"모델 스키마", - "Model":"모델", - "apply":"적용", - "Username":"사용자 이름", - "Password":"암호", - "Terms of service":"이용약관", - "Created by":"작성자", - "See more at":"추가정보:", - "Contact the developer":"개발자에게 문의", - "api version":"api버전", - "Response Content Type":"응답Content Type", - "fetching resource":"리소스 가져오기", - "fetching resource list":"리소스 목록 가져오기", - "Explore":"탐색", - "Show Swagger Petstore Example Apis":"Swagger Petstore 예제 보기", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"서버로부터 읽어들일수 없습니다. access-control-origin 설정이 올바르지 않을수 있습니다.", - "Please specify the protocol for":"다음을 위한 프로토콜을 정하세요", - "Can't read swagger JSON from":"swagger JSON 을 다음으로 부터 읽을수 없습니다", - "Finished Loading Resource Information. Rendering Swagger UI":"리소스 정보 불러오기 완료. Swagger UI 랜더링", - "Unable to read api":"api를 읽을 수 없습니다.", - "from path":"다음 경로로 부터", - "server returned":"서버 응답함." -}); diff --git a/public/api/docs/lang/pl.js b/public/api/docs/lang/pl.js deleted file mode 100755 index ce41e9179..000000000 --- a/public/api/docs/lang/pl.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Uwaga: Wycofane", - "Implementation Notes":"Uwagi Implementacji", - "Response Class":"Klasa Odpowiedzi", - "Status":"Status", - "Parameters":"Parametry", - "Parameter":"Parametr", - "Value":"Wartość", - "Description":"Opis", - "Parameter Type":"Typ Parametru", - "Data Type":"Typ Danych", - "Response Messages":"Wiadomości Odpowiedzi", - "HTTP Status Code":"Kod Statusu HTTP", - "Reason":"Przyczyna", - "Response Model":"Model Odpowiedzi", - "Request URL":"URL Wywołania", - "Response Body":"Treść Odpowiedzi", - "Response Code":"Kod Odpowiedzi", - "Response Headers":"Nagłówki Odpowiedzi", - "Hide Response":"Ukryj Odpowiedź", - "Headers":"Nagłówki", - "Try it out!":"Wypróbuj!", - "Show/Hide":"Pokaż/Ukryj", - "List Operations":"Lista Operacji", - "Expand Operations":"Rozwiń Operacje", - "Raw":"Nieprzetworzone", - "can't parse JSON. Raw result":"nie można przetworzyć pliku JSON. Nieprzetworzone dane", - "Model Schema":"Schemat Modelu", - "Model":"Model", - "apply":"użyj", - "Username":"Nazwa użytkownika", - "Password":"Hasło", - "Terms of service":"Warunki używania", - "Created by":"Utworzone przez", - "See more at":"Zobacz więcej na", - "Contact the developer":"Kontakt z deweloperem", - "api version":"wersja api", - "Response Content Type":"Typ Zasobu Odpowiedzi", - "fetching resource":"ładowanie zasobu", - "fetching resource list":"ładowanie listy zasobów", - "Explore":"Eksploruj", - "Show Swagger Petstore Example Apis":"Pokaż Przykładowe Api Swagger Petstore", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Brak połączenia z serwerem. Może on nie mieć odpowiednich ustawień access-control-origin.", - "Please specify the protocol for":"Proszę podać protokół dla", - "Can't read swagger JSON from":"Nie można odczytać swagger JSON z", - "Finished Loading Resource Information. Rendering Swagger UI":"Ukończono Ładowanie Informacji o Zasobie. Renderowanie Swagger UI", - "Unable to read api":"Nie można odczytać api", - "from path":"ze ścieżki", - "server returned":"serwer zwrócił" -}); diff --git a/public/api/docs/lang/pt.js b/public/api/docs/lang/pt.js deleted file mode 100755 index f2e7c13d4..000000000 --- a/public/api/docs/lang/pt.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Aviso: Depreciado", - "Implementation Notes":"Notas de Implementação", - "Response Class":"Classe de resposta", - "Status":"Status", - "Parameters":"Parâmetros", - "Parameter":"Parâmetro", - "Value":"Valor", - "Description":"Descrição", - "Parameter Type":"Tipo de parâmetro", - "Data Type":"Tipo de dados", - "Response Messages":"Mensagens de resposta", - "HTTP Status Code":"Código de status HTTP", - "Reason":"Razão", - "Response Model":"Modelo resposta", - "Request URL":"URL requisição", - "Response Body":"Corpo da resposta", - "Response Code":"Código da resposta", - "Response Headers":"Cabeçalho da resposta", - "Headers":"Cabeçalhos", - "Hide Response":"Esconder resposta", - "Try it out!":"Tente agora!", - "Show/Hide":"Mostrar/Esconder", - "List Operations":"Listar operações", - "Expand Operations":"Expandir operações", - "Raw":"Cru", - "can't parse JSON. Raw result":"Falha ao analisar JSON. Resulto cru", - "Model Schema":"Modelo esquema", - "Model":"Modelo", - "apply":"Aplicar", - "Username":"Usuário", - "Password":"Senha", - "Terms of service":"Termos do serviço", - "Created by":"Criado por", - "See more at":"Veja mais em", - "Contact the developer":"Contate o desenvolvedor", - "api version":"Versão api", - "Response Content Type":"Tipo de conteúdo da resposta", - "fetching resource":"busca recurso", - "fetching resource list":"buscando lista de recursos", - "Explore":"Explorar", - "Show Swagger Petstore Example Apis":"Show Swagger Petstore Example Apis", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Não é possível ler do servidor. Pode não ter as apropriadas configurações access-control-origin", - "Please specify the protocol for":"Por favor especifique o protocolo", - "Can't read swagger JSON from":"Não é possível ler o JSON Swagger de", - "Finished Loading Resource Information. Rendering Swagger UI":"Carregar informação de recurso finalizada. Renderizando Swagger UI", - "Unable to read api":"Não foi possível ler api", - "from path":"do caminho", - "server returned":"servidor retornou" -}); diff --git a/public/api/docs/lang/ru.js b/public/api/docs/lang/ru.js deleted file mode 100755 index 592744e95..000000000 --- a/public/api/docs/lang/ru.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Предупреждение: Устарело", - "Implementation Notes":"Заметки", - "Response Class":"Пример ответа", - "Status":"Статус", - "Parameters":"Параметры", - "Parameter":"Параметр", - "Value":"Значение", - "Description":"Описание", - "Parameter Type":"Тип параметра", - "Data Type":"Тип данных", - "HTTP Status Code":"HTTP код", - "Reason":"Причина", - "Response Model":"Структура ответа", - "Request URL":"URL запроса", - "Response Body":"Тело ответа", - "Response Code":"HTTP код ответа", - "Response Headers":"Заголовки ответа", - "Hide Response":"Спрятать ответ", - "Headers":"Заголовки", - "Response Messages":"Что может прийти в ответ", - "Try it out!":"Попробовать!", - "Show/Hide":"Показать/Скрыть", - "List Operations":"Операции кратко", - "Expand Operations":"Операции подробно", - "Raw":"В сыром виде", - "can't parse JSON. Raw result":"Не удается распарсить ответ:", - "Example Value":"Пример", - "Model Schema":"Структура", - "Model":"Описание", - "Click to set as parameter value":"Нажмите, чтобы испльзовать в качестве значения параметра", - "apply":"применить", - "Username":"Имя пользователя", - "Password":"Пароль", - "Terms of service":"Условия использования", - "Created by":"Разработано", - "See more at":"Еще тут", - "Contact the developer":"Связаться с разработчиком", - "api version":"Версия API", - "Response Content Type":"Content Type ответа", - "Parameter content type:":"Content Type параметра:", - "fetching resource":"Получение ресурса", - "fetching resource list":"Получение ресурсов", - "Explore":"Показать", - "Show Swagger Petstore Example Apis":"Показать примеры АПИ", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Не удается получить ответ от сервера. Возможно, проблема с настройками доступа", - "Please specify the protocol for":"Пожалуйста, укажите протокол для", - "Can't read swagger JSON from":"Не получается прочитать swagger json из", - "Finished Loading Resource Information. Rendering Swagger UI":"Загрузка информации о ресурсах завершена. Рендерим", - "Unable to read api":"Не удалось прочитать api", - "from path":"по адресу", - "server returned":"сервер сказал" -}); diff --git a/public/api/docs/lang/tr.js b/public/api/docs/lang/tr.js deleted file mode 100755 index 16426a9c3..000000000 --- a/public/api/docs/lang/tr.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"Uyarı: Deprecated", - "Implementation Notes":"Gerçekleştirim Notları", - "Response Class":"Dönen Sınıf", - "Status":"Statü", - "Parameters":"Parametreler", - "Parameter":"Parametre", - "Value":"Değer", - "Description":"Açıklama", - "Parameter Type":"Parametre Tipi", - "Data Type":"Veri Tipi", - "Response Messages":"Dönüş Mesajı", - "HTTP Status Code":"HTTP Statü Kodu", - "Reason":"Gerekçe", - "Response Model":"Dönüş Modeli", - "Request URL":"İstek URL", - "Response Body":"Dönüş İçeriği", - "Response Code":"Dönüş Kodu", - "Response Headers":"Dönüş Üst Bilgileri", - "Hide Response":"Dönüşü Gizle", - "Headers":"Üst Bilgiler", - "Try it out!":"Dene!", - "Show/Hide":"Göster/Gizle", - "List Operations":"Operasyonları Listele", - "Expand Operations":"Operasyonları Aç", - "Raw":"Ham", - "can't parse JSON. Raw result":"JSON çözümlenemiyor. Ham sonuç", - "Model Schema":"Model Şema", - "Model":"Model", - "apply":"uygula", - "Username":"Kullanıcı Adı", - "Password":"Parola", - "Terms of service":"Servis şartları", - "Created by":"Oluşturan", - "See more at":"Daha fazlası için", - "Contact the developer":"Geliştirici ile İletişime Geçin", - "api version":"api versiyon", - "Response Content Type":"Dönüş İçerik Tipi", - "fetching resource":"kaynak getiriliyor", - "fetching resource list":"kaynak listesi getiriliyor", - "Explore":"Keşfet", - "Show Swagger Petstore Example Apis":"Swagger Petstore Örnek Api'yi Gör", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"Sunucudan okuma yapılamıyor. Sunucu access-control-origin ayarlarınızı kontrol edin.", - "Please specify the protocol for":"Lütfen istenen adres için protokol belirtiniz", - "Can't read swagger JSON from":"Swagger JSON bu kaynaktan okunamıyor", - "Finished Loading Resource Information. Rendering Swagger UI":"Kaynak baglantısı tamamlandı. Swagger UI gösterime hazırlanıyor", - "Unable to read api":"api okunamadı", - "from path":"yoldan", - "server returned":"sunucuya dönüldü" -}); diff --git a/public/api/docs/lang/translator.js b/public/api/docs/lang/translator.js deleted file mode 100755 index ffb879f9a..000000000 --- a/public/api/docs/lang/translator.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; - -/** - * Translator for documentation pages. - * - * To enable translation you should include one of language-files in your index.html - * after . - * For example - - * - * If you wish to translate some new texts you should do two things: - * 1. Add a new phrase pair ("New Phrase": "New Translation") into your language file (for example lang/ru.js). It will be great if you add it in other language files too. - * 2. Mark that text it templates this way New Phrase or . - * The main thing here is attribute data-sw-translate. Only inner html, title-attribute and value-attribute are going to translate. - * - */ -window.SwaggerTranslator = { - - _words:[], - - translate: function(sel) { - var $this = this; - sel = sel || '[data-sw-translate]'; - - $(sel).each(function() { - $(this).html($this._tryTranslate($(this).html())); - - $(this).val($this._tryTranslate($(this).val())); - $(this).attr('title', $this._tryTranslate($(this).attr('title'))); - }); - }, - - _tryTranslate: function(word) { - return this._words[$.trim(word)] !== undefined ? this._words[$.trim(word)] : word; - }, - - learn: function(wordsMap) { - this._words = wordsMap; - } -}; diff --git a/public/api/docs/lang/zh-cn.js b/public/api/docs/lang/zh-cn.js deleted file mode 100755 index 570319ba1..000000000 --- a/public/api/docs/lang/zh-cn.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/* jshint quotmark: double */ -window.SwaggerTranslator.learn({ - "Warning: Deprecated":"警告:已过时", - "Implementation Notes":"实现备注", - "Response Class":"响应类", - "Status":"状态", - "Parameters":"参数", - "Parameter":"参数", - "Value":"值", - "Description":"描述", - "Parameter Type":"参数类型", - "Data Type":"数据类型", - "Response Messages":"响应消息", - "HTTP Status Code":"HTTP状态码", - "Reason":"原因", - "Response Model":"响应模型", - "Request URL":"请求URL", - "Response Body":"响应体", - "Response Code":"响应码", - "Response Headers":"响应头", - "Hide Response":"隐藏响应", - "Headers":"头", - "Try it out!":"试一下!", - "Show/Hide":"显示/隐藏", - "List Operations":"显示操作", - "Expand Operations":"展开操作", - "Raw":"原始", - "can't parse JSON. Raw result":"无法解析JSON. 原始结果", - "Model Schema":"模型架构", - "Model":"模型", - "apply":"应用", - "Username":"用户名", - "Password":"密码", - "Terms of service":"服务条款", - "Created by":"创建者", - "See more at":"查看更多:", - "Contact the developer":"联系开发者", - "api version":"api版本", - "Response Content Type":"响应Content Type", - "fetching resource":"正在获取资源", - "fetching resource list":"正在获取资源列表", - "Explore":"浏览", - "Show Swagger Petstore Example Apis":"显示 Swagger Petstore 示例 Apis", - "Can't read from server. It may not have the appropriate access-control-origin settings.":"无法从服务器读取。可能没有正确设置access-control-origin。", - "Please specify the protocol for":"请指定协议:", - "Can't read swagger JSON from":"无法读取swagger JSON于", - "Finished Loading Resource Information. Rendering Swagger UI":"已加载资源信息。正在渲染Swagger UI", - "Unable to read api":"无法读取api", - "from path":"从路径", - "server returned":"服务器返回" -}); diff --git a/public/api/docs/lib/backbone-min.js b/public/api/docs/lib/backbone-min.js deleted file mode 100755 index a3f544be6..000000000 --- a/public/api/docs/lib/backbone-min.js +++ /dev/null @@ -1,15 +0,0 @@ -// Backbone.js 1.1.2 - -(function(t,e){if(typeof define==="function"&&define.amd){define(["underscore","jquery","exports"],function(i,r,s){t.Backbone=e(t,s,i,r)})}else if(typeof exports!=="undefined"){var i=require("underscore");e(t,exports,i)}else{t.Backbone=e(t,{},t._,t.jQuery||t.Zepto||t.ender||t.$)}})(this,function(t,e,i,r){var s=t.Backbone;var n=[];var a=n.push;var o=n.slice;var h=n.splice;e.VERSION="1.1.2";e.$=r;e.noConflict=function(){t.Backbone=s;return this};e.emulateHTTP=false;e.emulateJSON=false;var u=e.Events={on:function(t,e,i){if(!c(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,r){if(!c(this,"once",t,[e,r])||!e)return this;var s=this;var n=i.once(function(){s.off(t,n);e.apply(this,arguments)});n._callback=e;return this.on(t,n,r)},off:function(t,e,r){var s,n,a,o,h,u,l,f;if(!this._events||!c(this,"off",t,[e,r]))return this;if(!t&&!e&&!r){this._events=void 0;return this}o=t?[t]:i.keys(this._events);for(h=0,u=o.length;h").attr(t);this.setElement(r,false)}else{this.setElement(i.result(this,"el"),false)}}});e.sync=function(t,r,s){var n=T[t];i.defaults(s||(s={}),{emulateHTTP:e.emulateHTTP,emulateJSON:e.emulateJSON});var a={type:n,dataType:"json"};if(!s.url){a.url=i.result(r,"url")||M()}if(s.data==null&&r&&(t==="create"||t==="update"||t==="patch")){a.contentType="application/json";a.data=JSON.stringify(s.attrs||r.toJSON(s))}if(s.emulateJSON){a.contentType="application/x-www-form-urlencoded";a.data=a.data?{model:a.data}:{}}if(s.emulateHTTP&&(n==="PUT"||n==="DELETE"||n==="PATCH")){a.type="POST";if(s.emulateJSON)a.data._method=n;var o=s.beforeSend;s.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",n);if(o)return o.apply(this,arguments)}}if(a.type!=="GET"&&!s.emulateJSON){a.processData=false}if(a.type==="PATCH"&&k){a.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var h=s.xhr=e.ajax(i.extend(a,s));r.trigger("request",r,h,s);return h};var k=typeof window!=="undefined"&&!!window.ActiveXObject&&!(window.XMLHttpRequest&&(new XMLHttpRequest).dispatchEvent);var T={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};e.ajax=function(){return e.$.ajax.apply(e.$,arguments)};var $=e.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var S=/\((.*?)\)/g;var H=/(\(\?)?:\w+/g;var A=/\*\w+/g;var I=/[\-{}\[\]+?.,\\\^$|#\s]/g;i.extend($.prototype,u,{initialize:function(){},route:function(t,r,s){if(!i.isRegExp(t))t=this._routeToRegExp(t);if(i.isFunction(r)){s=r;r=""}if(!s)s=this[r];var n=this;e.history.route(t,function(i){var a=n._extractParameters(t,i);n.execute(s,a);n.trigger.apply(n,["route:"+r].concat(a));n.trigger("route",r,a);e.history.trigger("route",n,r,a)});return this},execute:function(t,e){if(t)t.apply(this,e)},navigate:function(t,i){e.history.navigate(t,i);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=i.result(this,"routes");var t,e=i.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(I,"\\$&").replace(S,"(?:$1)?").replace(H,function(t,e){return e?t:"([^/?]+)"}).replace(A,"([^?]*?)");return new RegExp("^"+t+"(?:\\?([\\s\\S]*))?$")},_extractParameters:function(t,e){var r=t.exec(e).slice(1);return i.map(r,function(t,e){if(e===r.length-1)return t||null;return t?decodeURIComponent(t):null})}});var N=e.History=function(){this.handlers=[];i.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var R=/^[#\/]|\s+$/g;var O=/^\/+|\/+$/g;var P=/msie [\w.]+/;var C=/\/$/;var j=/#.*$/;N.started=false;i.extend(N.prototype,u,{interval:50,atRoot:function(){return this.location.pathname.replace(/[^\/]$/,"$&/")===this.root},getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=decodeURI(this.location.pathname+this.location.search);var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.slice(i.length)}else{t=this.getHash()}}return t.replace(R,"")},start:function(t){if(N.started)throw new Error("Backbone.history has already been started");N.started=true;this.options=i.extend({root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var r=this.getFragment();var s=document.documentMode;var n=P.exec(navigator.userAgent.toLowerCase())&&(!s||s<=7);this.root=("/"+this.root+"/").replace(O,"/");if(n&&this._wantsHashChange){var a=e.$('