diff --git a/.circleci/config.yml b/.circleci/config.yml index 1fde01d9c..66b3c02c3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ jobs: rubocop: docker: - - image: 'cimg/ruby:3.2' + - image: "cimg/ruby:3.2" steps: - checkout - ruby/install-deps @@ -11,36 +11,40 @@ jobs: test: docker: - - image: 'cimg/ruby:3.2-browsers' - - image: 'circleci/postgres:12.0-alpine-ram' + - image: "cimg/ruby:3.2-browsers" + - image: "circleci/postgres:12.0-alpine-ram" environment: POSTGRES_DB: choco_cake_test POSTGRES_PASSWORD: password POSTGRES_USER: choco - - image: 'circleci/redis:6.2-alpine' + - image: "circleci/redis:6.2-alpine" environment: - BUNDLE_JOBS: '3' - BUNDLE_RETRY: '3' - PAGER: '' + BUNDLE_JOBS: "3" + BUNDLE_RETRY: "3" + PAGER: "" POSTGRES_DB: choco_cake_test POSTGRES_PASSWORD: password POSTGRES_USER: choco POSTGRES_HOST: "127.0.0.1" RAILS_ENV: test + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: primary-key + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: deterministic-key + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: derivation-salt + EDITOR_ENCRYPTION_KEY: a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0 steps: - checkout - # - browser-tools/install-firefox + - browser-tools/install-firefox - ruby/install-deps: key: gems-v2- - run: - command: 'dockerize -wait tcp://localhost:5432 -timeout 1m' + command: "dockerize -wait tcp://localhost:5432 -timeout 1m" name: Wait for DB - run: - command: 'sudo apt-get update && sudo apt-get install --yes --no-install-recommends postgresql-client jq curl imagemagick' + command: "sudo apt-get update && sudo apt-get install --yes --no-install-recommends postgresql-client jq curl imagemagick" name: Install postgres client, jq, curl, imagemagick - run: - command: 'bin/rails db:setup --trace' + command: "bin/rails db:setup --trace" name: Database setup - ruby/rspec-test - store_artifacts: diff --git a/.clabot b/.clabot index 87f7255bf..52188e575 100644 --- a/.clabot +++ b/.clabot @@ -1,4 +1,29 @@ { - "contributors": ["loiswells97", "patch0", "IzzySmillie", "pjbRPF", "m-bowley", "sra405", "magdalenajadach", "tessyraspberrypi", "danhalson", "PetarSimonovic", "grega", "MarcScott"], - "message": "We require contributors to sign our Contributor License Agreement, and we don\"t have you on file. In order for us to review and merge your code, please contact @RaspberryPiFoundation/digital-products-learner-experience to get yourself added." + "contributors": [ + "ce-rpf", + "chris.lowis@gofreerange.com", + "chrisroos", + "conorriches", + "create-issue-branch", + "create-issue-branch[bot]", + "danhalson", + "dependabot[bot]", + "grega", + "IzzySmillie", + "james.mead+rpf@gofreerange.com", + "jamiebenstead", + "jrmhaig", + "KristinaDudnyk", + "lpmi-13", + "loiswells97", + "m-bowley", + "magdalenajadach", + "MarcScott", + "pjbRPF", + "patch0", + "PetarSimonovic", + "sra405", + "tuzz" + ], + "message": "We require contributors to sign our Contributor License Agreement, and we don't have you on file. In order for us to review and merge your code, please complete [this form](https://form.raspberrypi.org/4873530) and we'll get you added and review your contribution as soon as possible." } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fdb84e3ad..e33c14fcb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,13 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose { - "name": "editor-api", + "name": "editor-api (dev-container)", // Update the 'dockerComposeFile' list if you have more compose files or use different names. // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. "dockerComposeFile": [ - "../docker-compose.yml" + "../docker-compose.yml", + "docker-compose.yml" ], // The 'service' property is the name of the service for the container that VS Code should @@ -22,7 +23,8 @@ "features": { "ghcr.io/devcontainers-contrib/features/zsh-plugins:0": { "omzPlugins": "git docker-compose macos zsh-autosuggestions yarn fzf-zsh-plugin asdf zsh-nvm" - } + }, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} }, // Use 'forwardPorts' to make a list of ports inside the container available locally. @@ -34,7 +36,7 @@ // "runServices": [], // Uncomment the next line if you want to keep your containers running after VS Code shuts down. - "shutdownAction": "stopCompose", + "shutdownAction": "none", // Uncomment the next line to run commands after the container is created. // "postCreateCommand": "", @@ -44,8 +46,6 @@ "vscode": { "extensions": [ "ms-azuretools.vscode-docker", - "ninoseki.vscode-gem-lens", - "rebornix.ruby", "eamodio.gitlens", "github.vscode-pull-request-github", "wmaurer.change-case", @@ -56,7 +56,6 @@ "hashicorp.terraform", "yzhang.markdown-all-in-one", "mikestead.dotenv", - "wingrunr21.vscode-ruby", "ms-vscode.remote-repositories", "github.remotehub", "circleci.circleci", @@ -64,17 +63,23 @@ "christian-kohler.path-intellisense", "esbenp.prettier-vscode", "syler.sass-indented", - "codezombiech.gitignore" + "codezombiech.gitignore", + "shopify.ruby-lsp", + "koichisasada.vscode-rdbg", + "postman.postman-for-vscode", + "ninoseki.vscode-mogami" ], "settings": { - "terminal.integrated.defaultProfile.linux": "zsh" + "terminal.integrated.defaultProfile.linux": "zsh", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "rubyLsp.enabledFeatures": { + "diagnostics": true + } } } - }, - - "mounts": [ - "source=vscode_extensions,target=/root/.vscode-server/extensions,type=volume" - ] + } // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "devcontainer" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..c43b9d2cd --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,14 @@ +services: + api: + build: + context: . + target: dev-container + command: bash bin/docker-debug-entrypoint.sh + volumes: + # - ${HOME}/.bashrc:/root/.bashrc:ro # Map a ~/.bashrc in your home directory for customising bash + - ${HOME}/.ssh:/root/.ssh:ro # To share any ssh keys with the container + - /var/run/docker.sock:/var/run/docker.sock + - node_modules:/app/node_modules + +volumes: + node_modules: null diff --git a/.env.example b/.env.example index b22d6e169..173e415e2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ -ALLOWED_ORIGINS=localhost:3002,localhost:3000 +# localhost is set as an origin by default in development (config/initializers/cors.rb) +# so you probably only need to set ALLOWED_ORIGINS for debugging purposes +ALLOWED_ORIGINS="" AWS_ACCESS_KEY_ID=changeme AWS_S3_ACTIVE_STORAGE_BUCKET=changeme @@ -6,16 +8,42 @@ AWS_S3_REGION=changeme AWS_SECRET_ACCESS_KEY=changeme GITHUB_WEBHOOK_SECRET=test_token +GITHUB_WEBHOOK_REF="refs/heads/draft" -POSTGRES_HOST=changeme -POSTGRES_USER=changeme -POSTGRES_PASSWORD=changeme +POSTGRES_HOST=db +POSTGRES_PASSWORD=password +POSTGRES_USER=postgres -HYDRA_ADMIN_URL=http://host.docker.internal:9002 -HYDRA_ADMIN_API_KEY=test-key +USERINFO_API_URL=http://host.docker.internal:6000 # Internal docker address +USERINFO_API_KEY=1234 + +HYDRA_PUBLIC_URL=http://localhost:9001 # External docker address +HYDRA_PUBLIC_API_URL=http://host.docker.internal:9001 # Internal docker address +HYDRA_PUBLIC_TOKEN_URL=http://host.docker.internal:9001 # Internal docker address +HYDRA_CLIENT_ID=editor-dashboard-dev +HYDRA_CLIENT_SECRET=secret + +IDENTITY_URL=http://host.docker.internal:3002 # Internal docker address SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX -# Add the below to bypass token authentication with hyrdra -# BYPASS_AUTH=true -# AUTH_USER_ID=<> +# Add the below to bypass API token authentication with hydra +# BYPASS_OAUTH=true + +HOST_URL=http://localhost:3009 +EDITOR_PUBLIC_URL=http://localhost:3012 + +PROFILE_API_KEY=test # This has to match the value set in Profile (https://github.com/RaspberryPiFoundation/profile/blob/ca10a4f360b6fe2b04be76264e03283054126b0f/.env.example#L45). + +# The application is configured to log sent emails in development. If +# you need to send emails using Postmark set the following environment +# variable. API tokens are available through the postmark interface: +# https://account.postmarkapp.com/servers +# POSTMARK_API_TOKEN= + +# Run bin/rails db:encryption:init to generate values for these if you need to encrypt securely locally. +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=primary-key +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=deterministic-key +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=derivation-salt + +EDITOR_ENCRYPTION_KEY=a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0a5672dde --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: "bundler" + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..d25ca32d8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## Status + +- Closes _add issue numbers or delete_ +- Related to _add issue numbers or delete_ + +## Points for consideration: + +- Security +- Performance + +## What's changed? + +_Description of what's been done - bullets are often best_ + +## Steps to perform after deploying to production + +_If the production environment requires any extra work after this PR has been deployed detail it here. This could be running a Rake task, a migration, or upgrading a Gem. That kind of thing._ diff --git a/.gitignore b/.gitignore index 08019486d..e9f86c3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ /coverage .byebug_history + +docker-compose.override.yml diff --git a/.rubocop.yml b/.rubocop.yml index 0f1b4ec4f..3e76e5268 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,7 @@ --- -require: rubocop-graphql +require: + - rubocop-graphql + - rubocop-capybara inherit_from: - https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-base.yml - https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-rails.yml @@ -27,3 +29,19 @@ RSpec/DescribeClass: RSpec/MultipleMemoizedHelpers: Max: 8 + +RSpec/ExampleLength: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Layout/LineLength: + Enabled: false + +Naming/VariableNumber: + EnforcedStyle: snake_case + AllowedIdentifiers: sha256, X-Hub-Signature-256 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 86e143de7..3cab9fdc4 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,7 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2023-02-20 08:27:43 UTC using RuboCop version 1.39.0. +# on 2024-04-17 15:45:30 UTC using RuboCop version 1.47.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. - -# Offense count: 2 -Lint/UselessAssignment: - Exclude: - - 'lib/tasks/projects.rake' diff --git a/.ruby-version b/.ruby-version index 72b3400f1..ab96aa90d 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.2.1 +ruby-3.2.3 diff --git a/.tool-versions b/.tool-versions index 40b87ee46..c1c77c322 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.2.1 +ruby 3.2.3 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..a16b483f3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "ruby_lsp", + "name": "Debug", + "request": "launch", + "program": "ruby ${file}", + }, + { + "type": "ruby_lsp", + "request": "launch", + "name": "Debug test file", + "program": "ruby -Itest ${relativeFile}", + }, + { + "type": "ruby_lsp", + "request": "attach", + "name": "Attach to existing server", + }, + ], +} diff --git a/Dockerfile b/Dockerfile index bd09ce8c1..5de430110 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,14 @@ FROM ruby:3.2-slim-bullseye as base RUN gem install bundler \ && apt-get update \ && apt-get upgrade --yes \ + && apt-get install --yes --no-install-recommends \ + libpq5 libxml2 libxslt1.1 libvips \ + curl gnupg graphviz nodejs \ + && echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && curl -sL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \ + && apt-get update \ + && apt-get install --yes --no-install-recommends postgresql-client-15 \ && rm -rf /var/lib/apt/lists/* /var/lib/apt/archives/*.deb -RUN apt-get update && apt-get install -y sudo curl wget vim git zsh docker.io -RUN sh -c "$(wget https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh -O -)" ENV TZ='Europe/London' ENV RUBYOPT='-W:no-deprecated -W:no-experimental' @@ -13,11 +18,25 @@ FROM base AS builder WORKDIR /app RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - build-essential libpq-dev libxml2-dev libxslt1-dev git \ + build-essential libpq-dev libxml2-dev libxslt1-dev git libyaml-dev \ + firefox-esr python2-dev \ && rm -rf /var/lib/apt/lists/* /var/lib/apt/archives/*.deb COPY Gemfile Gemfile.lock /app/ RUN bundle install --jobs 4 \ - && bundle binstubs --all --path /usr/local/bundle/bin + && bundle binstubs --all --path /usr/local/bundle/bin \ + && bundle binstubs bundler --force + +# Dev container image +FROM builder AS dev-container +RUN apt-get update \ + && apt-get install --yes --no-install-recommends sudo git vim zsh ssh curl less +RUN sh -c "$(curl -L https://github.com/deluan/zsh-in-docker/releases/download/v1.1.5/zsh-in-docker.sh)" -- \ + -t robbyrussell \ + -p git -p docker-compose -p yarn \ + -p https://github.com/zsh-users/zsh-autosuggestions \ + # -p https://github.com/marlonrichert/zsh-autocomplete \ + -p https://github.com/unixorn/fzf-zsh-plugin +RUN chsh -s $(which zsh) ${USER} # Slim application image without development dependencies FROM base AS app diff --git a/Gemfile b/Gemfile index 685eafe50..877486e94 100644 --- a/Gemfile +++ b/Gemfile @@ -5,31 +5,46 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '~> 3.2.0' +gem 'administrate', '~> 0.20.1' +gem 'administrate-field-active_storage' gem 'aws-sdk-s3', require: false gem 'bootsnap', require: false gem 'cancancan', '~> 3.3' +gem 'countries' +gem 'email_validator' +gem 'faker' gem 'faraday' gem 'github_webhook', '~> 1.4' gem 'globalid' -gem 'good_job', '~> 3.12' +gem 'good_job', '~> 4.3' gem 'graphql' gem 'graphql-client' +gem 'i18n' +gem 'image_processing' gem 'importmap-rails' gem 'jbuilder' gem 'kaminari' +gem 'omniauth-rails_csrf_protection', '~> 1.0.1' +gem 'omniauth-rpi', + github: 'RaspberryPiFoundation/omniauth-rpi', + tag: 'v1.3.1' gem 'open-uri' +gem 'paper_trail' gem 'pg', '~> 1.1' -gem 'puma', '~> 5.6' +gem 'postmark-rails' +gem 'puma', '~> 6' gem 'rack-cors' -gem 'rails', '~> 7.0.0' +gem 'rails', '~> 7.1' gem 'scout_apm' -gem 'sentry-rails', '~> 5.5.0' +gem 'sentry-rails' group :development, :test do + gem 'awesome_print' gem 'bullet' + gem 'debug' gem 'dotenv-rails' gem 'factory_bot_rails' - gem 'faker' + gem 'graphiql-rails' gem 'pry-byebug' gem 'rspec' gem 'rspec_junit_formatter' @@ -41,8 +56,19 @@ group :development, :test do gem 'simplecov', require: false end +group :development do + gem 'rails-erd' + gem 'ruby-lsp', '~> 0.17.7' + gem 'ruby-lsp-rails' + gem 'ruby-lsp-rspec' +end + group :test do + gem 'capybara' gem 'climate_control' + gem 'database_cleaner-active_record' + gem 'selenium-webdriver' gem 'shoulda-matchers', '~> 5.0' + gem 'webdrivers' gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index 463b4c5ba..b954c8b20 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,74 +1,105 @@ +GIT + remote: https://github.com/RaspberryPiFoundation/omniauth-rpi.git + revision: b7cb36cbfbf9a66f2376bb1501a4bdb205f2a7bf + tag: v1.3.1 + specs: + omniauth-rpi (1.3.1) + jwt (~> 2.2.3) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.4) + GEM remote: https://rubygems.org/ specs: - actioncable (7.0.4.2) - actionpack (= 7.0.4.2) - activesupport (= 7.0.4.2) + actioncable (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.4.2) - actionpack (= 7.0.4.2) - activejob (= 7.0.4.2) - activerecord (= 7.0.4.2) - activestorage (= 7.0.4.2) - activesupport (= 7.0.4.2) + zeitwerk (~> 2.6) + actionmailbox (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.4.2) - actionpack (= 7.0.4.2) - actionview (= 7.0.4.2) - activejob (= 7.0.4.2) - activesupport (= 7.0.4.2) + actionmailer (7.1.3.4) + actionpack (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.4.2) - actionview (= 7.0.4.2) - activesupport (= 7.0.4.2) - rack (~> 2.0, >= 2.2.0) + rails-dom-testing (~> 2.2) + actionpack (7.1.3.4) + actionview (= 7.1.3.4) + activesupport (= 7.1.3.4) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.4.2) - actionpack (= 7.0.4.2) - activerecord (= 7.0.4.2) - activestorage (= 7.0.4.2) - activesupport (= 7.0.4.2) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.3.4) + actionpack (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.4.2) - activesupport (= 7.0.4.2) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.4.2) - activesupport (= 7.0.4.2) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.3.6) - activemodel (7.0.4.2) - activesupport (= 7.0.4.2) - activerecord (7.0.4.2) - activemodel (= 7.0.4.2) - activesupport (= 7.0.4.2) - activestorage (7.0.4.2) - actionpack (= 7.0.4.2) - activejob (= 7.0.4.2) - activerecord (= 7.0.4.2) - activesupport (= 7.0.4.2) + activemodel (7.1.3.4) + activesupport (= 7.1.3.4) + activerecord (7.1.3.4) + activemodel (= 7.1.3.4) + activesupport (= 7.1.3.4) + timeout (>= 0.4.0) + activestorage (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activesupport (= 7.1.3.4) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.4.2) + activesupport (7.1.3.4) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) addressable (2.8.1) public_suffix (>= 2.0.2, < 6.0) + administrate (0.20.1) + actionpack (>= 6.0, < 8.0) + actionview (>= 6.0, < 8.0) + activerecord (>= 6.0, < 8.0) + jquery-rails (~> 4.6.0) + kaminari (~> 1.2.2) + sassc-rails (~> 2.1) + selectize-rails (~> 0.6) + administrate-field-active_storage (1.0.1) + administrate (>= 0.2.2) + rails (>= 7.0) ast (2.4.2) + awesome_print (1.9.2) aws-eventstream (1.2.0) aws-partitions (1.718.0) aws-sdk-core (3.170.0) @@ -85,43 +116,69 @@ GEM aws-sigv4 (~> 1.4) aws-sigv4 (1.5.2) aws-eventstream (~> 1, >= 1.0.2) + base64 (0.2.0) + bigdecimal (3.1.8) bootsnap (1.16.0) msgpack (~> 1.2) - builder (3.2.4) - bullet (7.0.7) + builder (3.3.0) + bullet (7.1.6) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) cancancan (3.4.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + choice (0.2.0) climate_control (1.2.0) coderay (1.1.3) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.3) + connection_pool (2.4.1) + countries (5.7.1) + unaccent (~> 0.3) crack (0.4.5) rexml crass (1.0.6) - date (3.3.3) + database_cleaner-active_record (2.1.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + date (3.3.4) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) diff-lcs (1.5.0) docile (1.4.0) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) - erubi (1.12.0) - et-orbi (1.2.7) + drb (2.2.1) + email_validator (2.2.4) + activemodel + erubi (1.13.0) + et-orbi (1.2.11) tzinfo factory_bot (6.2.1) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) - faker (3.1.1) + faker (3.4.1) i18n (>= 1.8.11, < 2) faraday (2.7.4) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - fugit (1.8.1) - et-orbi (~> 1, >= 1.2.7) + ffi (1.16.3) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) github_webhook (1.4.2) activesupport (>= 4) @@ -129,29 +186,44 @@ GEM railties (>= 4) globalid (1.1.0) activesupport (>= 5.0) - good_job (3.12.6) - activejob (>= 6.0.0) - activerecord (>= 6.0.0) - concurrent-ruby (>= 1.0.2) - fugit (>= 1.1) - railties (>= 6.0.0) - thor (>= 0.14.1) - webrick (>= 1.3) + good_job (4.3.0) + activejob (>= 6.1.0) + activerecord (>= 6.1.0) + concurrent-ruby (>= 1.3.1) + fugit (>= 1.11.0) + railties (>= 6.1.0) + thor (>= 1.0.0) + graphiql-rails (1.9.0) + railties + sprockets-rails graphql (2.0.17) graphql-client (0.18.0) activesupport (>= 3.0) graphql hashdiff (1.0.1) - i18n (1.12.0) + hashie (5.0.0) + i18n (1.14.5) concurrent-ruby (~> 1.0) + image_processing (1.12.2) + mini_magick (>= 4.9.5, < 5) + ruby-vips (>= 2.0.17, < 3) importmap-rails (1.1.5) actionpack (>= 6.0.0) railties (>= 6.0.0) + io-console (0.7.2) + irb (1.13.1) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) + jquery-rails (4.6.0) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) json (2.6.3) + jwt (2.2.3) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -164,87 +236,148 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - loofah (2.19.1) + language_server-protocol (3.17.0.3) + logger (1.6.0) + loofah (2.22.0) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.8.1) mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) + matrix (0.4.2) method_source (1.0.0) + mini_magick (4.12.0) mini_mime (1.1.2) - minitest (5.17.0) + minitest (5.23.1) msgpack (1.6.0) - net-imap (0.3.4) + multi_xml (0.6.0) + mutex_m (0.2.0) + net-imap (0.4.12) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-smtp (0.3.3) + net-smtp (0.5.0) net-protocol - nio4r (2.5.8) - nokogiri (1.14.2-aarch64-linux) + nio4r (2.7.3) + nokogiri (1.16.6-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.14.2-x86_64-linux) + nokogiri (1.16.6-x86_64-linux) racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) + omniauth (2.1.2) + hashie (>= 3.4.6) + rack (>= 2.2.3) + rack-protection + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) open-uri (0.3.0) stringio time uri + paper_trail (15.1.0) + activerecord (>= 6.1) + request_store (~> 1.4) parallel (1.22.1) parser (3.2.1.0) ast (~> 2.4.1) pg (1.4.6) + postmark (1.25.0) + json + postmark-rails (0.22.1) + actionmailer (>= 3.0.0) + postmark (>= 1.21.3, < 2.0) + prism (0.30.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) pry-byebug (3.10.1) byebug (~> 11.0) pry (>= 0.13, < 0.15) + psych (5.1.2) + stringio public_suffix (5.0.1) - puma (5.6.5) + puma (6.4.2) nio4r (~> 2.0) raabro (1.4.0) - racc (1.6.2) - rack (2.2.6.2) + racc (1.8.0) + rack (2.2.9) rack-cors (2.0.0) rack (>= 2.0.0) - rack-test (2.0.2) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-session (1.0.2) + rack (< 3) + rack-test (2.1.0) rack (>= 1.3) - rails (7.0.4.2) - actioncable (= 7.0.4.2) - actionmailbox (= 7.0.4.2) - actionmailer (= 7.0.4.2) - actionpack (= 7.0.4.2) - actiontext (= 7.0.4.2) - actionview (= 7.0.4.2) - activejob (= 7.0.4.2) - activemodel (= 7.0.4.2) - activerecord (= 7.0.4.2) - activestorage (= 7.0.4.2) - activesupport (= 7.0.4.2) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.3.4) + actioncable (= 7.1.3.4) + actionmailbox (= 7.1.3.4) + actionmailer (= 7.1.3.4) + actionpack (= 7.1.3.4) + actiontext (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activemodel (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) bundler (>= 1.15.0) - railties (= 7.0.4.2) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + railties (= 7.1.3.4) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - railties (7.0.4.2) - actionpack (= 7.0.4.2) - activesupport (= 7.0.4.2) - method_source + rails-erd (1.7.2) + activerecord (>= 4.2) + activesupport (>= 4.2) + choice (~> 0.2.0) + ruby-graphviz (~> 1.2) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.0.6) + rake (13.2.1) + rbs (3.5.2) + logger + rdoc (6.7.0) + psych (>= 4.0.0) regexp_parser (2.7.0) - rexml (3.2.5) + reline (0.5.9) + io-console (~> 0.5) + request_store (1.7.0) + rack (>= 1.4) + rexml (3.3.0) + strscan rspec (3.12.0) rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) @@ -254,17 +387,17 @@ GEM rspec-expectations (3.12.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-mocks (3.12.3) + rspec-mocks (3.12.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-rails (6.0.1) + rspec-rails (6.1.1) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.11) - rspec-expectations (~> 3.11) - rspec-mocks (~> 3.11) - rspec-support (~> 3.11) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) rspec-support (3.12.0) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) @@ -291,14 +424,43 @@ GEM rubocop-rspec (2.18.1) rubocop (~> 1.33) rubocop-capybara (~> 2.17) + ruby-graphviz (1.2.5) + rexml + ruby-lsp (0.17.7) + language_server-protocol (~> 3.17.0) + prism (>= 0.29.0, < 0.31) + rbs (>= 3, < 4) + sorbet-runtime (>= 0.5.10782) + ruby-lsp-rails (0.3.10) + ruby-lsp (>= 0.17.2, < 0.18.0) + ruby-lsp-rspec (0.1.12) + ruby-lsp (~> 0.17.0) ruby-progressbar (1.11.0) + ruby-vips (2.2.0) + ffi (~> 1.12) ruby2_keywords (0.0.5) + rubyzip (2.3.2) + sassc (2.4.0) + ffi (~> 1.9) + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt scout_apm (5.3.5) parser - sentry-rails (5.5.0) + selectize-rails (0.12.6) + selenium-webdriver (4.18.1) + base64 (~> 0.2) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + sentry-rails (5.17.3) railties (>= 5.0) - sentry-ruby (~> 5.5.0) - sentry-ruby (5.5.0) + sentry-ruby (~> 5.17.3) + sentry-ruby (5.17.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) @@ -308,54 +470,95 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - stringio (3.0.5) - thor (1.2.1) - time (0.2.1) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) + sorbet-runtime (0.5.11481) + sprockets (4.2.0) + concurrent-ruby (~> 1.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) + stringio (3.1.1) + strscan (3.1.0) + thor (1.3.1) + tilt (2.3.0) + time (0.3.0) date - timeout (0.3.2) + timeout (0.4.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unaccent (0.4.0) unicode-display_width (2.4.2) uniform_notifier (1.16.0) - uri (0.12.0) + uri (0.13.0) + version_gem (1.1.3) + webdrivers (5.2.0) + nokogiri (~> 1.6) + rubyzip (>= 1.3.0) + selenium-webdriver (~> 4.0) webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) webrick (1.8.1) - websocket-driver (0.7.5) + websocket (1.2.10) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.7) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.6.15) PLATFORMS aarch64-linux + arm64-darwin-22 + arm64-darwin-23 + arm64-darwin-24 x86_64-linux DEPENDENCIES + administrate (~> 0.20.1) + administrate-field-active_storage + awesome_print aws-sdk-s3 bootsnap bullet cancancan (~> 3.3) + capybara climate_control + countries + database_cleaner-active_record + debug dotenv-rails + email_validator factory_bot_rails faker faraday github_webhook (~> 1.4) globalid - good_job (~> 3.12) + good_job (~> 4.3) + graphiql-rails graphql graphql-client + i18n + image_processing importmap-rails jbuilder kaminari + omniauth-rails_csrf_protection (~> 1.0.1) + omniauth-rpi! open-uri + paper_trail pg (~> 1.1) + postmark-rails pry-byebug - puma (~> 5.6) + puma (~> 6) rack-cors - rails (~> 7.0.0) + rails (~> 7.1) + rails-erd rspec rspec-rails rspec_junit_formatter @@ -363,14 +566,19 @@ DEPENDENCIES rubocop-graphql rubocop-rails rubocop-rspec + ruby-lsp (~> 0.17.7) + ruby-lsp-rails + ruby-lsp-rspec scout_apm - sentry-rails (~> 5.5.0) + selenium-webdriver + sentry-rails shoulda-matchers (~> 5.0) simplecov + webdrivers webmock RUBY VERSION - ruby 3.2.1p31 + ruby 3.2.3p157 BUNDLED WITH 2.4.7 diff --git a/Procfile b/Procfile index 45c6e4847..394d20da5 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,3 @@ web: bundle exec puma -C config/puma.rb -release: bundle exec rails db:migrate && bundle exec rake projects:create_starter +release: bundle exec rails db:migrate && bundle exec rake projects:create_experience_cs_examples worker: bundle exec good_job start --max-threads=8 diff --git a/README.md b/README.md index e1819412f..17ec1c210 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,11 @@ -# The Raspberry Pi Foundation Code Editor - API +# Raspberry Pi Foundation Code Editor API -This document discusses the components used to build the Raspberry Pi Foundation Code Editor. It's a good starting point both for working on the editor itself and for using ideas or components from the editor in other projects. - -The document assumes some familiarity with the app as a user. [Try it out](https://editor.raspberrypi.org) before reading further. - -## API - -The [editor API](https://github.com/RaspberryPiFoundation/editor-api) is a Rails monolith and is hosted at [`editor-api.raspberrypi.org`](https://editor-api.raspberrypi.org/). Through the combination of a REST interface and a GraphQL API, it provides mechanisms for: +The editor API is a Rails monolith and is hosted at [`editor-api.raspberrypi.org`](https://editor-api.raspberrypi.org/). Through the combination of a REST interface and a GraphQL API, it provides mechanisms for: - Session management - User auth (delegated to [Raspberry Pi Accounts / Open ID Connect](https://github.com/RaspberryPiFoundation/profile)) and permissions (managed using the `cancancan` gem) -- Persistence of projects, including code files, data, images and metadata. - -Individual projects can be requested from `/api/projects/{project_identifier}` and a list of a user's projects is available via the GraphQL API. - -Project images are uploaded via `POST` requests to `/projects/{project_identfier}/images` and stored in an S3 bucket. However, the ability to upload images in the user interface is not currently enabled for safeguarding reasons. - -A project remix is created via a `POST` request to `projects/{original_project_identifier}/remix`. - -### Requests from UI to API - -Currently requests to the API from a specific project page are generally performed via `axios`, with `AsyncThunk`s being used to manage the status of such requests and update the UI accordingly. - -The My Projects page loads data and requests project renaming/deletion via GraphQL, with data stored in an Apollo cache. In the future, we aim to transition the whole app over to using the GraphQL/Apollo approach. +- Persistence of projects, including code files, data, images and metadata +- Management of schools, school classes, lessons, teachers and students for Code Editor for Education (CEfE) ## Getting Started @@ -39,7 +22,7 @@ docker-compose build Set up the database: ``` -docker compose run api rails db:setup +docker compose run --rm api rails db:setup ``` ### Running the app @@ -53,19 +36,97 @@ docker-compose up #### Updating gems inside the container This can be done with the `bin/with-builder.sh` script: + ``` ./bin/with-builder.sh bundle update ``` + which should update the Gems in the container, without the need for rebuilding. -### CORS Allowed Origins +### Seeding + +By default in development only, two tasks are called to seed data: + +`docker compose run --rm api rails projects:create_all` +`docker compose run --rm api rails for_education:seed_a_school_with_lessons_and_students` + +If needed manually the following task will create all projects: + +`docker compose run --rm api rails projects:create_all` + +For CEfE the following scenarios are modelled by the tasks: + +`docker compose run --rm api rails for_education:seed_an_unverified_school` - seeds an unverified school to test the onboarding flow +`docker compose run --rm api rails for_education:seed_a_verified_school` - seeds only a verified school +`docker compose run --rm api rails for_education:seed_a_school_with_lessons_and_students` - seeds a school with a class, two lessons, a project in each, and two students + +To clear CEfE data the following cmd will remove the school associated with the `jane.doe@example.com` user, and associated school data: + +`rails for_education:destroy_seed_data` + +To override values, you can prefix the tasks with environment variables, for example: + +`SEEDING_CREATOR_ID=00000000-0000-0000-0000-000000000000 rails for_education:seed_a_verified_school` + +Also avilable to override are: `SEEDING_TEACHER_ID`. + +> NOTE: The student ids and school id in the CM seeds are hard coded to match profile seed data. -Add a comma separated list to the relevant enviroment settings. E.g for development in the `.env` file: +#### Syncing the database from Production / Staging + +##### Prerequisites + +- You must have the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) installed +- You must be added to the Heroku app `editor-api-production` to sync from the production database +- You must be added to the Heroku app `editor-api-staging`, to sync from the staging database + +#### Syncing from PRODUCTION... + +| ... to ENV | ... run this: | +| ------------- | -------------------------------------- | +| Local Dev Env | `./bin/db-sync/production-to-local.sh` | + +##### Syncing from STAGING... + +| ... to ENV | ... run this: | +| ------------- | ----------------------------------- | +| Local Dev Env | `./bin/db-sync/staging-to-local.sh` | + +The `*-to-local.sh` scripts will backup the database in your local terminal, then run an instance of the Docker container and run commands to populate your development DB with that data - see [./bin/db-sync/load-local-db.sh](./bin/db-sync/load-local-db.sh) + +### Testing + +Run the entire test suite using: + +``` +docker-compose run api rspec +``` + +Or individual specs using: ``` -ALLOWED_ORIGINS=localhost:3002,localhost:3000 +docker-compose run api rspec spec/path/to/spec.rb ``` +### CORS Allowed Origins + +Handled in `config/initializers/cors.rb`. + ### Webhooks -This API receives push event data from the [Raspberry Pi Learning](https://github.com/raspberrypilearning) organisation via webhooks. These webhooks are mediated locally through `smee`, which runs in a Docker container. The webhook data is processed using the `github_webhooks` gem in the `github_webhooks_controller`. +This API receives push event data from the [Raspberry Pi Learning](https://github.com/raspberrypilearning) organisation via webhooks. This data is used to create or update code projects related to the [Code Club Projects Site](https://projects.raspberrypi.org), and is processed using the `github_webhooks` gem in the `github_webhooks_controller`. For development purposes, these webhooks are mediated locally through `smee`, which runs in a Docker container. + +## Usage + +### Projects + +Individual projects can be requested from `/api/projects/{project_identifier}` and a list of a user's projects is available via the GraphQL API. + +Project images are uploaded via `POST` requests to `/projects/{project_identfier}/images` and stored in an S3 bucket. + +A project remix is created via a `POST` request to `projects/{original_project_identifier}/remix`. + +### Code Editor for Education + +Editor API provides routes for managing resources such as schools, school classes and lessons, as well as for inviting teachers and managing student accounts via `profile` requests. + diff --git a/app.json b/app.json new file mode 100644 index 000000000..bcd4d338b --- /dev/null +++ b/app.json @@ -0,0 +1,10 @@ +{ + "addons": [ + { + "plan": "heroku-postgresql", + "options": { + "version": "14" + } + } + ] +} diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index ddd546a0b..6151b36ad 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,4 +1,4 @@ //= link_tree ../images //= link_directory ../stylesheets .css -//= link_tree ../../javascript .js //= link_tree ../../../vendor/javascript .js +//= link administrate-field-active_storage/application.css diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb deleted file mode 100644 index 9aec23053..000000000 --- a/app/channels/application_cable/channel.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb deleted file mode 100644 index 8d6c2a1bf..000000000 --- a/app/channels/application_cable/connection.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module ApplicationCable - class Connection < ActionCable::Connection::Base - end -end diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb new file mode 100644 index 000000000..ccc865687 --- /dev/null +++ b/app/controllers/admin/application_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Admin + class ApplicationController < Administrate::ApplicationController + include AuthenticationHelper + + before_action :authenticate_admin + + helper_method :current_user + + def authenticate_admin + redirect_to '/', alert: I18n.t('errors.admin.unauthorized') unless current_user&.admin? + end + end +end diff --git a/app/controllers/admin/components_controller.rb b/app/controllers/admin/components_controller.rb new file mode 100644 index 000000000..b05eddfa3 --- /dev/null +++ b/app/controllers/admin/components_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + class ComponentsController < Admin::ApplicationController + end +end diff --git a/app/controllers/admin/lessons_controller.rb b/app/controllers/admin/lessons_controller.rb new file mode 100644 index 000000000..db4cfde13 --- /dev/null +++ b/app/controllers/admin/lessons_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + class LessonsController < Admin::ApplicationController + end +end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb new file mode 100644 index 000000000..89605cddd --- /dev/null +++ b/app/controllers/admin/projects_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Admin + class ProjectsController < Admin::ApplicationController + before_action :set_host_for_local_storage + + def scoped_resource + action_name == 'index' ? resource_class.internal_projects : resource_class.all + end + + def destroy_image + image = requested_resource.images.find(params[:image_id]) + image.purge + redirect_back(fallback_location: requested_resource) + end + + private + + def set_host_for_local_storage + return unless Rails.application.config.active_storage.service == :local + + ActiveStorage::Current.url_options = { host: request.base_url } + end + end +end diff --git a/app/controllers/admin/school_classes_controller.rb b/app/controllers/admin/school_classes_controller.rb new file mode 100644 index 000000000..c6ec2043b --- /dev/null +++ b/app/controllers/admin/school_classes_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + class SchoolClassesController < Admin::ApplicationController + end +end diff --git a/app/controllers/admin/schools_controller.rb b/app/controllers/admin/schools_controller.rb new file mode 100644 index 000000000..83899b434 --- /dev/null +++ b/app/controllers/admin/schools_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Admin + class SchoolsController < Admin::ApplicationController + def verify + service = SchoolVerificationService.new(requested_resource) + + if service.verify(token: current_user.token) + flash[:notice] = t('administrate.controller.verify_school.success') + else + flash[:error] = t('administrate.controller.verify_school.error') + end + + redirect_to admin_school_path(requested_resource) + end + + def reject + service = SchoolVerificationService.new(requested_resource) + + if service.reject + flash[:notice] = t('administrate.controller.reject_school.success') + else + flash[:error] = t('administrate.controller.reject_school.error') + end + + redirect_to admin_school_path(requested_resource) + end + + def reopen + service = SchoolVerificationService.new(requested_resource) + + if service.reopen + flash[:notice] = t('administrate.controller.reopen_school.success') + else + flash[:error] = t('administrate.controller.reopen_school.error') + end + + redirect_to admin_school_path(requested_resource) + end + + def default_sorting_attribute + :created_at + end + + def default_sorting_direction + :desc + end + end +end diff --git a/app/controllers/api/class_members_controller.rb b/app/controllers/api/class_members_controller.rb new file mode 100644 index 000000000..746b6294e --- /dev/null +++ b/app/controllers/api/class_members_controller.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Api + class ClassMembersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + load_and_authorize_resource :school_class, through: :school, through_association: :classes, id_param: :class_id + load_and_authorize_resource :class_student, through: :school_class, through_association: :students + + def index + @class_students = @school_class.students.accessible_by(current_ability) + owners = SchoolOwner::List.call(school: @school).fetch(:school_owners, []) + result = ClassMember::List.call(school_class: @school_class, class_students: @class_students, token: current_user.token) + + if result.success? + @school_owner_ids = owners.map(&:id) + @class_members = result[:class_members] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create + user_ids = [class_member_params[:user_id]] + user_type = class_member_params[:type] + owners = SchoolOwner::List.call(school: @school).fetch(:school_owners, []) + @school_owner_ids = owners.map(&:id) + if user_type == 'student' + teachers = { school_teachers: [] } + students = SchoolStudent::List.call(school: @school, token: current_user.token, student_ids: user_ids) + else + teachers = SchoolTeacher::List.call(school: @school, teacher_ids: user_ids) + students = { school_students: [] } + end + result = ClassMember::Create.call(school_class: @school_class, students: students[:school_students], teachers: teachers[:school_teachers]) + + if result.success? + @class_member = result[:class_members].first + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create_batch + owners = SchoolOwner::List.call(school: @school).fetch(:school_owners, []) + @school_owner_ids = owners.map(&:id) + + # Teacher objects needs to be the compliment of student objects so that every user creation is attempted and validated. + student_objects = create_batch_params.select { |user| user[:type] == 'student' } + teacher_objects = create_batch_params.select { |user| student_objects.pluck(:user_id).exclude?(user[:user_id]) } + student_ids = student_objects.pluck(:user_id) + teacher_ids = teacher_objects.pluck(:user_id) + + students = list_students(@school, current_user.token, student_ids) + teachers = list_teachers(@school, teacher_ids) + + result = ClassMember::Create.call(school_class: @school_class, students: students[:school_students], teachers: teachers[:school_teachers]) + + if result.success? + @class_members = result[:class_members] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + result = ClassMember::Delete.call(school_class: @school_class, class_member_id: params[:id]) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def class_member_params + params.require(:class_member).permit(:user_id, :type) + end + + def create_batch_params + class_members = params.require(:class_members) + + class_members.map do |class_member| + next if class_member.blank? + + class_member.permit(:user_id, :type).to_h.with_indifferent_access + end + end + + def list_students(school, _token, student_ids) + if student_ids.present? + SchoolStudent::List.call(school:, token: current_user.token, student_ids:) + else + { school_students: [] } + end + end + + def list_teachers(school, teacher_ids) + if teacher_ids.present? + SchoolTeacher::List.call(school:, teacher_ids:) + else + { school_teachers: [] } + end + end + end +end diff --git a/app/controllers/api/default_projects_controller.rb b/app/controllers/api/default_projects_controller.rb index 988e3dfa2..62df845df 100644 --- a/app/controllers/api/default_projects_controller.rb +++ b/app/controllers/api/default_projects_controller.rb @@ -5,7 +5,7 @@ class DefaultProjectsController < ApiController before_action :authorize_user, only: %i[create] def show - data = if params[:type] == 'html' + data = if params[:type] == Project::Types::HTML html_project else python_project @@ -23,7 +23,7 @@ def html def create identifier = PhraseIdentifier.generate - @project = Project.new(identifier:, project_type: 'python') + @project = Project.new(identifier:, project_type: Project::Types::PYTHON) @project.components << Component.new(python_component) @project.save @@ -38,7 +38,7 @@ def python_component def python_project { - type: 'python', + type: Project::Types::PYTHON, components: [ { lang: 'py', name: 'main', content: "import turtle\nt = turtle.Turtle()\nt.forward(100)\nprint(\"Oh yeah!\")" } @@ -53,7 +53,7 @@ def html_project CON { - type: 'html', + type: Project::Types::HTML, components: [ { lang: 'html', name: 'index', content: }, { lang: 'css', name: 'style', content: "h1 {\n color: blue;\n}" }, diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb new file mode 100644 index 000000000..305b2a632 --- /dev/null +++ b/app/controllers/api/lessons_controller.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Api + class LessonsController < ApiController + before_action :authorize_user, except: %i[index show] + before_action :verify_school_class_belongs_to_school, only: :create + load_and_authorize_resource :lesson + + def index + archive_scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived + scope = params[:school_class_id] ? archive_scope.where(school_class_id: params[:school_class_id]) : archive_scope + ordered_scope = scope.order(created_at: :asc) + @lessons_with_users = ordered_scope.accessible_by(current_ability).with_users + render :index, formats: [:json], status: :ok + end + + def show + @lesson_with_user = @lesson.with_user + render :show, formats: [:json], status: :ok + end + + def create + result = Lesson::Create.call(lesson_params:) + + if result.success? + @lesson_with_user = result[:lesson].with_user + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create_copy + result = Lesson::CreateCopy.call(lesson: @lesson, lesson_params:) + + if result.success? + @lesson_with_user = result[:lesson].with_user + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def update + # TODO: Consider removing user_id from the lesson_params for update so users can update other users' lessons without changing ownership + # OR consider dropping user_id on lessons and using teacher id/ids on the class instead + result = Lesson::Update.call(lesson: @lesson, lesson_params:) + + if result.success? + @lesson_with_user = result[:lesson].with_user + render :show, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + operation = params[:undo] == 'true' ? Lesson::Unarchive : Lesson::Archive + result = operation.call(lesson: @lesson) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def verify_school_class_belongs_to_school + return if base_params[:school_class_id].blank? + return if school&.classes&.pluck(:id)&.include?(base_params[:school_class_id]) + + raise ParameterError, 'school_class_id does not correspond to school_id' + end + + def lesson_params + base_params.merge(user_id: current_user.id) + end + + def base_params + params.fetch(:lesson, {}).permit( + :school_id, + :school_class_id, + :name, + :description, + :visibility, + :due_date, + { + project_attributes: [ + :name, + :project_type, + :locale, + { components: %i[id name extension content index default] } + ] + } + ) + end + + def school_owner? + school && current_user.school_owner?(school) + end + + def school + @school ||= @lesson&.school || School.find_by(id: base_params[:school_id]) + end + end +end diff --git a/app/controllers/api/my_school_controller.rb b/app/controllers/api/my_school_controller.rb new file mode 100644 index 000000000..788053ae9 --- /dev/null +++ b/app/controllers/api/my_school_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Api + class MySchoolController < ApiController + before_action :authorize_user + + def show + @school = School.find_for_user!(current_user) + @user = current_user + render :show, formats: [:json], status: :ok + end + end +end diff --git a/app/controllers/api/project_errors_controller.rb b/app/controllers/api/project_errors_controller.rb new file mode 100644 index 000000000..51488d7c9 --- /dev/null +++ b/app/controllers/api/project_errors_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Api + class ProjectErrorsController < ApiController + def create + project_error = ProjectError.new(project_error_params) + project_error.save! + + render json: { data: project_error }, status: :created + end + + rescue_from ActiveRecord::RecordInvalid, with: :generic_error_response + + def raw_params + params.permit( + :project_id, + :error, + :error_type, + :user_id + ) + end + + def project_error_params + py_params = raw_params + + project_id = py_params.delete(:project_id) + + if project_id.present? + project = Project.find_by(identifier: project_id) + py_params[:project_id] = project&.id || nil + end + + py_params + end + + def generic_error_response(exception) + render json: { data: [], error: exception.message }, status: :bad_request + end + end +end diff --git a/app/controllers/api/projects/images_controller.rb b/app/controllers/api/projects/images_controller.rb index cf2cd472d..bb55f4ecb 100644 --- a/app/controllers/api/projects/images_controller.rb +++ b/app/controllers/api/projects/images_controller.rb @@ -3,7 +3,13 @@ module Api module Projects class ImagesController < ApiController - before_action :authorize_user + before_action :authorize_user, only: %i[create] + + def show + @project = Project.find_by!(identifier: params[:project_id]) + authorize! :show, @project + render '/api/projects/images', formats: [:json] + end def create @project = Project.find_by!(identifier: params[:project_id]) diff --git a/app/controllers/api/projects/remixes_controller.rb b/app/controllers/api/projects/remixes_controller.rb index 2629c8771..0eb1ba6e2 100644 --- a/app/controllers/api/projects/remixes_controller.rb +++ b/app/controllers/api/projects/remixes_controller.rb @@ -4,11 +4,26 @@ module Api module Projects class RemixesController < ApiController before_action :authorize_user + load_and_authorize_resource :school, only: :index + before_action :load_and_authorize_remix, only: %i[show] + + def index + projects = Project.where(remixed_from_id: project.id).accessible_by(current_ability) + @projects_with_users = projects.includes(:school_project).with_users(@current_user) + render index: @projects_with_users, formats: [:json] + end + + def show + render '/api/projects/show', formats: [:json] + end def create + # Ensure we have a fallback value to prevent bad requests + remix_origin = request.origin || request.referer result = Project::CreateRemix.call(params: remix_params, - user_id: current_user, - original_project: project) + user_id: current_user&.id, + original_project: project, + remix_origin:) if result.success? @project = result[:project] @@ -24,11 +39,22 @@ def project @project ||= Project.find_by!(identifier: params[:project_id]) end + def load_and_authorize_remix + @project = Project.find_by!(remixed_from_id: project.id, user_id: current_user&.id) + authorize! :show, @project + end + def remix_params params.require(:project) .permit(:name, :identifier, + :project_type, :locale, + :user_id, + :videos, + :audio, + :instructions, + image_list: [], components: %i[id name extension content index]) end end diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index 3b3dd2ba3..2ac99ca99 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -5,11 +5,11 @@ module Api class ProjectsController < ApiController before_action :authorize_user, only: %i[create update index destroy] - before_action :load_project, only: %i[show update destroy] + before_action :load_project, only: %i[show update destroy show_context] before_action :load_projects, only: %i[index] - after_action :pagination_link_header, only: [:index] load_and_authorize_resource - skip_load_resource only: :create + before_action :verify_lesson_belongs_to_school, only: :create + after_action :pagination_link_header, only: %i[index] def index @paginated_projects = @projects.page(params[:page]) @@ -17,29 +17,33 @@ def index end def show + if !@project.school_id.nil? && @project.lesson_id.nil? + project_with_user = @project.with_user(@current_user) + @user = project_with_user[1] + end + + @project.user_id = @current_user.id if class_teacher?(@project) render :show, formats: [:json] end def create - project_hash = project_params.merge(user_id: current_user) - result = Project::Create.call(project_hash:) + result = Project::Create.call(project_hash: project_params, current_user:) if result.success? @project = result[:project] - render :show, formats: [:json] + render :show, formats: [:json], status: :created else - render json: { error: result[:error] }, status: :internal_server_error + render json: { error: result[:error] }, status: :unprocessable_entity end end def update - update_hash = project_params.merge(user_id: current_user) - result = Project::Update.call(project: @project, update_hash:) + result = Project::Update.call(project: @project, update_hash: project_params, current_user: @current_user) if result.success? render :show, formats: [:json] else - render json: { error: result[:error] }, status: :bad_request + render json: { error: result[:error] }, status: :unprocessable_entity end end @@ -48,30 +52,73 @@ def destroy head :ok end + # Returns the identifier, school_id, lesson_id, and class_id of the project so the full context can be loaded + def show_context + render :context, formats: [:json] + end + private + def verify_lesson_belongs_to_school + return if base_params[:lesson_id].blank? + return if school&.lessons&.pluck(:id)&.include?(base_params[:lesson_id]) + + raise ParameterError, 'lesson_id does not correspond to school_id' + end + def load_project project_loader = ProjectLoader.new(params[:id], [params[:locale]]) - @project = project_loader.load + @project = if action_name == 'show' + project_loader.load(include_images: true) + else + project_loader.load + end end def load_projects - @projects = Project.where(user_id: current_user).order(updated_at: :desc) + @projects = Project.where(user_id: current_user&.id).order(updated_at: :desc) end def project_params + if school_owner? || current_user&.experience_cs_admin? + # A school owner or an Experience CS admin must specify who the project user is. + base_params + else + # A school teacher may only create projects they own. + base_params.merge(user_id: current_user&.id) + end + end + + def base_params params.fetch(:project, {}).permit( + :school_id, + :lesson_id, + :user_id, :identifier, :name, :project_type, :locale, + :instructions, { - image_list: [], components: %i[id name extension content index default] - } + }, + parent: {}, + image_list: [] ) end + def school_owner? + school && current_user.school_owner?(school) + end + + def class_teacher?(project) + project.lesson_id.present? && project.lesson.school_class.present? && project.lesson.school_class.teacher_ids.include?(current_user.id) + end + + def school + @school ||= @project&.school || School.find_by(id: base_params[:school_id]) + end + def pagination_link_header pagination_links = [] pagination_links << page_links(first_page, 'first') diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb new file mode 100644 index 000000000..a26c8485d --- /dev/null +++ b/app/controllers/api/school_classes_controller.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Api + class SchoolClassesController < ApiController + before_action :authorize_user + before_action :load_and_authorize_school + before_action :load_and_authorize_school_class + + def index + school_classes = @school.classes.accessible_by(current_ability) + school_classes = school_classes.joins(:teachers).where(teachers: { teacher_id: current_user.id }) if params[:my_classes] == 'true' + @school_classes_with_teachers = school_classes.with_teachers + render :index, formats: [:json], status: :ok + end + + def show + @school_class_with_teachers = @school_class.with_teachers + render :show, formats: [:json], status: :ok + end + + def create + result = SchoolClass::Create.call(school: @school, school_class_params:, current_user:) + + if result.success? + @school_class_with_teachers = result[:school_class].with_teachers + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def update + school_class = @school.classes.find(params[:id]) + result = SchoolClass::Update.call(school_class:, school_class_params:) + + if result.success? + @school_class_with_teachers = result[:school_class].with_teachers + render :show, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + result = SchoolClass::Delete.call(school: @school, school_class_id: params[:id]) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def load_and_authorize_school + @school = if params[:school_id].match?(/\d\d-\d\d-\d\d/) + School.find_by(code: params[:school_id]) + else + School.find(params[:school_id]) + end + authorize! :read, @school + end + + def load_and_authorize_school_class + if %w[index create].include?(params[:action]) + authorize! params[:action].to_sym, SchoolClass + else + @school_class = if params[:id].match?(/\d\d-\d\d-\d\d/) + @school.classes.find_by(code: params[:id]) + else + @school.classes.find(params[:id]) + end + + authorize! params[:action].to_sym, @school_class + end + end + + def school_class_params + # A school teacher may only create classes they own. + params.require(:school_class).permit(:name, :description) + end + + def school_owner? + current_user.school_owner?(@school) + end + end +end diff --git a/app/controllers/api/school_members_controller.rb b/app/controllers/api/school_members_controller.rb new file mode 100644 index 000000000..aef765752 --- /dev/null +++ b/app/controllers/api/school_members_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Api + class SchoolMembersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_member, class: false + + before_action :create_safeguarding_flags + + def index + result = SchoolMember::List.call(school: @school, token: current_user.token) + + if result.success? + @school_members = result[:school_members] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def create_safeguarding_flags + create_teacher_safeguarding_flag + create_owner_safeguarding_flag + end + + def create_teacher_safeguarding_flag + return unless current_user.school_teacher?(@school) + + ProfileApiClient.create_safeguarding_flag( + token: current_user.token, + flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], + email: current_user.email + ) + end + + def create_owner_safeguarding_flag + return unless current_user.school_owner?(@school) + + ProfileApiClient.create_safeguarding_flag( + token: current_user.token, + flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], + email: current_user.email + ) + end + end +end diff --git a/app/controllers/api/school_owners_controller.rb b/app/controllers/api/school_owners_controller.rb new file mode 100644 index 000000000..5bf29a5f9 --- /dev/null +++ b/app/controllers/api/school_owners_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Api + class SchoolOwnersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_owner, class: false + + def index + result = SchoolOwner::List.call(school: @school) + + if result.success? + @school_owners = result[:school_owners] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create + result = SchoolOwner::Invite.call(school: @school, school_owner_params:, token: current_user.token) + + if result.success? + head :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + result = SchoolOwner::Remove.call(school: @school, owner_id: params[:id], token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_owner_params + params.require(:school_owner).permit(:email_address) + end + end +end diff --git a/app/controllers/api/school_projects_controller.rb b/app/controllers/api/school_projects_controller.rb new file mode 100644 index 000000000..edaf3121d --- /dev/null +++ b/app/controllers/api/school_projects_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Api + class SchoolProjectsController < ApiController + before_action :authorize_user + load_and_authorize_resource :project + + def show_finished + @school_project = Project.find_by(identifier: params[:id]).school_project + authorize! :show_finished, @school_project + render :finished, formats: [:json], status: :ok + end + + def set_finished + project = Project.find_by(identifier: params[:id]) + @school_project = project.school_project + authorize! :set_finished, @school_project + result = SchoolProject::SetFinished.call(school_project: @school_project, finished: params[:finished]) + + if result.success? + @school_project = result[:school_project] + render :finished, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + end +end diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb new file mode 100644 index 000000000..5eba8a5c2 --- /dev/null +++ b/app/controllers/api/school_students_controller.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Api + class SchoolStudentsController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_student, class: false + + before_action :create_safeguarding_flags + + def index + result = SchoolStudent::List.call(school: @school, token: current_user.token) + + if result.success? + @school_students = result[:school_students] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create + result = SchoolStudent::Create.call( + school: @school, school_student_params:, token: current_user.token + ) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create_batch + result = SchoolStudent::CreateBatch.call( + school: @school, school_students_params:, token: current_user.token, user_id: current_user.id + ) + + if result.success? + @job_id = result[:job_id] + render :create_batch, formats: [:json], status: :accepted + else + render json: { error: result[:error], error_type: result[:error_type] }, status: :unprocessable_entity + end + end + + def update + result = SchoolStudent::Update.call( + school: @school, student_id: params[:id], school_student_params:, token: current_user.token + ) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + result = SchoolStudent::Delete.call(school: @school, student_id: params[:id], token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_student_params + params.require(:school_student).permit(:username, :password, :name) + end + + def school_students_params + school_students = params.require(:school_students) + + school_students.map do |student| + next if student.blank? + + student.permit(:username, :password, :name).to_h.with_indifferent_access + end + end + + def create_safeguarding_flags + create_teacher_safeguarding_flag + create_owner_safeguarding_flag + end + + def create_teacher_safeguarding_flag + return unless current_user.school_teacher?(@school) + + ProfileApiClient.create_safeguarding_flag( + token: current_user.token, + flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], + email: current_user.email + ) + end + + def create_owner_safeguarding_flag + return unless current_user.school_owner?(@school) + + ProfileApiClient.create_safeguarding_flag( + token: current_user.token, + flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], + email: current_user.email + ) + end + end +end diff --git a/app/controllers/api/school_teachers_controller.rb b/app/controllers/api/school_teachers_controller.rb new file mode 100644 index 000000000..0615fe7d3 --- /dev/null +++ b/app/controllers/api/school_teachers_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Api + class SchoolTeachersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_teacher, class: false + + def index + result = SchoolTeacher::List.call(school: @school) + + if result.success? + @school_teachers = result[:school_teachers] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def create + result = SchoolTeacher::Invite.call(school: @school, school_teacher_params:, token: current_user.token) + + if result.success? + head :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + result = SchoolTeacher::Remove.call(school: @school, teacher_id: params[:id], token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_teacher_params + params.require(:school_teacher).permit(:email_address) + end + end +end diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb new file mode 100644 index 000000000..4e36b654c --- /dev/null +++ b/app/controllers/api/schools_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Api + class SchoolsController < ApiController + before_action :authorize_user + load_and_authorize_resource + + def index + @schools = School.accessible_by(current_ability) + render :index, formats: [:json], status: :ok + end + + def show + render :show, formats: [:json], status: :ok + end + + def create + result = School::Create.call(school_params:, creator_id: current_user.id) + + if result.success? + @school = result[:school] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def update + school = School.find(params[:id]) + result = School::Update.call(school:, school_params:) + + if result.success? + @school = result[:school] + render :show, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + def destroy + result = School::Delete.call(school_id: params[:id]) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_params + params.require(:school).permit( + :name, + :website, + :reference, + :address_line_1, + :address_line_2, + :municipality, + :administrative_area, + :postal_code, + :country_code, + :creator_role, + :creator_department, + :creator_agree_authority, + :creator_agree_terms_and_conditions, + :creator_agree_to_ux_contact, + :creator_agree_responsible_safeguarding, + :user_origin + ) + end + end +end diff --git a/app/controllers/api/teacher_invitations_controller.rb b/app/controllers/api/teacher_invitations_controller.rb new file mode 100644 index 000000000..644a23fce --- /dev/null +++ b/app/controllers/api/teacher_invitations_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Api + class TeacherInvitationsController < ApiController + rescue_from ActiveSupport::MessageVerifier::InvalidSignature, with: -> { denied } + + before_action :authorize_user + before_action :load_invitation + before_action :ensure_invitation_email_matches_user_email + + def show + render :show, formats: [:json], status: :ok + end + + def accept + role = Role.teacher.find_or_initialize_by(user_id: current_user.id, school: @invitation.school) + if role.save + @invitation.update!(accepted_at: Time.current) if @invitation.accepted_at.blank? + head :ok + else + render json: { error: role.errors }, status: :unprocessable_entity + end + end + + private + + def load_invitation + @invitation = TeacherInvitation.find_by_token_for!(:teacher_invitation, params[:token]) + end + + def ensure_invitation_email_matches_user_email + return if @invitation.email_address == current_user.email + + render json: { error: 'Invitation email does not match user email' }, status: :forbidden + end + end +end diff --git a/app/controllers/api/user_jobs_controller.rb b/app/controllers/api/user_jobs_controller.rb new file mode 100644 index 000000000..a51dbb016 --- /dev/null +++ b/app/controllers/api/user_jobs_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Api + class UserJobsController < ApiController + before_action :authorize_user + + def index + user_jobs = UserJob.where(user_id: current_user.id).includes(:good_job) + jobs = user_jobs&.map { |user_job| job_attributes(user_job&.good_job) } + if jobs.any? + render json: { jobs: }, status: :ok + else + render json: { error: 'No jobs found' }, status: :not_found + end + end + + def show + user_job = UserJob.find_by(good_job_id: params[:id], user_id: current_user.id) + job = job_attributes(user_job&.good_job) unless user_job.nil? + if job.present? + render json: { job: }, status: :ok + else + render json: { error: 'Job not found' }, status: :not_found + end + end + + private + + def job_attributes(job) + { + id: job.id, + concurrency_key: job.concurrency_key, + status: job.status, + scheduled_at: job.scheduled_at, + performed_at: job.performed_at, + finished_at: job.finished_at, + error: job.error + } + end + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index dd7b4bf36..595d2fbb0 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -1,24 +1,36 @@ # frozen_string_literal: true class ApiController < ActionController::API + class ::ParameterError < StandardError; end + include Identifiable - unless Rails.application.config.consider_all_requests_local - rescue_from ActiveRecord::RecordNotFound, with: -> { notfound } - rescue_from CanCan::AccessDenied, with: -> { denied } - end + rescue_from ActionController::ParameterMissing, with: -> { bad_request } + rescue_from ActiveRecord::RecordNotFound, with: -> { not_found } + rescue_from CanCan::AccessDenied, with: -> { denied } + rescue_from ParameterError, with: -> { unprocessable } + + before_action :set_paper_trail_whodunnit private - def authorize_user - head :unauthorized unless current_user + def bad_request + head :bad_request # 400 status end - def notfound - head :not_found + def authorize_user + head :unauthorized unless current_user # 401 status end def denied - head :forbidden + head :forbidden # 403 status + end + + def not_found + head :not_found # 404 status + end + + def unprocessable + head :unprocessable_entity # 422 status end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7944f9f99..3d577319f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true class ApplicationController < ActionController::Base + include AuthenticationHelper end diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb new file mode 100644 index 000000000..c6efc85b5 --- /dev/null +++ b/app/controllers/auth_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class AuthController < ApplicationController + # def index + + # end + + def callback + Rails.logger.debug { "callback: #{omniauth_params}" } + # Prevent session fixation. If the session has been initialized before + # this, and we need to keep the data, then we should copy values over. + reset_session + + self.current_user = User.from_omniauth request.env['omniauth.auth'] + + return redirect_to admin_root_path if current_user.admin? + + redirect_to root_path + end + + def destroy + reset_session + + # Prevent redirect loops etc. + if ENV['BYPASS_OAUTH'].present? + redirect_to root_path + return + end + + redirect_to "#{ENV.fetch('http://github.com/IDENTITY_URL', nil)}/logout?returnTo=#{ENV.fetch('http://github.com/HOST_URL', nil)}", + allow_other_host: true + end + + def failure + flash[:alert] = if request.env['omniauth.error.type'] == :not_verified + 'Login error - account not verified' + else + 'Login error message' + end + + redirect_to root_path + end + + private + + def omniauth_params + request.env['omniauth.params'] + end +end diff --git a/app/controllers/concerns/identifiable.rb b/app/controllers/concerns/identifiable.rb index c6711cd6c..b62a07d5a 100644 --- a/app/controllers/concerns/identifiable.rb +++ b/app/controllers/concerns/identifiable.rb @@ -1,21 +1,14 @@ # frozen_string_literal: true -require 'hydra_admin_api' - module Identifiable extend ActiveSupport::Concern def identify_user token = request.headers['Authorization'] - return nil unless token - - HydraAdminApi.fetch_oauth_user_id(token:) + User.from_token(token:) if token end - def current_user_id - @current_user_id ||= identify_user + def current_user + @current_user ||= identify_user end - - # current_user is required by CanCanCan - alias current_user current_user_id end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 84cc9c841..1f0e34e62 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -22,7 +22,7 @@ def operation_name end def context - @context ||= { current_user_id:, current_ability: Ability.new(current_user_id) } + @context ||= { current_user:, current_ability: Ability.new(current_user), remix_origin: request.origin } end # Handle variables in form data, JSON body, or a blank value diff --git a/app/dashboards/class_student_dashboard.rb b/app/dashboards/class_student_dashboard.rb new file mode 100644 index 000000000..b637bf822 --- /dev/null +++ b/app/dashboards/class_student_dashboard.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class ClassStudentDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + school_class: Field::HasOne, + student_id: Field::String, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + student_id + created_at + updated_at + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + student_id_changed + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how school classes are displayed + # across all pages of the admin dashboard. + # + # def display_resource(school_class) + # "SchoolClass ##{school_class.id}" + # end +end diff --git a/app/dashboards/class_teacher_dashboard.rb b/app/dashboards/class_teacher_dashboard.rb new file mode 100644 index 000000000..1e23b2163 --- /dev/null +++ b/app/dashboards/class_teacher_dashboard.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class ClassTeacherDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + school_class: Field::HasOne, + teacher_id: Field::String, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + teacher_id + created_at + updated_at + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + teacher_id + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how school classes are displayed + # across all pages of the admin dashboard. + # + # def display_resource(class_teacher) + # class_teacher.teacher.name + # end +end diff --git a/app/dashboards/component_dashboard.rb b/app/dashboards/component_dashboard.rb new file mode 100644 index 000000000..58c060298 --- /dev/null +++ b/app/dashboards/component_dashboard.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class ComponentDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + id: Field::String, + content: Field::String, + default: Field::Boolean, + extension: Field::String, + name: Field::String, + project: Field::BelongsTo, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + name + extension + content + default + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + id + content + default + extension + name + project + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how components are displayed + # across all pages of the admin dashboard. + # + # def display_resource(component) + # "Component ##{component.id}" + # end +end diff --git a/app/dashboards/lesson_dashboard.rb b/app/dashboards/lesson_dashboard.rb new file mode 100644 index 000000000..2a5775315 --- /dev/null +++ b/app/dashboards/lesson_dashboard.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class LessonDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + school: Field::BelongsTo, + school_class: Field::BelongsTo, + parent: Field::BelongsTo, + copies: Field::HasMany, + project: Field::HasOne, + id: Field::String, + copied_from_id: Field::String, + user_id: Field::String, + name: Field::String, + description: Field::String, + visibility: Field::String, + due_date: Field::DateTime, + archived_at: Field::DateTime, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + name + school_class + copies + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + school + school_class + project + parent + copies + id + copied_from_id + user_id + name + description + visibility + due_date + archived_at + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how lessons are displayed + # across all pages of the admin dashboard. + # + def display_resource(lesson) + lesson.name + end +end diff --git a/app/dashboards/project_dashboard.rb b/app/dashboards/project_dashboard.rb new file mode 100644 index 000000000..d9e43c22f --- /dev/null +++ b/app/dashboards/project_dashboard.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class ProjectDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + id: Field::String, + components: Field::HasMany, + identifier: Field::String, + images: Field::ActiveStorage.with_options( + direct_upload: true, + destroy_url: proc do |_namespace, _resource, attachment| + [:images_admin_project, { image_id: attachment.id }] + end + ), + locale: Field::String, + name: Field::String, + project_type: Field::String, + remixed_from_id: Field::String, + user_id: Field::String, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + name + identifier + project_type + updated_at + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + id + components + identifier + locale + name + project_type + images + remixed_from_id + user_id + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[ + images + ].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how projects are displayed + # across all pages of the admin dashboard. + # + def display_resource(project) + "Project ##{project.name}" + end + + def permitted_attributes + super + [images: []] + end +end diff --git a/app/dashboards/school_class_dashboard.rb b/app/dashboards/school_class_dashboard.rb new file mode 100644 index 000000000..3188f5c59 --- /dev/null +++ b/app/dashboards/school_class_dashboard.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class SchoolClassDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + school: Field::BelongsTo, + teachers: Field::HasMany, + students: Field::HasMany, + lessons: Field::HasMany, + id: Field::String, + name: Field::String, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + name + lessons + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + school + lessons + teachers + students + name + id + created_at + updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how school classes are displayed + # across all pages of the admin dashboard. + # + def display_resource(school_class) + school_class.name + end +end diff --git a/app/dashboards/school_dashboard.rb b/app/dashboards/school_dashboard.rb new file mode 100644 index 000000000..97db07828 --- /dev/null +++ b/app/dashboards/school_dashboard.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class SchoolDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + id: Field::String, + creator: Field::BelongsTo.with_options(class_name: 'User'), + postal_code: Field::String, + creator_role: Field::String, + creator_department: Field::String, + name: Field::String, + website: Field::String, + address_line_1: Field::String, + address_line_2: Field::String, + municipality: Field::String, + administrative_area: Field::String, + country_code: CountryField, + classes: Field::HasMany, + lessons: Field::HasMany, + projects: Field::HasMany, + reference: Field::String, + verified_at: Field::DateTime, + rejected_at: Field::DateTime, + created_at: Field::DateTime, + updated_at: Field::DateTime, + user_origin: EnumField + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + name + user_origin + reference + country_code + created_at + verified_at + rejected_at + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + name + user_origin + creator + creator_role + creator_department + reference + website + address_line_1 + address_line_2 + municipality + administrative_area + postal_code + country_code + classes + lessons + projects + created_at + updated_at + verified_at + rejected_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[ + name + reference + website + address_line_1 + address_line_2 + municipality + postal_code + administrative_area + country_code + ].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how projects are displayed + # across all pages of the admin dashboard. + # + def display_resource(school) + school.name.to_s + end +end diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb new file mode 100644 index 000000000..ebc336d7d --- /dev/null +++ b/app/dashboards/user_dashboard.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'administrate/base_dashboard' + +class UserDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + name: Field::String, + username: Field::String, + roles: Field::String, + email: Field::String, + country: CountryField, + organisations: Field::String + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = %i[ + name + username + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = %i[ + name + username + email + postcode + country + roles + organisations + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = %i[].freeze + + def display_resource(user) + "#{user.name} (#{user.email})" + end +end diff --git a/app/fields/country_field.rb b/app/fields/country_field.rb new file mode 100644 index 000000000..a298734f2 --- /dev/null +++ b/app/fields/country_field.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'administrate/field/base' + +class CountryField < Administrate::Field::Base + def to_s + ISO3166::Country.find_country_by_alpha2(data) + end +end diff --git a/app/fields/enum_field.rb b/app/fields/enum_field.rb new file mode 100644 index 000000000..1e3ae8b4a --- /dev/null +++ b/app/fields/enum_field.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class EnumField < Administrate::Field::Select + def to_s + # Use Rails' i18n for enums + return if data.blank? + + I18n.t( + "activerecord.attributes.#{resource.class.model_name.i18n_key}.#{attribute}_values.#{data}", + default: data.humanize + ) + end +end diff --git a/app/graphql/editor_api_schema.rb b/app/graphql/editor_api_schema.rb index 275c33879..e71d3cb15 100644 --- a/app/graphql/editor_api_schema.rb +++ b/app/graphql/editor_api_schema.rb @@ -18,6 +18,9 @@ class EditorApiSchema < GraphQL::Schema default_max_page_size 10 + # Turn off introspection on production + disable_introspection_entry_points if Rails.env.production? + # GraphQL-Ruby calls this when something goes wrong while running a query: # def self.type_error(err, context) # # if err.is_a?(GraphQL::InvalidNullError) diff --git a/app/graphql/mutations/create_component.rb b/app/graphql/mutations/create_component.rb index 421733d30..e1896fd97 100644 --- a/app/graphql/mutations/create_component.rb +++ b/app/graphql/mutations/create_component.rb @@ -26,9 +26,7 @@ def resolve(**input) end def ready?(**_args) - if context[:current_ability]&.can?(:create, Component, Project.new(user_id: context[:current_user_id])) - return true - end + return true if context[:current_ability]&.can?(:create, Component, Project.new(user_id: context[:current_user]&.id)) raise GraphQL::ExecutionError, 'You are not permitted to create a component' end diff --git a/app/graphql/mutations/create_project.rb b/app/graphql/mutations/create_project.rb index ea3419fba..07c98c3f7 100644 --- a/app/graphql/mutations/create_project.rb +++ b/app/graphql/mutations/create_project.rb @@ -8,17 +8,19 @@ class CreateProject < BaseMutation field :project, Types::ProjectType, description: 'The project that has been created' def resolve(**input) - project_hash = input.merge(user_id: context[:current_user_id], - components: input[:components]&.map(&:to_h)) + project_hash = input.merge( + user_id: context[:current_user]&.id, + components: input[:components]&.map(&:to_h) + ) - response = Project::Create.call(project_hash:) + response = Project::Create.call(project_hash:, current_user: context[:current_user]) raise GraphQL::ExecutionError, response[:error] unless response.success? { project: response[:project] } end def ready?(**_args) - return true if context[:current_ability]&.can?(:create, Project, user_id: context[:current_user_id]) + return true if context[:current_ability]&.can?(:create, Project, user_id: context[:current_user]&.id) raise GraphQL::ExecutionError, 'You are not permitted to create a project' end diff --git a/app/graphql/mutations/delete_project.rb b/app/graphql/mutations/delete_project.rb index acd9d81c6..c371219fe 100644 --- a/app/graphql/mutations/delete_project.rb +++ b/app/graphql/mutations/delete_project.rb @@ -13,9 +13,7 @@ def resolve(**input) raise GraphQL::ExecutionError, 'Project not found' unless project - unless context[:current_ability].can?(:destroy, project) - raise GraphQL::ExecutionError, 'You are not permitted to delete that project' - end + raise GraphQL::ExecutionError, 'You are not permitted to delete that project' unless context[:current_ability].can?(:destroy, project) return { id: project.id } if project.destroy @@ -23,7 +21,7 @@ def resolve(**input) end def ready?(...) - return true if context[:current_ability]&.can?(:destroy, Project, user_id: context[:current_user_id]) + return true if context[:current_ability]&.can?(:destroy, Project, user_id: context[:current_user]&.id) raise GraphQL::ExecutionError, 'You are not permitted to delete projects' end diff --git a/app/graphql/mutations/remix_project.rb b/app/graphql/mutations/remix_project.rb index 9ff9db36f..e95c728f2 100644 --- a/app/graphql/mutations/remix_project.rb +++ b/app/graphql/mutations/remix_project.rb @@ -13,12 +13,18 @@ def resolve(**input) raise GraphQL::ExecutionError, 'You are not permitted to read this project' unless can_read?(original_project) params = { - name: input[:name] || original_project.name, + name: remix_name(input, original_project), identifier: original_project.identifier, components: remix_components(input, original_project) } - response = Project::CreateRemix.call(params:, user_id: context[:current_user_id], - original_project:) + + response = Project::CreateRemix.call( + params:, + remix_origin:, + user_id: context[:current_user]&.id, + original_project: + ) + raise GraphQL::ExecutionError, response[:error] unless response.success? { project: response[:project] } @@ -31,7 +37,7 @@ def ready?(**_args) end def can_create_project? - context[:current_ability]&.can?(:create, Project, user_id: context[:current_user_id]) + context[:current_ability]&.can?(:create, Project, user_id: context[:current_user]&.id) end def can_read?(original_project) @@ -41,5 +47,13 @@ def can_read?(original_project) def remix_components(input, original_project) input[:components]&.map(&:to_h) || original_project.components end + + def remix_origin + context[:remix_origin] + end + + def remix_name(input, original_project) + input[:name] || original_project.name + end end end diff --git a/app/graphql/mutations/update_component.rb b/app/graphql/mutations/update_component.rb index 881537b89..56e3e770d 100644 --- a/app/graphql/mutations/update_component.rb +++ b/app/graphql/mutations/update_component.rb @@ -23,7 +23,7 @@ def resolve(**input) end def ready?(**_args) - return true if context[:current_ability]&.can?(:update, Component, user_id: context[:current_user_id]) + return true if context[:current_ability]&.can?(:update, Component, user_id: context[:current_user]&.id) raise GraphQL::ExecutionError, 'You are not permitted to update a component' end diff --git a/app/graphql/mutations/update_project.rb b/app/graphql/mutations/update_project.rb index 038a62c0f..bab48bdd5 100644 --- a/app/graphql/mutations/update_project.rb +++ b/app/graphql/mutations/update_project.rb @@ -23,7 +23,7 @@ def resolve(**input) end def ready?(**_args) - return true if context[:current_ability]&.can?(:update, Project, user_id: context[:current_user_id]) + return true if context[:current_ability]&.can?(:update, Project, user_id: context[:current_user]&.id) raise GraphQL::ExecutionError, 'You are not permitted to update a project' end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index fe9563f40..f24983f5b 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -15,7 +15,7 @@ class QueryType < Types::BaseObject description: 'List of preferred project locales, defaults to ["en"]' end - field :projects, Types::ProjectType.connection_type, 'All viewable projects' do + field :projects, Types::ProjectType.connection_type, 'All viewable personal projects' do argument :user_id, String, required: false, description: 'Filter by user ID' end @@ -26,7 +26,7 @@ def project(identifier:, preferred_locales: ['en']) def projects(user_id: nil) results = Project.accessible_by(context[:current_ability], :show).order(updated_at: :desc) - results = results.where(user_id:) if user_id + results = results.where(user_id:, school_id: nil, lesson_id: nil) if user_id results end diff --git a/app/helpers/admin/project_helper.rb b/app/helpers/admin/project_helper.rb new file mode 100644 index 000000000..24e92558d --- /dev/null +++ b/app/helpers/admin/project_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Admin + module ProjectHelper + def projects_link(search_term, scope = nil) + params = {}.tap do |hsh| + hsh[:search] = search_term if search_term.present? + hsh[:scope] = scope if scope.present? + end + admin_projects_path(params) + end + + def sanitized_project_order_params + params.permit(:search, :id, :order, :page, :per_page, :direction, :orders, :scope) + end + end +end diff --git a/app/helpers/authentication_helper.rb b/app/helpers/authentication_helper.rb new file mode 100644 index 000000000..4acf0039e --- /dev/null +++ b/app/helpers/authentication_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# TODO: re-enable cop +# rubocop:disable Rails/HelperInstanceVariable + +module AuthenticationHelper + def current_user + return @current_user if @current_user + return nil unless session[:current_user] + + @current_user = User.new(session[:current_user]) + end + + def current_user=(user) + session[:current_user] = user.serializable_hash + + @current_user = user + end +end + +# rubocop:enable Rails/HelperInstanceVariable diff --git a/app/jobs/create_students_job.rb b/app/jobs/create_students_job.rb new file mode 100644 index 000000000..bb1360a19 --- /dev/null +++ b/app/jobs/create_students_job.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class ConcurrencyExceededForSchool < StandardError; end + +class CreateStudentsJob < ApplicationJob + retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |_job, e| + Sentry.capture_exception(e) + raise e + end + + # Don't retry... + rescue_from ConcurrencyExceededForSchool do |e| + Rails.logger.error "Only one job per school can be enqueued at a time: #{school_id}" + Sentry.capture_exception(e) + raise e + end + + # Don't retry... + rescue_from ActiveRecord::RecordInvalid do |e| + Rails.logger.error "Failed to create student role: #{e.record.errors.full_messages.join(', ')}" + Sentry.capture_exception(e) + raise e + end + + include GoodJob::ActiveJobExtensions::Concurrency + + queue_as :default + + # Restrict to one job per school to avoid duplicates + good_job_control_concurrency_with( + key: -> { "create_students_job_#{arguments.first[:school_id]}" }, + total_limit: 1 + ) + + def self.attempt_perform_later(school_id:, students:, token:, user_id:) + concurrency_key = "create_students_job_#{school_id}" + existing_jobs = GoodJob::Job.where(concurrency_key:, finished_at: nil) + + raise ConcurrencyExceededForSchool, 'Only one job per school can be enqueued at a time.' if existing_jobs.exists? + + ActiveRecord::Base.transaction do + job = perform_later(school_id:, students:, token:) + UserJob.create!(user_id:, good_job_id: job.job_id) unless job.nil? + + job + end + end + + def perform(school_id:, students:, token:) + students.each { |student| student[:password] = DecryptionHelpers.decrypt_password(student[:password]) } + responses = ProfileApiClient.create_school_students(token:, students:, school_id:) + return if responses[:created].blank? + + responses[:created].each do |user_id| + Role.student.create!(school_id:, user_id:) + end + end +end diff --git a/app/jobs/upload_job.rb b/app/jobs/upload_job.rb index ef7ca0543..0f4021cd6 100644 --- a/app/jobs/upload_job.rb +++ b/app/jobs/upload_job.rb @@ -1,10 +1,28 @@ # frozen_string_literal: true require 'open-uri' -require 'project_importer' require 'github_api' +require 'locales' + +class InvalidDirectoryStructureError < StandardError; end +class DataNotFoundError < StandardError; end class UploadJob < ApplicationJob + retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |_job, e| + Sentry.capture_exception(e) + raise e + end + + # Don't retry... + rescue_from DataNotFoundError, InvalidDirectoryStructureError do |e| + Sentry.capture_exception(e) + raise e + end + + VALID_LOCALES = Locales.load_locales.freeze + + @skip_job = false + ProjectContentQuery = GithubApi::Client.parse <<-GRAPHQL query($owner: String!, $repository: String!, $expression: String!) { repository(owner: $owner, name: $repository) { @@ -34,11 +52,22 @@ class UploadJob < ApplicationJob } GRAPHQL + PROJECT_CONFIG = 'project_config.yml' + def perform(payload) modified_locales(payload).each do |locale| projects_data = load_projects_data(locale, repository(payload), owner(payload)) + + # A missing repo just returns nil... + raise DataNotFoundError, "Data not found in: #{repository(payload)} (with locale: #{locale})" if projects_data.data&.repository&.object.nil? + projects_data.data.repository.object.entries.each do |project_dir| project = format_project(project_dir, locale, repository(payload), owner(payload)) + if @skip_job + Rails.logger.warn "Build skipped for #{project[:name]}" + next + end + project_importer = ProjectImporter.new(**project) project_importer.import! end @@ -50,29 +79,75 @@ def perform(payload) def modified_locales(payload) commits = payload[:commits] modified_paths = commits.map { |commit| commit[:added] | commit[:modified] | commit[:removed] }.flatten - modified_paths.map { |path| path.split('/')[0] }.uniq + locales = modified_paths.map { |path| path.split('/')[0] }.uniq + locales.select { |locale| VALID_LOCALES.include?(locale.to_sym) } end def load_projects_data(locale, repository, owner) - GithubApi::Client.query( + response = GithubApi::Client.query( ProjectContentQuery, variables: { repository:, owner:, expression: "#{ENV.fetch('http://github.com/GITHUB_WEBHOOK_REF')}:#{locale}/code" } ) + + handle_graphql_errors(response) + response + end + + def handle_graphql_errors(response) + errors = response&.errors || response&.data&.errors + return if errors.blank? + + raise GraphQL::Client::Error, "GraphQL query failed with errors: #{errors.inspect}" end def format_project(project_dir, locale, repository, owner) - components = [] - images = [] - project_dir.object.entries.each do |file| - if file.name == 'project_config.yml' - @proj_config = YAML.safe_load(file.object.text, symbolize_names: true) - elsif file.object.text - components << component(file) + data = project_dir.object + validate_directory_structure(data) + + proj_config_file = data.entries.find { |file| file.name == PROJECT_CONFIG } + proj_config = YAML.safe_load(proj_config_file.object.text, symbolize_names: true) + + if proj_config[:build] == false + @skip_job = true + return proj_config + end + + files = data.entries.reject { |file| file.name == PROJECT_CONFIG } + categorized_files = categorize_files(files, project_dir, locale, repository, owner) + + { **proj_config, locale:, **categorized_files } + end + + def validate_directory_structure(data) + raise InvalidDirectoryStructureError, 'The directory structure is incorrect and the job can\'t be processed.' unless data.respond_to?(:entries) + end + + def categorize_files(files, project_dir, locale, repository, owner) + categories = { + components: [], + images: [], + videos: [], + audio: [] + } + + files.each do |file| + mime_type = file_mime_type(file) + + case mime_type + when %r{text|application/javascript} + categories[:components] << component(file) + when /image/ + categories[:images] << media(file, project_dir, locale, repository, owner) + when /video/ + categories[:videos] << media(file, project_dir, locale, repository, owner) + when /audio/ + categories[:audio] << media(file, project_dir, locale, repository, owner) else - images << image(file, project_dir, locale, repository, owner) + raise "Unsupported file type: #{mime_type}" end end - { **@proj_config, locale:, components:, images: } + + categories end def component(file) @@ -83,7 +158,7 @@ def component(file) { name:, extension:, content:, default: } end - def image(file, project_dir, locale, repository, owner) + def media(file, project_dir, locale, repository, owner) filename = file.name directory = project_dir.name url = "https://github.com/#{owner}/#{repository}/raw/#{ENV.fetch('http://github.com/GITHUB_WEBHOOK_REF')}/#{locale}/code/#{directory}/#{filename}" @@ -97,4 +172,8 @@ def repository(payload) def owner(payload) payload[:repository][:owner][:name] end + + def file_mime_type(file) + Marcel::MimeType.for(file.object, name: file.name) + end end diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb new file mode 100644 index 000000000..d90631277 --- /dev/null +++ b/app/mailers/invitation_mailer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class InvitationMailer < ApplicationMailer + default from: email_address_with_name('web@raspberrypi.org', 'Raspberry Pi Foundation') + + def invite_teacher + @school = params[:invitation].school + @token = params[:invitation].generate_token_for(:teacher_invitation) + + mail(to: params[:invitation].email_address, + subject: "You have been invited to join #{@school.name}", + track_opens: 'true', + message_stream: 'outbound') + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index fbaabb4a6..a7b65202e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -4,12 +4,121 @@ class Ability include CanCan::Ability def initialize(user) - can :show, Project, user_id: nil - can :show, Component, project: { user_id: nil } + define_common_non_student_abilities(user) - return if user.blank? + return unless user - can %i[read create update destroy], Project, user_id: user - can %i[read create update destroy], Component, project: { user_id: user } + define_authenticated_non_student_abilities(user) + user.schools.each do |school| + define_school_student_abilities(user:, school:) if user.school_student?(school) + define_school_teacher_abilities(user:, school:) if user.school_teacher?(school) + define_school_owner_abilities(school:) if user.school_owner?(school) + end + + define_experience_cs_admin_abilities(user) + end + + private + + def define_common_non_student_abilities(user) + return if user&.student? + + # Anyone can view projects not owned by a user or a school. + can :show, Project, user_id: nil, school_id: nil + can :show, Component, project: { user_id: nil, school_id: nil } + + # Anyone can read publicly shared lessons. + can :read, Lesson, visibility: 'public' + end + + def define_authenticated_non_student_abilities(user) + return if user&.student? + + # Any authenticated user can create a school. They agree to become the school-owner. + can :create, School + + # An unverified school owner can read their own school. + can :read, School, creator_id: user.id, verified_at: nil + + # Any authenticated user can create a lesson, to support a RPF library of public lessons. + can :create, Lesson, school_id: nil, school_class_id: nil + + # Any authenticated user can create a copy of a publicly shared lesson. + can :create_copy, Lesson, visibility: 'public' + + # Any authenticated user can manage their own lessons. + can %i[read create_copy update destroy], Lesson, user_id: user.id, school_id: nil + + # Any authenticated user can create projects not owned by a school. + can :create, Project, user_id: user.id, school_id: nil + can :create, Component, project: { user_id: user.id, school_id: nil } + + # Any authenticated user can manage their own projects. + can %i[read update destroy], Project, user_id: user.id + can %i[read update destroy], Component, project: { user_id: user.id } + end + + def define_school_owner_abilities(school:) + can(%i[read update destroy], School, id: school.id) + can(%i[read], :school_member) + can(%i[read create update destroy], SchoolClass, school: { id: school.id }) + can(%i[read show_context], Project, school_id: school.id, lesson: { visibility: %w[teachers students] }) + can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } }) + can(%i[read create destroy], :school_owner) + can(%i[read create destroy], :school_teacher) + can(%i[read create create_batch update destroy], :school_student) + can(%i[create create_copy], Lesson, school_id: school.id) + can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public]) + end + + def define_school_teacher_abilities(user:, school:) + can(%i[read], School, id: school.id) + can(%i[read], :school_member) + can(%i[create], SchoolClass, school: { id: school.id }) + can(%i[read update destroy], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id }) + can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } }) + can(%i[read], :school_owner) + can(%i[read], :school_teacher) + can(%i[read create create_batch update], :school_student) + can(%i[create update destroy], Lesson) do |lesson| + school_teacher_can_manage_lesson?(user:, school:, lesson:) + end + can(%i[read create_copy], Lesson, school_id: school.id, visibility: %w[teachers students]) + can(%i[create], Project) do |project| + school_teacher_can_manage_project?(user:, school:, project:) + end + can(%i[read update show_context], Project, school_id: school.id, lesson: { visibility: %w[teachers students] }) + can(%i[read], Project, + remixed_from_id: Project.where(school_id: school.id, remixed_from_id: nil, lesson_id: Lesson.where(school_class_id: ClassTeacher.where(teacher_id: user.id).select(:school_class_id))).pluck(:id)) + end + + def define_school_student_abilities(user:, school:) + can(%i[read], School, id: school.id) + can(%i[read], SchoolClass, school: { id: school.id }, students: { student_id: user.id }) + # Ensure no access to ClassMember resources, relationships otherwise allow access in some circumstances. + can(%i[read], Lesson, school_id: school.id, visibility: 'students', school_class: { students: { student_id: user.id } }) + can(%i[read create update], Project, school_id: school.id, user_id: user.id, lesson_id: nil, remixed_from_id: Project.where(school_id: school.id, lesson_id: Lesson.where(visibility: 'students').select(:id)).pluck(:id)) + can(%i[read show_context], Project, lesson: { school_id: school.id, visibility: 'students', school_class: { students: { student_id: user.id } } }) + can(%i[show_finished set_finished], SchoolProject, project: { user_id: user.id, lesson_id: nil }, school_id: school.id) + end + + def define_experience_cs_admin_abilities(user) + return unless user&.experience_cs_admin? + + can %i[read create update destroy], Project, user_id: nil + end + + def school_teacher_can_manage_lesson?(user:, school:, lesson:) + is_my_lesson = lesson.school_id == school.id && lesson.user_id == user.id + is_my_class = lesson.school_class&.teacher_ids&.include?(user.id) + + is_my_class || (is_my_lesson && !lesson.school_class) + end + + def school_teacher_can_manage_project?(user:, school:, project:) + is_my_project = project.school_id == school.id && project.user_id == user.id + is_my_lesson = project.lesson && project.lesson.user_id == user.id + + is_my_project && (is_my_lesson || !project.lesson) end end diff --git a/app/models/class_student.rb b/app/models/class_student.rb new file mode 100644 index 000000000..ba9d80b35 --- /dev/null +++ b/app/models/class_student.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ClassStudent < ApplicationRecord + belongs_to :school_class + delegate :school, to: :school_class + attr_accessor :student + + validates :student_id, presence: true, uniqueness: { + scope: :school_class_id, + case_sensitive: false + } + + validate :student_has_the_school_student_role_for_the_school + + has_paper_trail( + meta: { + meta_school_id: ->(cm) { cm.school_class&.school_id } + } + ) + + private + + def student_has_the_school_student_role_for_the_school + return unless student_id_changed? && errors.blank? && student.present? + + return if student.school_student?(school) + + msg = "'#{student.id}' does not have the 'school-student' role for organisation '#{school.id}'" + errors.add(:student, msg) + end +end diff --git a/app/models/class_teacher.rb b/app/models/class_teacher.rb new file mode 100644 index 000000000..32ba9b4d3 --- /dev/null +++ b/app/models/class_teacher.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ClassTeacher < ApplicationRecord + belongs_to :school_class + delegate :school, to: :school_class + attr_accessor :teacher + + validates :teacher_id, presence: true, uniqueness: { + scope: :school_class_id, + case_sensitive: false + } + + validate :teacher_has_the_school_teacher_role_for_the_school + + has_paper_trail( + meta: { + meta_school_id: ->(cm) { cm.school_class&.school_id } + } + ) + + private + + def teacher_has_the_school_teacher_role_for_the_school + return unless teacher_id_changed? && errors.blank? && teacher.present? + + return if teacher.school_teacher?(school) + + msg = "'#{teacher.id}' does not have the 'school-teacher' role for organisation '#{school.id}'" + errors.add(:teacher, msg) + end +end diff --git a/app/models/component.rb b/app/models/component.rb index 4bbce5ec9..cc802f61d 100644 --- a/app/models/component.rb +++ b/app/models/component.rb @@ -4,8 +4,17 @@ class Component < ApplicationRecord belongs_to :project validates :name, presence: true validates :extension, presence: true + validates :content, length: { maximum: 8_500_000 } validate :default_component_protected_properties, on: :update + has_paper_trail( + if: ->(c) { c.project&.school_id }, + meta: { + meta_project_id: ->(c) { c.project&.id }, + meta_school_id: ->(c) { c.project&.school_id } + } + ) + private def default_component_protected_properties diff --git a/app/models/filesystem_project.rb b/app/models/filesystem_project.rb new file mode 100644 index 000000000..df0891324 --- /dev/null +++ b/app/models/filesystem_project.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'yaml' + +class FilesystemProject + CODE_FORMATS = ['.py', '.csv', '.txt', '.html', '.css'].freeze + PROJECTS_ROOT = Rails.root.join('lib/tasks/project_components') + PROJECT_CONFIG = 'project_config.yml' + + def self.import_all! + PROJECTS_ROOT.each_child do |dir| + proj_config = YAML.safe_load_file(dir.join(PROJECT_CONFIG).to_s) + + files = dir.children.reject { |file| file.basename.to_s == 'project_config.yml' } + categorized_files = categorize_files(files, dir) + + project_importer = ProjectImporter.new(name: proj_config['NAME'], identifier: proj_config['IDENTIFIER'], + type: proj_config['TYPE'] || Project::Types::PYTHON, + locale: proj_config['LOCALE'] || 'en', **categorized_files) + project_importer.import! + end + end + + def self.categorize_files(files, dir) + categories = { + components: [], + images: [], + videos: [], + audio: [] + } + + files.each do |file| + if CODE_FORMATS.include? File.extname(file) + categories[:components] << component(file, dir) + else + mime_type = file_mime_type(file) + + case mime_type + when %r{text|application/javascript} + categories[:components] << component(file, dir) + when /image/ + categories[:images] << media(file, dir) + when /video/ + categories[:videos] << media(file, dir) + when /audio/ + categories[:audio] << media(file, dir) + else + raise "Unsupported file type: #{mime_type}" + end + end + end + + categories + end + + def self.component(file, dir) + name = File.basename(file, '.*') + extension = File.extname(file).delete('.') + code = File.read(dir.join(File.basename(file)).to_s) + default = (File.basename(file) == 'main.py') + { name:, extension:, content: code, default: } + end + + def self.file_mime_type(file) + Marcel::MimeType.for(File.open(file), name: File.basename(file)) + end + + def self.media(file, dir) + filename = File.basename(file) + io = File.open(dir.join(filename).to_s) + { filename:, io: } + end +end diff --git a/app/models/lesson.rb b/app/models/lesson.rb new file mode 100644 index 000000000..ae0f0dfde --- /dev/null +++ b/app/models/lesson.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Lesson < ApplicationRecord + belongs_to :school, optional: true + belongs_to :school_class, optional: true + belongs_to :parent, optional: true, class_name: :Lesson, foreign_key: :copied_from_id, inverse_of: :copies + has_many :copies, dependent: :nullify, class_name: :Lesson, foreign_key: :copied_from_id, inverse_of: :parent + has_one :project, dependent: :nullify + accepts_nested_attributes_for :project + + before_validation :assign_school_from_school_class + before_destroy -> { throw :abort } + + validates :user_id, presence: true + validates :name, presence: true + validates :visibility, presence: true, inclusion: { in: %w[private teachers students public] } + + validate :user_has_the_school_owner_or_school_teacher_role_for_the_school + validate :user_is_the_school_teacher_for_the_school_class + + scope :archived, -> { where.not(archived_at: nil) } + scope :unarchived, -> { where(archived_at: nil) } + + def self.users + User.from_userinfo(ids: pluck(:user_id)) + end + + def self.with_users + by_id = users.index_by(&:id) + all.map { |instance| [instance, by_id[instance.user_id]] } + end + + def with_user + [self, User.from_userinfo(ids: user_id).first] + end + + def archived? + archived_at.present? + end + + def archive! + return if archived? + + self.archived_at = Time.now.utc + save!(validate: false) + end + + def unarchive! + return unless archived? + + self.archived_at = nil + save!(validate: false) + end + + private + + def assign_school_from_school_class + self.school ||= school_class&.school + end + + def user_has_the_school_owner_or_school_teacher_role_for_the_school + return unless user_id_changed? && errors.blank? && school + + user = User.new(id: user_id) + return if user.school_owner?(school) + return if user.school_teacher?(school) + + msg = "'#{user_id}' does not have the 'school-owner' or 'school-teacher' role for organisation '#{school.id}'" + errors.add(:user, msg) + end + + def user_is_the_school_teacher_for_the_school_class + return if !school_class || school_class.teacher_ids.include?(user_id) + + errors.add(:user, "'#{user_id}' is not the 'school-teacher' for school_class '#{school_class.id}'") + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 165cf513a..4b4003b60 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1,17 +1,78 @@ # frozen_string_literal: true -require 'phrase_identifier' - class Project < ApplicationRecord + module Types + PYTHON = 'python' + HTML = 'html' + SCRATCH = 'scratch' + end + + belongs_to :school, optional: true + belongs_to :lesson, optional: true + belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes + has_many :remixes, dependent: :nullify, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :parent + has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project + has_many :project_errors, dependent: :nullify + has_many_attached :images + has_many_attached :videos + has_many_attached :audio + has_one :school_project, dependent: :destroy + + accepts_nested_attributes_for :components + before_validation :check_unique_not_null, on: :create + before_validation :create_school_project_if_needed + validates :identifier, presence: true, uniqueness: { scope: :locale } validate :identifier_cannot_be_taken_by_another_user validates :locale, presence: true, unless: :user_id - belongs_to :parent, class_name: 'Project', foreign_key: 'remixed_from_id', optional: true, inverse_of: :remixes - has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project - has_many :remixes, class_name: 'Project', foreign_key: 'remixed_from_id', dependent: :nullify, inverse_of: :parent - has_many_attached :images - accepts_nested_attributes_for :components + validate :user_has_a_role_within_the_school + validate :user_is_class_teacher_or_student + validate :project_with_instructions_must_belong_to_school + validate :project_with_school_id_has_school_project + validate :school_project_school_matches_project_school + + scope :internal_projects, -> { where(user_id: nil) } + + has_paper_trail( + if: ->(p) { p&.school_id }, + meta: { + meta_remixed_from_id: ->(p) { p&.remixed_from_id }, + meta_school_id: ->(p) { p&.school_id } + } + ) + + def self.users(current_user) + school = School.find_by(id: pluck(:school_id)) + SchoolStudent::List.call(school:, token: current_user.token, student_ids: pluck(:user_id).uniq)[:school_students] || [] + end + + def self.with_users(current_user) + by_id = users(current_user).index_by(&:id) + all.map { |instance| [instance, by_id[instance.user_id]] } + end + + def with_user(current_user) + school = School.find_by(id: school_id) + students = SchoolStudent::List.call(school:, token: current_user.token, + student_ids: [user_id])[:school_students] || [] + [self, students.first] + end + + # Work around a CanCanCan issue with accepts_nested_attributes_for. + # https://github.com/CanCanCommunity/cancancan/issues/774 + def components=(array) + super(array.map { |o| o.is_a?(Hash) ? Component.new(o) : o }) + end + + def last_edited_at + # datetime that the project or one of its components was last updated + [updated_at, components.maximum(:updated_at)].compact.max + end + + def media + images + videos + audio + end private @@ -19,9 +80,61 @@ def check_unique_not_null self.identifier ||= PhraseIdentifier.generate end + def create_school_project_if_needed + return unless school.present? && school_project.nil? + + self.school_project = SchoolProject.new(school:) + end + def identifier_cannot_be_taken_by_another_user return if Project.where(identifier: self.identifier).where.not(user_id:).empty? errors.add(:identifier, "can't be taken by another user") end + + def user_has_a_role_within_the_school + return unless user_id_changed? && errors.blank? && school + + user = User.new(id: user_id) + return if user.school_roles(school).any? + + msg = "'#{user_id}' does not have any roles for for organisation '#{school_id}'" + errors.add(:user, msg) + end + + def user_is_class_teacher_or_student + # TODO: Revisit the case where the lesson is not associated to a class i.e. when we build a lesson library + no_lesson = !lesson + no_school_class = lesson && !lesson.school_class + + return if no_lesson || no_school_class || user_is_class_student || user_is_class_teacher + + errors.add(:user, "'#{user_id}' is not a class member or the owner of the lesson '#{lesson_id}'") + end + + def user_is_class_student + lesson&.school_class&.students&.exists?(student_id: user_id) + end + + def user_is_class_teacher + lesson&.school_class&.teachers&.exists?(teacher_id: user_id) + end + + def project_with_instructions_must_belong_to_school + return unless instructions && !school_id + + errors.add(:instructions, 'Projects with instructions must belong to a school') + end + + def project_with_school_id_has_school_project + return unless school_id && !school_project + + errors.add(:school_project, 'Project with school_id must have a school_project') + end + + def school_project_school_matches_project_school + return unless school_id && school_project && school_id != school_project.school_id + + errors.add(:school_project, 'School project school_id must match project school_id') + end end diff --git a/app/models/project_error.rb b/app/models/project_error.rb new file mode 100644 index 000000000..f6a820cd8 --- /dev/null +++ b/app/models/project_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ProjectError < ApplicationRecord + belongs_to :project, optional: true + validates :error, presence: true +end diff --git a/app/models/role.rb b/app/models/role.rb new file mode 100644 index 000000000..e8f19165e --- /dev/null +++ b/app/models/role.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Role < ApplicationRecord + belongs_to :school + + enum :role, %i[student teacher owner] + + validates :user_id, presence: true + validates :role, presence: true, uniqueness: { scope: %i[school_id user_id] } + validate :students_cannot_have_additional_roles + validate :users_can_only_have_roles_in_one_school + + has_paper_trail( + meta: { + meta_school_id: ->(cm) { cm.school&.id } + } + ) + + private + + def students_cannot_have_additional_roles + other_roles = Role.where(user_id:) + + if other_roles.student.any? + errors.add(:base, "Cannot create #{role} role as this user already has the student role for this school") + elsif student? && other_roles.any? + other_role_names = [ + other_roles.map(&:role).join(' and '), + 'role'.pluralize(other_roles.length) + ].join(' ') + errors.add(:base, "Cannot create student role as this user already has the #{other_role_names} for this school") + end + end + + def users_can_only_have_roles_in_one_school + schools = Role.where(user_id:).map(&:school) + return if schools.empty? || (schools.any? && schools.first == school) + + errors.add(:base, 'Cannot create role as this user already has a role in a different school') + end +end diff --git a/app/models/school.rb b/app/models/school.rb new file mode 100644 index 000000000..6176bfb78 --- /dev/null +++ b/app/models/school.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +class School < ApplicationRecord + has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy + has_many :lessons, dependent: :nullify + has_many :projects, dependent: :nullify + has_many :roles, dependent: :nullify + has_many :school_projects, dependent: :nullify + + VALID_URL_REGEX = %r{\A(?:https?://)?(?:www.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,63}(\.[a-z]{2,63})*(/.*)?\z}ix + + enum :user_origin, %i[for_education experience_cs], default: :for_education, validate: true + + validates :name, presence: true + validates :website, presence: true, format: { with: VALID_URL_REGEX, message: I18n.t('validations.school.website') } + validates :address_line_1, presence: true + validates :municipality, presence: true + validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes } + validates :reference, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false + validates :creator_id, presence: true, uniqueness: true + validates :creator_agree_authority, presence: true, acceptance: true + validates :creator_agree_terms_and_conditions, presence: true, acceptance: true + validates :creator_agree_responsible_safeguarding, presence: true, acceptance: true + validates :rejected_at, absence: { if: proc { |school| school.verified? } } + validates :verified_at, absence: { if: proc { |school| school.rejected? } } + validates :code, + uniqueness: { allow_nil: true }, + presence: { if: proc { |school| school.verified? } }, + absence: { unless: proc { |school| school.verified? } }, + format: { with: /\d\d-\d\d-\d\d/, allow_nil: true } + validate :verified_at_cannot_be_changed + validate :code_cannot_be_changed + + before_validation :normalize_reference + + before_save :format_uk_postal_code, if: :should_format_uk_postal_code? + + def self.find_for_user!(user) + school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id) + raise ActiveRecord::RecordNotFound unless school + + school + end + + def creator + User.from_userinfo(ids: creator_id).first + end + + def verified? + verified_at.present? + end + + def rejected? + rejected_at.present? + end + + def verify! + attempts = 0 + begin + update!(verified_at: Time.zone.now, code: ForEducationCodeGenerator.generate) + rescue ActiveRecord::RecordInvalid => e + raise unless e.record.errors[:code].include?('has already been taken') && attempts <= 5 + + attempts += 1 + retry + end + end + + def reject + update(rejected_at: Time.zone.now) + end + + def reopen + update(rejected_at: nil) + end + + def postal_code=(str) + super(str.to_s.upcase) + end + + private + + # Ensure the reference is nil, not an empty string + def normalize_reference + self.reference = nil if reference.blank? + end + + def verified_at_cannot_be_changed + errors.add(:verified_at, 'cannot be changed after verification') if verified_at_was.present? && verified_at_changed? + end + + def rejected_at_cannot_be_changed + errors.add(:rejected_at, 'cannot be changed after rejection') if rejected_at_was.present? && rejected_at_changed? + end + + def code_cannot_be_changed + errors.add(:code, 'cannot be changed after verification') if code_was.present? && code_changed? + end + + def should_format_uk_postal_code? + country_code == 'GB' && postal_code.to_s.length >= 5 + end + + def format_uk_postal_code + cleaned_postal_code = postal_code.delete(' ') + # insert a space as the third-from-last character in the postcode, eg. SW1A1AA -> SW1A 1AA + # ensures UK postcodes are always formatted correctly (as the inward code is always 3 chars long) + self.postal_code = "#{cleaned_postal_code[0..-4]} #{cleaned_postal_code[-3..]}" + end +end diff --git a/app/models/school_class.rb b/app/models/school_class.rb new file mode 100644 index 000000000..f50dea799 --- /dev/null +++ b/app/models/school_class.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class SchoolClass < ApplicationRecord + belongs_to :school + has_many :students, class_name: :ClassStudent, inverse_of: :school_class, dependent: :destroy + has_many :teachers, class_name: :ClassTeacher, inverse_of: :school_class, dependent: :destroy + has_many :lessons, dependent: :nullify + accepts_nested_attributes_for :teachers + + scope :with_teachers, ->(user_id) { joins(:teachers).where(teachers: { id: user_id }) } + + before_validation :assign_class_code, on: :create + + validates :name, presence: true + validates :code, uniqueness: { scope: :school_id }, presence: true, format: { with: /\d\d-\d\d-\d\d/, allow_nil: false } + validate :code_cannot_be_changed + validate :school_class_has_at_least_one_teacher + + has_paper_trail( + meta: { + meta_school_id: ->(cm) { cm.school&.id } + } + ) + + def self.teachers + teacher_ids = all.map(&:teacher_ids).flatten.uniq + User.from_userinfo(ids: teacher_ids) + end + + def self.with_teachers + by_id = teachers.index_by(&:id) + all.map { |instance| [instance, instance.teacher_ids.map { |teacher_id| by_id[teacher_id] }] } + end + + def teacher_ids + teachers.pluck(:teacher_id) + end + + def with_teachers + [self, User.from_userinfo(ids: teacher_ids)] + end + + def assign_class_code + return if code.present? + + 5.times do + self.code = ForEducationCodeGenerator.generate + return if code_is_unique_within_school + end + + errors.add(:code, 'could not be generated') + end + + private + + def school_class_has_at_least_one_teacher + return if teachers.present? + + errors.add(:teachers, 'must have at least one teacher') + end + + def code_cannot_be_changed + errors.add(:code, 'cannot be changed after verification') if code_was.present? && code_changed? + end + + def code_is_unique_within_school + code.present? && SchoolClass.where(code:, school:).none? + end +end diff --git a/app/models/school_project.rb b/app/models/school_project.rb new file mode 100644 index 000000000..1ce8df1ff --- /dev/null +++ b/app/models/school_project.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class SchoolProject < ApplicationRecord + belongs_to :school + belongs_to :project +end diff --git a/app/models/teacher_invitation.rb b/app/models/teacher_invitation.rb new file mode 100644 index 000000000..bb058c570 --- /dev/null +++ b/app/models/teacher_invitation.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class TeacherInvitation < ApplicationRecord + delegate :name, to: :school, prefix: true + + belongs_to :school + validates :email_address, + format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') } + validate :school_is_verified + after_create_commit :send_invitation_email + encrypts :email_address + + generates_token_for :teacher_invitation, expires_in: 30.days do + email_address + end + + private + + def school_is_verified + return if school.verified? + + errors.add(:school, 'is not verified') + end + + def send_invitation_email + InvitationMailer.with(invitation: self).invite_teacher.deliver_later + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..386ab2615 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +class User + include ActiveModel::Serialization + include ActiveModel::Model + + ATTRIBUTES = %w[ + country + country_code + email + email_verified + id + name + nickname + picture + postcode + profile + token + username + roles + ].freeze + + attr_accessor(*ATTRIBUTES) + + def attributes + ATTRIBUTES.index_with { |_k| nil } + end + + def schools + School.joins(:roles).merge(Role.where(user_id: id)).distinct + end + + def school_roles(school) + Role.where(school:, user_id: id).map(&:role) + end + + def school_owner?(school) + Role.owner.find_by(school:, user_id: id) + end + + def school_teacher?(school) + Role.teacher.find_by(school:, user_id: id) + end + + def school_student?(school) + Role.student.find_by(school:, user_id: id) + end + + def student? + Role.student.exists?(user_id: id) + end + + def admin? + parsed_roles.include?('editor-admin') + end + + def experience_cs_admin? + parsed_roles.include?('experience-cs-admin') + end + + def parsed_roles + roles&.to_s&.split(',')&.map(&:strip) || [] + end + + def ==(other) + id == other.id + end + + def self.where(id:) + from_userinfo(ids: id) + end + + def self.from_userinfo(ids:) + user_ids = Array(ids) + + UserInfoApiClient.fetch_by_ids(user_ids).map do |info| + info = info.stringify_keys + args = info.slice(*ATTRIBUTES) + + new(args) + end + end + + def self.from_omniauth(auth = nil) + return nil unless auth + + from_auth(auth) + end + + def self.from_auth(auth) + return nil unless auth + + args = auth.extra.raw_info.to_h.slice(*ATTRIBUTES) + args['id'] = auth.uid + args['token'] = auth.credentials&.token + + new(args) + end + + def self.from_token(token:) + return nil if token.blank? + + auth = HydraPublicApiClient.fetch_oauth_user(token:) + return nil if auth.blank? + + auth = auth.stringify_keys + args = auth.slice(*ATTRIBUTES) + + args['id'] ||= auth['sub'].sub('student:', '') + args['token'] = token + + new(args) + end +end diff --git a/app/models/user_job.rb b/app/models/user_job.rb new file mode 100644 index 000000000..5a0058669 --- /dev/null +++ b/app/models/user_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UserJob < ApplicationRecord + belongs_to :good_job, class_name: 'GoodJob::Job' + + validates :user_id, presence: true + + attr_accessor :user +end diff --git a/app/models/word.rb b/app/models/word.rb deleted file mode 100644 index 4a6cb9b32..000000000 --- a/app/models/word.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class Word < ApplicationRecord -end diff --git a/app/services/school_verification_service.rb b/app/services/school_verification_service.rb new file mode 100644 index 000000000..579bfdfdc --- /dev/null +++ b/app/services/school_verification_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class SchoolVerificationService + attr_reader :school + + def initialize(school) + @school = school + end + + def verify(token:) + School.transaction do + school.verify! + Role.owner.create!(user_id: school.creator_id, school:) + Role.teacher.create!(user_id: school.creator_id, school:) + ProfileApiClient.create_school(token:, id: school.id, code: school.code) + end + rescue StandardError => e + Sentry.capture_exception(e) + Rails.logger.error { "Failed to verify school #{@school.id}: #{e.message}" } + false + else + true + end + + delegate :reject, to: :school + delegate :reopen, to: :school +end diff --git a/app/views/admin/schools/show.html.erb b/app/views/admin/schools/show.html.erb new file mode 100644 index 000000000..ebc796f52 --- /dev/null +++ b/app/views/admin/schools/show.html.erb @@ -0,0 +1,82 @@ +<%# +# Show + +This view is the template for the show page. +It renders the attributes of a resource, +as well as a link to its edit page. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Show][1]. + Contains methods for accessing the resource to be displayed on the page, + as well as helpers for describing how each attribute of the resource + should be displayed. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Show +%> + +<% content_for(:title) { t("administrate.actions.show_resource", name: page.page_title) } %> +
+

+ <%= content_for(:title) %> +

+
+ <%= link_to( + t("administrate.actions.edit_resource", name: page.page_title), + [:edit, namespace, page.resource], + class: "button", + ) if accessible_action?(page.resource, :edit) %> + + <%= link_to( + t("administrate.actions.verify_school"), + verify_admin_school_path(page.resource), + class: "button button--verify", + method: :post, + style: "background-color: green; margin: 5px;", + data: { confirm: t("administrate.actions.confirm_verify_school") } + ) unless page.resource.verified? || page.resource.rejected? %> + + <%= link_to( + t("administrate.actions.reopen_school"), + reopen_admin_school_path(page.resource), + class: "button button--verify", + method: :patch, + style: "background-color: darkorange; margin: 5px;", + data: { confirm: t("administrate.actions.confirm_reopen_school") } + ) unless page.resource.verified? || !page.resource.rejected? %> + + <%= link_to( + t("administrate.actions.reject_school"), + reject_admin_school_path(page.resource), + class: "button button--danger", + method: :patch, + style: "background-color: hsla(0, 88%, 33%, 1); margin: 5px 5px 5px 0;", + data: { confirm: t("administrate.actions.confirm_reject_school") } + ) unless page.resource.verified? || page.resource.rejected? %> +
+
+ +
+
+ <% page.attributes.each do |title, attributes| %> +
"> + <% if title.present? %> + <%= t "helpers.label.#{page.resource_name}.#{title}", default: title %> + <% end %> + + <% attributes.each do |attribute| %> +
+ <%= t( + "helpers.label.#{resource_name}.#{attribute.name}", + default: page.resource.class.human_attribute_name(attribute.name), + ) %> +
+ +
<%= render_field attribute, page: page %>
+ <% end %> +
+ <% end %> +
+
diff --git a/app/views/api/class_members/_class_member.json.jbuilder b/app/views/api/class_members/_class_member.json.jbuilder new file mode 100644 index 000000000..eeaaa67b1 --- /dev/null +++ b/app/views/api/class_members/_class_member.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.call( + class_member, + :id, + :school_class_id, + :created_at, + :updated_at +) + +if class_member.respond_to?(:student) + json.student_id(class_member.student_id) + json.set! :student do + json.partial! '/api/school_students/school_student', student: class_member.student + end +elsif class_member.respond_to?(:teacher) + json.teacher_id(class_member.teacher_id) + if @school_owner_ids.include?(class_member.teacher_id) + json.set! :owner do + json.partial! '/api/school_owners/school_owner', owner: class_member.teacher + end + else + json.set! :teacher do + json.partial! '/api/school_teachers/school_teacher', teacher: class_member.teacher + end + end +end diff --git a/app/views/api/class_members/index.json.jbuilder b/app/views/api/class_members/index.json.jbuilder new file mode 100644 index 000000000..31512e6e9 --- /dev/null +++ b/app/views/api/class_members/index.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.array!(@class_members) do |class_member| + if class_member.respond_to?(:student_id) + json.partial! 'class_member', class_member: + elsif @school_owner_ids.include?(class_member.id) + json.set! :owner do + json.partial! '/api/school_owners/school_owner', owner: class_member + end + else + json.set! :teacher do + json.partial! '/api/school_teachers/school_teacher', teacher: class_member + end + end +end diff --git a/app/views/api/class_members/show.json.jbuilder b/app/views/api/class_members/show.json.jbuilder new file mode 100644 index 000000000..c4cbeeeb1 --- /dev/null +++ b/app/views/api/class_members/show.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +if defined?(@class_members) + json.array! @class_members do |class_member| + json.partial! 'class_member', class_member: + end +elsif defined?(@class_member) + json.class_member do + json.partial! 'class_member', class_member: @class_member + end +end diff --git a/app/views/api/lessons/index.json.jbuilder b/app/views/api/lessons/index.json.jbuilder new file mode 100644 index 000000000..74d8d0a43 --- /dev/null +++ b/app/views/api/lessons/index.json.jbuilder @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +json.array!(@lessons_with_users) do |lesson, user| + json.call( + lesson, + :id, + :school_id, + :school_class_id, + :copied_from_id, + :user_id, + :name, + :description, + :visibility, + :due_date, + :archived_at, + :created_at, + :updated_at + ) + + if lesson.project + json.project( + lesson.project, + :identifier, + :project_type + ) + end + + json.user_name(user&.name) +end diff --git a/app/views/api/lessons/show.json.jbuilder b/app/views/api/lessons/show.json.jbuilder new file mode 100644 index 000000000..b99bd4bb7 --- /dev/null +++ b/app/views/api/lessons/show.json.jbuilder @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +lesson, user = @lesson_with_user + +json.call( + lesson, + :id, + :school_id, + :school_class_id, + :copied_from_id, + :user_id, + :name, + :description, + :visibility, + :due_date, + :archived_at, + :created_at, + :updated_at +) + +if lesson.project + json.project( + lesson.project, + :identifier, + :project_type + ) +end + +json.user_name(user&.name) diff --git a/app/views/api/my_school/show.json.jbuilder b/app/views/api/my_school/show.json.jbuilder new file mode 100644 index 000000000..40f10f263 --- /dev/null +++ b/app/views/api/my_school/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! '/api/schools/school', school: @school, roles: @user.school_roles(@school), code: true diff --git a/app/views/api/projects/context.json.jbuilder b/app/views/api/projects/context.json.jbuilder new file mode 100644 index 000000000..5e7f4c685 --- /dev/null +++ b/app/views/api/projects/context.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +json.call( + @project, + :identifier, + :project_type, + :school_id, + :lesson_id +) + +json.class_id(@project.lesson.school_class_id) diff --git a/app/views/api/projects/index.json.jbuilder b/app/views/api/projects/index.json.jbuilder index f29fe2640..654cb5763 100644 --- a/app/views/api/projects/index.json.jbuilder +++ b/app/views/api/projects/index.json.jbuilder @@ -1,3 +1,10 @@ # frozen_string_literal: true -json.array! @paginated_projects, :identifier, :project_type, :name, :user_id, :updated_at +json.array!( + @paginated_projects, + :identifier, + :project_type, + :name, + :user_id, + :updated_at +) diff --git a/app/views/api/projects/remixes/index.json.jbuilder b/app/views/api/projects/remixes/index.json.jbuilder new file mode 100644 index 000000000..774d0063e --- /dev/null +++ b/app/views/api/projects/remixes/index.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +json.array!(@projects_with_users) do |project, user| + json.call( + project, + :identifier, + :project_type, + :name, + :user_id, + :updated_at, + :last_edited_at + ) + + json.user_name(user&.name) + json.finished(project.school_project&.finished) +end diff --git a/app/views/api/projects/show.json.jbuilder b/app/views/api/projects/show.json.jbuilder index 4f1803aa6..d40f827ad 100644 --- a/app/views/api/projects/show.json.jbuilder +++ b/app/views/api/projects/show.json.jbuilder @@ -1,12 +1,44 @@ # frozen_string_literal: true -json.call(@project, :identifier, :project_type, :locale, :name, :user_id) +json.call( + @project, + :identifier, + :project_type, + :locale, + :name, + :user_id, + :instructions +) -json.parent(@project.parent, :name, :identifier) if @project.parent +if @project.parent + json.parent( + @project.parent, + :name, + :identifier + ) +end + +json.components( + @project.components, + :id, + :name, + :extension, + :content +) + +json.image_list(@project.images) do |image| + json.filename(image.filename) + json.url(http://github.com/rails_blob_url(image)) +end -json.components @project.components, :id, :name, :extension, :content +json.videos(@project.videos) do |video| + json.filename(video.filename) + json.url(http://github.com/rails_blob_url(video)) +end -json.image_list @project.images do |image| - json.filename image.filename - json.url rails_blob_url(http://github.com/image) +json.audio(@project.audio) do |audio_file| + json.filename(audio_file.filename) + json.url(http://github.com/rails_blob_url(audio_file)) end + +json.user_name(@user&.name) if @user.present? && @project.parent diff --git a/app/views/api/school_classes/index.json.jbuilder b/app/views/api/school_classes/index.json.jbuilder new file mode 100644 index 000000000..ffe264341 --- /dev/null +++ b/app/views/api/school_classes/index.json.jbuilder @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +json.array!(@school_classes_with_teachers) do |school_class, teachers| + json.call( + school_class, + :id, + :description, + :school_id, + :name, + :created_at, + :updated_at + ) + + json.teachers(teachers) do |teacher| + json.partial! '/api/school_teachers/school_teacher', teacher:, include_email: false if teacher.present? + end +end diff --git a/app/views/api/school_classes/show.json.jbuilder b/app/views/api/school_classes/show.json.jbuilder new file mode 100644 index 000000000..b28535b24 --- /dev/null +++ b/app/views/api/school_classes/show.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +school_class, teachers = @school_class_with_teachers + +json.call( + school_class, + :id, + :description, + :school_id, + :name, + :code, + :created_at, + :updated_at +) + +json.teachers(teachers) do |teacher| + json.partial! '/api/school_teachers/school_teacher', teacher:, include_email: false +end diff --git a/app/views/api/school_members/index.json.jbuilder b/app/views/api/school_members/index.json.jbuilder new file mode 100644 index 000000000..cb5d1062a --- /dev/null +++ b/app/views/api/school_members/index.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.array!(@school_members) do |school_member| + case school_member.type + when :owner + json.set! :owner do + json.partial! '/api/school_owners/school_owner', owner: school_member + end + when :teacher + json.set! :teacher do + json.partial! '/api/school_teachers/school_teacher', teacher: school_member + end + else + json.set! :student do + json.partial! '/api/school_students/school_student', student: school_member + end + end +end diff --git a/app/views/api/school_owners/_school_owner.json.jbuilder b/app/views/api/school_owners/_school_owner.json.jbuilder new file mode 100644 index 000000000..d002c3852 --- /dev/null +++ b/app/views/api/school_owners/_school_owner.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.call(owner, :id, :name) +json.type('owner') + +include_email = local_assigns.fetch(:include_email, true) +json.email(owner.email) if include_email diff --git a/app/views/api/school_owners/index.json.jbuilder b/app/views/api/school_owners/index.json.jbuilder new file mode 100644 index 000000000..f413ed0a7 --- /dev/null +++ b/app/views/api/school_owners/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array!(@school_owners) do |owner| + json.partial! 'school_owner', owner: +end diff --git a/app/views/api/school_projects/finished.json.jbuilder b/app/views/api/school_projects/finished.json.jbuilder new file mode 100644 index 000000000..74d408152 --- /dev/null +++ b/app/views/api/school_projects/finished.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +json.call( + @school_project, + :id, + :school_id, + :project_id, + :finished +) + +json.identifier(@school_project.project.identifier) diff --git a/app/views/api/school_students/_school_student.json.jbuilder b/app/views/api/school_students/_school_student.json.jbuilder new file mode 100644 index 000000000..ae43c7df0 --- /dev/null +++ b/app/views/api/school_students/_school_student.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.call(student, :id, :username, :name) +json.type('student') diff --git a/app/views/api/school_students/create_batch.json.jbuilder b/app/views/api/school_students/create_batch.json.jbuilder new file mode 100644 index 000000000..7c4447b23 --- /dev/null +++ b/app/views/api/school_students/create_batch.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.job_id @job_id diff --git a/app/views/api/school_students/index.json.jbuilder b/app/views/api/school_students/index.json.jbuilder new file mode 100644 index 000000000..7ee101ed3 --- /dev/null +++ b/app/views/api/school_students/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array!(@school_students) do |student| + json.partial! 'school_student', student: +end diff --git a/app/views/api/school_teachers/_school_teacher.json.jbuilder b/app/views/api/school_teachers/_school_teacher.json.jbuilder new file mode 100644 index 000000000..fe6255a89 --- /dev/null +++ b/app/views/api/school_teachers/_school_teacher.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +json.call(teacher, :id, :name) +json.type('teacher') + +include_email = local_assigns.fetch(:include_email, true) + +json.email(teacher.email) if include_email diff --git a/app/views/api/school_teachers/index.json.jbuilder b/app/views/api/school_teachers/index.json.jbuilder new file mode 100644 index 000000000..c29bfd298 --- /dev/null +++ b/app/views/api/school_teachers/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array!(@school_teachers) do |teacher| + json.partial! 'school_teacher', teacher: +end diff --git a/app/views/api/schools/_school.json.jbuilder b/app/views/api/schools/_school.json.jbuilder new file mode 100644 index 000000000..7dea9024d --- /dev/null +++ b/app/views/api/schools/_school.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.call( + school, + :id, + :name, + :website, + :reference, + :address_line_1, + :address_line_2, + :municipality, + :administrative_area, + :postal_code, + :country_code, + :verified_at, + :created_at, + :updated_at +) + +include_roles = local_assigns.fetch(:roles, false) +json.roles(roles) if include_roles + +include_code = local_assigns.fetch(:code, false) +json.code(school.code) if include_code + +include_user_origin = local_assigns.fetch(:user_origin, false) +json.user_origin(school.user_origin) if include_user_origin diff --git a/app/views/api/schools/index.json.jbuilder b/app/views/api/schools/index.json.jbuilder new file mode 100644 index 000000000..688131641 --- /dev/null +++ b/app/views/api/schools/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array!(@schools) do |school| + json.partial! 'school', school: +end diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder new file mode 100644 index 000000000..fb913b0cc --- /dev/null +++ b/app/views/api/schools/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'school', school: @school, user_origin: true diff --git a/app/views/api/teacher_invitations/show.json.jbuilder b/app/views/api/teacher_invitations/show.json.jbuilder new file mode 100644 index 000000000..dd735e413 --- /dev/null +++ b/app/views/api/teacher_invitations/show.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +json.call( + @invitation, + :school_name +) diff --git a/app/views/auth/index.html.erb b/app/views/auth/index.html.erb new file mode 100644 index 000000000..3826b844d --- /dev/null +++ b/app/views/auth/index.html.erb @@ -0,0 +1,12 @@ +<% if current_user %> +
+ +
+<% else %> + <%= button_to "Log in", login_path %> +<% end %> +<% flash.each do |type, msg| %> +
+ <%= msg %> +
+<% end %> diff --git a/app/views/fields/country_field/_form.html.erb b/app/views/fields/country_field/_form.html.erb new file mode 100644 index 000000000..f31628dfe --- /dev/null +++ b/app/views/fields/country_field/_form.html.erb @@ -0,0 +1,6 @@ +
+ <%= f.label field.attribute %> +
+
+ <%= f.select :country_code, ISO3166::Country.translations.map { |v, k| [k, v] }, include_blank: true %> +
diff --git a/app/views/fields/country_field/_index.html.erb b/app/views/fields/country_field/_index.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/country_field/_index.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/fields/country_field/_show.html.erb b/app/views/fields/country_field/_show.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/country_field/_show.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/fields/enum_field/_form.html.erb b/app/views/fields/enum_field/_form.html.erb new file mode 100644 index 000000000..1e72f7636 --- /dev/null +++ b/app/views/fields/enum_field/_form.html.erb @@ -0,0 +1,6 @@ +
+ <%= f.label field.attribute %> +
+
+ <%= f.text_field field.attribute %> +
diff --git a/app/views/fields/enum_field/_index.html.erb b/app/views/fields/enum_field/_index.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/enum_field/_index.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/fields/enum_field/_show.html.erb b/app/views/fields/enum_field/_show.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/enum_field/_show.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/invitation_mailer/invite_teacher.text.erb b/app/views/invitation_mailer/invite_teacher.text.erb new file mode 100644 index 000000000..dacca3965 --- /dev/null +++ b/app/views/invitation_mailer/invite_teacher.text.erb @@ -0,0 +1,14 @@ +You have been invited to join: + +<%= @school.name %> + +Being part of this school account will allow you to access the school dashboard in the Code Editor and create coding projects for students. + +Join school: + +<%= "#{ENV.fetch('http://github.com/EDITOR_PUBLIC_URL')}/en/invitations/#{@token}" %> + +-- +Raspberry Pi Foundation +Copyright Raspberry Pi Foundation UK registered charity 1129409 +All rights reserved diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e93b05cbe..3bb439645 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -5,11 +5,8 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - <%= yield %> diff --git a/bin/db-sync/download-s3.sh b/bin/db-sync/download-s3.sh new file mode 100755 index 000000000..0b7269161 --- /dev/null +++ b/bin/db-sync/download-s3.sh @@ -0,0 +1,21 @@ +#!/bin/bash -eu + +S3_BUCKET="$1" +S3_PROFILE="editor-images-env-sync" + +# copy bucket to local +echo "Copying contents from $S3_BUCKET S3 bucket" +aws s3 cp $S3_BUCKET ./storage/tmp --profile $S3_PROFILE --recursive +# check for error codes +if [ "$?" -ne "0" ]; then + echo "Unable to transfer S3 contents" +else + for f in ./storage/tmp/* ; do + echo "Processing $f" + file=$( echo ${f##*/} ) + dir1=${file:0:2} + dir2=${file:2:2} + mkdir -p "./storage/${dir1}/${dir2}" | true + mv "${f}" "./storage/${dir1}/${dir2}/${file}" + done +fi diff --git a/bin/db-sync/load-local-db.sh b/bin/db-sync/load-local-db.sh new file mode 100755 index 000000000..41ce5df17 --- /dev/null +++ b/bin/db-sync/load-local-db.sh @@ -0,0 +1,18 @@ +#!/bin/bash -eu + +export DISABLE_DATABASE_ENVIRONMENT_CHECK=1 +echo 'Dropping databases' +bin/rails db:drop +echo 'Recreating empty development & test databases' +bin/rails db:create +echo 'Replacing databases' +export PGPASSWORD="$POSTGRES_PASSWORD" +psql -h $POSTGRES_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'CREATE SCHEMA IF NOT EXISTS heroku_ext;' +pg_restore --verbose --if-exists --clean --no-acl --no-owner -h $POSTGRES_HOST -w -U $POSTGRES_USER -d $POSTGRES_DB /app/tmp/heroku-data/latest.dump +echo 'Setting the database environment' +bin/rails db:environment:set RAILS_ENV=development +echo 'Running any remaining migrations' +bin/rails db:migrate + +# update rails active storage table to local service +PGPASSWORD=$POSTGRES_PASSWORD psql -h $POSTGRES_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c "UPDATE active_storage_blobs SET service_name = 'local';" diff --git a/bin/db-sync/production-to-local.sh b/bin/db-sync/production-to-local.sh new file mode 100755 index 000000000..af862bfa9 --- /dev/null +++ b/bin/db-sync/production-to-local.sh @@ -0,0 +1,25 @@ +#/bin/sh + +echo 'This script will overwrite your LOCAL database with the contents of the PRODUCTION database.' +echo +read -p 'Do you want to continue? (y/N) ' -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + data_dir='tmp/heroku-data' + + rm -rf "$data_dir" + mkdir -p "$data_dir" + ls "$data_dir" + echo 'Getting pg database' + heroku pg:backups:capture --app editor-api-production + heroku pg:backups:download --app editor-api-production --output "$data_dir/latest.dump" + echo 'starting a container to run DB commands' + docker-compose run api bin/db-sync/load-local-db.sh + echo 'Database sync complete' +else + echo 'Database sync cancelled' +fi + +# copy bucket to local +./bin/db-sync/download-s3.sh "s3://editor-images-production" diff --git a/bin/db-sync/staging-to-local.sh b/bin/db-sync/staging-to-local.sh new file mode 100755 index 000000000..7f65f1f2d --- /dev/null +++ b/bin/db-sync/staging-to-local.sh @@ -0,0 +1,22 @@ +#/bin/sh + +echo 'This script will overwrite your LOCAL database with the contents of the STAGING database.' +echo +read -p 'Do you want to continue? (y/N) ' -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + data_dir='tmp/heroku-data' + + rm -rf "$data_dir" + mkdir -p "$data_dir" + ls "$data_dir" + echo 'Getting pg database' + heroku pg:backups:capture --app editor-api-staging + heroku pg:backups:download --app editor-api-staging --output "$data_dir/latest.dump" + echo 'starting a container to run DB commands' + docker compose run api bin/db-sync/load-local-db.sh + echo 'Database sync complete' +else + echo 'Database sync cancelled' +fi diff --git a/bin/docker-debug-entrypoint.sh b/bin/docker-debug-entrypoint.sh new file mode 100755 index 000000000..8494d46f5 --- /dev/null +++ b/bin/docker-debug-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +rails db:prepare +rdbg -n -o -c -- bin/rails s -p 3009 -b '0.0.0.0' diff --git a/bin/docker-entrypoint.sh b/bin/docker-entrypoint.sh new file mode 100755 index 000000000..b99a8f559 --- /dev/null +++ b/bin/docker-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +rails db:prepare +rails server --port 3009 --binding 0.0.0.0 diff --git a/bin/setup b/bin/setup index 516b651e3..3a74034f1 100755 --- a/bin/setup +++ b/bin/setup @@ -7,7 +7,7 @@ require 'fileutils' APP_ROOT = File.expand_path('..', __dir__) def system!(*args) - system(*args) || abort("\n== Command #{args} failed ==") + system(*args, exception: true) end FileUtils.chdir APP_ROOT do diff --git a/config/application.rb b/config/application.rb index 9c463589f..ec122fbcf 100644 --- a/config/application.rb +++ b/config/application.rb @@ -10,11 +10,12 @@ require 'active_storage/engine' require 'action_controller/railtie' require 'action_mailer/railtie' -require 'action_mailbox/engine' -require 'action_text/engine' +# require "action_mailbox/engine" +# require "action_text/engine" require 'action_view/railtie' -require 'action_cable/engine' +# require "action_cable/engine" # require "rails/test_unit/railtie" +require_relative '../lib/corp_middleware' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -23,11 +24,17 @@ module App class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.0 + config.load_defaults 7.1 + + config.add_autoload_paths_to_load_path = false config.autoload_paths << "#{root}/lib/concepts" Rails.autoloaders.main.collapse('lib/concepts/*/operations') + config.autoload_paths << "#{root}/lib/helpers" + + config.autoload_lib(ignore: %w[assets tasks]) + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files @@ -35,16 +42,23 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") - # config.autoload_paths += %W["#{config.root}/lib"] - - # Don't generate system test files. - config.generators.system_tests = nil config.generators do |g| g.orm :active_record, primary_key_type: :uuid g.test_framework :rspec end + config.assets.css_compressor = nil + config.active_job.queue_adapter = :good_job + + config.to_prepare do + Administrate::ApplicationController.helper App::Application.helpers + end + + config.api_only = false + + config.middleware.insert_before 0, CorpMiddleware + config.generators.system_tests = nil end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 66babe9d1..bc0d9b4c8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -8,7 +8,7 @@ # 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 + config.enable_reloading = true # Do not eager load code on boot. config.eager_load = false @@ -37,12 +37,21 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local + Rails.application.routes.default_url_options = { host: ENV.fetch('http://github.com/HOST_URL', 'http://localhost:3009/') } # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.perform_caching = false + if ENV['POSTMARK_API_TOKEN'] + config.action_mailer.raise_delivery_errors = true + config.action_mailer.delivery_method = :postmark + config.action_mailer.postmark_settings = { + api_token: ENV['POSTMARK_API_TOKEN'] + } + end + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -58,23 +67,32 @@ # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Use the async adapter for Active Job in development + config.active_job.queue_adapter = :good_job + + # Suppress logger output for asset requests. + config.assets.quiet = 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 - # Uncomment if you wish to allow Action Cable access from any origin. - # config.action_cable.disable_request_forgery_protection = true - # Allow smee requests config.hosts << 'smee.io' + config.hosts << 'editor-api.rpfdev.com' # bullet - N+1 config.after_initialize do Bullet.enable = true Bullet.bullet_logger = true # may need toggling off if too aggressive - Bullet.raise = true # raise an error if n+1 query occurs + Bullet.raise = false # raise an error if n+1 query occurs end + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true end diff --git a/config/environments/production.rb b/config/environments/production.rb index 45fbc4549..57f3a22cd 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -6,7 +6,7 @@ # Settings specified here will take precedence over those in config/application.rb. # Code is not reloaded between requests. - config.cache_classes = true + config.enable_reloading = false # Eager load code on boot. This eager loads most of Rails and # your application in memory, allowing both threaded web servers @@ -15,46 +15,67 @@ config.eager_load = true # Full error reports are disabled and caching is turned on. - config.consider_all_requests_local = false + config.consider_all_requests_local = false config.action_controller.perform_caching = true - # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] - # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment + # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). # config.require_master_key = true - # Disable serving static files from the `/public` folder by default since - # Apache or NGINX already handles this. - config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fall back to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com/" # Specifies the header that your server uses for sending files. # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX config.active_storage.service = :amazon + Rails.application.routes.default_url_options = { + host: ENV.fetch('http://github.com/HOST_URL', + "https://#{ENV.fetch('http://github.com/HEROKU_APP_NAME', '')}.herokuapp.com") + } - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = "wss://example.com/cable" - # config.action_cable.allowed_request_origins = [ "http://example.com/", /http:\/\/example.*/ ] + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # config.force_ssl = true - # Include generic and useful information about system operation, but avoid logging too much - # information to avoid inadvertent exposure of personally identifiable information (PII). - config.log_level = :info + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new($stdout) + .tap { |logger| logger.formatter = Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } # Prepend all log lines with the following tags. config.log_tags = [:request_id] + # "info" includes generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). If you + # want to log everything, set the level to "debug". + config.log_level = ENV.fetch('http://github.com/RAILS_LOG_LEVEL', 'info') + # Use a different cache store in production. # config.cache_store = :mem_cache_store # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque + # config.active_job.queue_adapter = :resque # config.active_job.queue_name_prefix = "app_production" config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :postmark + config.action_mailer.postmark_settings = { + api_token: ENV.fetch('http://github.com/POSTMARK_API_TOKEN') + } # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. @@ -82,4 +103,12 @@ # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 875fc7138..3374e9ade 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -10,12 +10,13 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - # Turn false under Spring and add config.action_view.cache_template_loading = true - config.cache_classes = true + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false - # Eager loading loads your whole application. When running a single test locally, - # this probably isn't necessary. It's a good idea to do in a continuous integration - # system, or in some way before deploying your code. + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. config.eager_load = ENV['CI'].present? # Configure public file server for tests with Cache-Control for performance. @@ -25,18 +26,19 @@ } # Show full error reports and disable caching. - config.consider_all_requests_local = false + config.consider_all_requests_local = true config.action_controller.perform_caching = false config.cache_store = :null_store - # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test + Rails.application.routes.default_url_options = { host: ENV.fetch('http://github.com/HOST_URL', 'http://localhost:3000/') } config.action_mailer.perform_caching = false @@ -67,4 +69,6 @@ # may need toggling off if too aggressive Bullet.raise = true # raise an error if n+1 query occurs end + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 000000000..bcafccdd3 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = '1.0' + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/config/initializers/auth.rb b/config/initializers/auth.rb deleted file mode 100644 index 9b97cd4e3..000000000 --- a/config/initializers/auth.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -BYPASS_AUTH = ENV['BYPASS_AUTH'] == 'true' -AUTH_USER_ID = ENV.fetch('http://github.com/AUTH_USER_ID', nil) if BYPASS_AUTH diff --git a/config/initializers/awesome_print.rb b/config/initializers/awesome_print.rb new file mode 100644 index 000000000..bcbb1ce20 --- /dev/null +++ b/config/initializers/awesome_print.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require 'awesome_print' if Rails.env.development? diff --git a/config/initializers/bullet.rb b/config/initializers/bullet.rb new file mode 100644 index 000000000..b40b53940 --- /dev/null +++ b/config/initializers/bullet.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Bullet.add_safelist type: :unused_eager_loading, class_name: 'Project', association: :images_attachments if Rails.env.development? || Rails.env.test? diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index f37ed8de4..af395e463 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true # Be sure to restart your server when you modify this file. -# Define an application-wide content security policy -# For further information see the following documentation -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header # Rails.application.configure do # config.content_security_policy do |policy| @@ -17,11 +17,10 @@ # # policy.report_uri "/csp-violation-report-endpoint" # end # -# # Generate session nonces for permitted importmap and inline scripts +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src) +# config.content_security_policy_nonce_directives = %w(script-src style-src) # -# # Report CSP violations to a specified URI. See: -# # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only +# # Report violations without enforcing the policy. # # config.content_security_policy_report_only = true # end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index d16944308..2fa210159 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -1,10 +1,27 @@ # frozen_string_literal: true -origins_array = ENV['ALLOWED_ORIGINS']&.split(',')&.map(&:strip) || [] +# Be sure to restart your server when you modify this file. +# Read more: https://github.com/cyu/rack-cors + +require Rails.root.join('lib/origin_parser') Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins origins_array - resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link'] + # localhost and test domain origins + origins(%r{https?://localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test? + + standard_cors_options + end + + allow do + # environment-specific origins set through ALLOWED_ORIGINS env var + # should only be necessary for staging / production environments (see above for local and test) + origins OriginParser.parse_origins + + standard_cors_options end end + +def standard_cors_options + resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link'] +end diff --git a/config/initializers/encryption.rb b/config/initializers/encryption.rb new file mode 100644 index 000000000..fa52981c3 --- /dev/null +++ b/config/initializers/encryption.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.active_record.encryption.primary_key = ENV.fetch('http://github.com/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') + config.active_record.encryption.deterministic_key = ENV.fetch('http://github.com/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') + config.active_record.encryption.key_derivation_salt = ENV.fetch('http://github.com/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 3babc73f0..c416e6a62 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -2,7 +2,9 @@ # Be sure to restart your server when you modify this file. -# Configure sensitive parameters which will be filtered from the log file. +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += %i[ passw secret token _key crypt salt certificate otp ssn ] diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb new file mode 100644 index 000000000..54a245bea --- /dev/null +++ b/config/initializers/good_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Rails.application.config.to_prepare do + GoodJob::ApplicationController.class_eval do + include AuthenticationHelper + + before_action :authenticate_admin + + helper_method :current_user + + def authenticate_admin + redirect_to '/', alert: I18n.t('errors.admin.unauthorized') unless current_user&.admin? + end + end +end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 000000000..87d82bb2d --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +OmniAuth.config.logger = Rails.logger + +if ENV['BYPASS_OAUTH'].present? + using RpiAuthBypass + + extra = RpiAuthBypass::DEFAULT_EXTRA.deep_merge(raw_info: { roles: 'editor-admin' }) + OmniAuth.config.add_rpi_mock(extra:) + OmniAuth.config.enable_rpi_auth_bypass +end + +Rails.application.config.middleware.use OmniAuth::Builder do + provider( + OmniAuth::Strategies::Rpi, ENV.fetch('http://github.com/HYDRA_CLIENT_ID', nil), ENV.fetch('http://github.com/HYDRA_CLIENT_SECRET', nil), + scope: 'openid email profile roles force-consent', + callback_path: '/auth/callback', + client_options: { + site: ENV.fetch('http://github.com/HYDRA_PUBLIC_URL', nil), + authorize_url: "#{ENV.fetch('http://github.com/HYDRA_PUBLIC_URL', nil)}/oauth2/auth", + token_url: "#{ENV.fetch('http://github.com/HYDRA_PUBLIC_TOKEN_URL', ENV.fetch('http://github.com/HYDRA_PUBLIC_URL', nil))}/oauth2/token", + auth_scheme: :basic_auth + }, + authorize_params: {}, + origin_param: 'returnTo' + ) + + OmniAuth.config.on_failure = AuthController.action(:failure) +end diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb index 50bcf4ead..b635b527e 100644 --- a/config/initializers/permissions_policy.rb +++ b/config/initializers/permissions_policy.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true +# Be sure to restart your server when you modify this file. + # Define an application-wide HTTP permissions policy. For further -# information see https://developers.google.com/web/updates/2018/06/feature-policy -# -# Rails.application.config.permissions_policy do |f| -# f.camera :none -# f.gyroscope :none -# f.microphone :none -# f.usb :none -# f.fullscreen :self -# f.payment :self, "https://secure.example.com/" +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com/" # end diff --git a/config/locales/admin/en.yml b/config/locales/admin/en.yml new file mode 100644 index 000000000..5c3a7cfb0 --- /dev/null +++ b/config/locales/admin/en.yml @@ -0,0 +1,28 @@ +--- +en: + administrate: + actions: + discard: Discard + confirm_discard: Are you sure you want to discard this record? + verify_school: Verify School + confirm_verify_school: Are you sure you want to verify this school? + reject_school: Reject School + confirm_reject_school: Are you sure you want to reject this school? + reopen_school: Reopen School + confirm_reopen_school: Are you sure you want to reopen this school? + controller: + verify_school: + success: "Successfully verified." + error: "There was an error verifying the school" + reject_school: + success: "Successfully rejected." + error: "There was an error rejecting the school" + reopen_school: + success: "Successfully reopened." + error: "There was an error reopening the school" + activerecord: + attributes: + school: + user_origin_values: + for_education: "Code Editor for Education" + experience_cs: "Experience CS" diff --git a/config/locales/en.yml b/config/locales/en.yml index 70e5074e6..d2cf7ba91 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,10 +1,20 @@ en: errors: + admin: + unauthorized: "Not authorized." project: editing: - delete_default_component: 'Cannot delete default file' - change_default_name: 'Cannot amend default file name' - change_default_extension: 'Cannot amend default file extension' + delete_default_component: "Cannot delete default file" + change_default_name: "Cannot amend default file name" + change_default_extension: "Cannot amend default file extension" + student_update_instructions: "Student cannot update project instructions" remixing: - invalid_params: 'Invalid parameters' - cannot_save: 'Cannot create project remix' + invalid_params: "Invalid parameters" + cannot_save: "Cannot create project remix" + validations: + school: + website: "must be a valid URL" + invitation: + email_address: "'%s' is invalid" + school_student: + not_empty: "You must supply a %{field}" diff --git a/config/routes.rb b/config/routes.rb index af593f336..f48edf7f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,30 @@ # frozen_string_literal: true Rails.application.routes.draw do + namespace :admin do + mount GoodJob::Engine => 'good_job' + resources :components + + resources :projects do + delete :images, on: :member, action: :destroy_image + end + + resources :schools, only: %i[index show edit update] do + member do + post :verify + patch :reject + patch :reopen + end + end + + resources :school_classes, only: %i[show] + resources :lessons, only: %i[show] + + root to: 'projects#index' + end + post '/graphql', to: 'graphql#execute' + mount GraphiQL::Rails::Engine, at: '/graphql', graphql_path: '/graphql#execute' unless Rails.env.production? namespace :api do resource :default_project, only: %i[show] do @@ -10,10 +33,48 @@ end resources :projects, only: %i[index show update destroy create] do - resource :remix, only: %i[create], controller: 'projects/remixes' - resource :images, only: %i[create], controller: 'projects/images' + get :finished, on: :member, to: 'school_projects#show_finished' + get :context, on: :member, to: 'projects#show_context' + put :finished, on: :member, to: 'school_projects#set_finished' + resource :remix, only: %i[show create], controller: 'projects/remixes' + resources :remixes, only: %i[index], controller: 'projects/remixes' + resource :images, only: %i[show create], controller: 'projects/images' end + + resource :project_errors, only: %i[create] + + resource :school, only: [:show], controller: 'my_school' + resources :schools, only: %i[index show create update destroy] do + resources :members, only: %i[index], controller: 'school_members' + resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do + resources :members, only: %i[index create destroy], controller: 'class_members' do + post :batch, on: :collection, to: 'class_members#create_batch' + end + end + + resources :owners, only: %i[index create destroy], controller: 'school_owners' + resources :teachers, only: %i[index create destroy], controller: 'school_teachers' + resources :students, only: %i[index create update destroy], controller: 'school_students' do + post :batch, on: :collection, to: 'school_students#create_batch' + end + end + + resources :lessons, only: %i[index create show update destroy] do + post :copy, on: :member, to: 'lessons#create_copy' + end + + resources :teacher_invitations, param: :token, only: :show do + put :accept, on: :member + end + + resources :user_jobs, only: %i[index show] end resource :github_webhooks, only: :create, defaults: { formats: :json } + + root to: 'auth#index' + + post '/auth/rpi', as: 'login' + get '/auth/callback', to: 'auth#callback', as: 'callback' + get '/logout', to: 'auth#destroy', as: 'logout' end diff --git a/db/migrate/20230823190008_create_project_errors.rb b/db/migrate/20230823190008_create_project_errors.rb new file mode 100644 index 000000000..e600c7d80 --- /dev/null +++ b/db/migrate/20230823190008_create_project_errors.rb @@ -0,0 +1,11 @@ +class CreateProjectErrors < ActiveRecord::Migration[7.0] + def change + create_table :project_errors, id: :uuid do |t| + t.references :project, type: :uuid, foreign_key: true + t.string :error, null: false + t.string :error_type + t.uuid :user_id + t.timestamps + end + end +end diff --git a/db/migrate/20231106151439_add_remix_origin.rb b/db/migrate/20231106151439_add_remix_origin.rb new file mode 100644 index 000000000..1660e569d --- /dev/null +++ b/db/migrate/20231106151439_add_remix_origin.rb @@ -0,0 +1,5 @@ +class AddRemixOrigin < ActiveRecord::Migration[7.0] + def change + add_column :projects, :remix_origin, :string + end +end diff --git a/db/migrate/20231106151705_default_remixed_project_origin.rb b/db/migrate/20231106151705_default_remixed_project_origin.rb new file mode 100644 index 000000000..f6512bad2 --- /dev/null +++ b/db/migrate/20231106151705_default_remixed_project_origin.rb @@ -0,0 +1,16 @@ +class DefaultRemixedProjectOrigin < ActiveRecord::Migration[7.0] + def up + if Rails.env.development? + remix_origin = 'http://localhost:3010/' + elsif ENV.fetch('http://github.com/SENTRY_CURRENT_ENV') == 'staging' + remix_origin = 'staging-editor.raspberrypi.org' + elsif Rails.env.production? + remix_origin = 'editor.raspberrypi.org' + end + Project.where.not(remixed_from_id: nil).update(remix_origin: remix_origin) + end + + def down + Project.update(remix_origin: nil) + end +end diff --git a/db/migrate/20240201160923_create_schools.rb b/db/migrate/20240201160923_create_schools.rb new file mode 100644 index 000000000..0a6476122 --- /dev/null +++ b/db/migrate/20240201160923_create_schools.rb @@ -0,0 +1,20 @@ +class CreateSchools < ActiveRecord::Migration[7.0] + def change + create_table :schools, id: :uuid do |t| + t.string :name, null: false + t.string :reference + + t.string :address_line_1, null: false + t.string :address_line_2 + t.string :municipality, null: false + t.string :administrative_area + t.string :postal_code + t.string :country_code, null: false + + t.datetime :verified_at + t.timestamps + end + + add_index :schools, :reference, unique: true + end +end diff --git a/db/migrate/20240201165749_create_school_classes.rb b/db/migrate/20240201165749_create_school_classes.rb new file mode 100644 index 000000000..11fed7e67 --- /dev/null +++ b/db/migrate/20240201165749_create_school_classes.rb @@ -0,0 +1,12 @@ +class CreateSchoolClasses < ActiveRecord::Migration[7.0] + def change + create_table :school_classes, id: :uuid do |t| + t.references :school, type: :uuid, foreign_key: true, index: true, null: false + t.uuid :teacher_id, null: false + t.string :name, null: false + t.timestamps + end + + add_index :school_classes, %i[school_id teacher_id] + end +end diff --git a/db/migrate/20240201171700_create_class_members.rb b/db/migrate/20240201171700_create_class_members.rb new file mode 100644 index 000000000..7cc021265 --- /dev/null +++ b/db/migrate/20240201171700_create_class_members.rb @@ -0,0 +1,12 @@ +class CreateClassMembers < ActiveRecord::Migration[7.0] + def change + create_table :class_members, id: :uuid do |t| + t.references :school_class, type: :uuid, foreign_key: true, index: true, null: false + t.uuid :student_id, null: false + t.timestamps + end + + add_index :class_members, :student_id + add_index :class_members, %i[school_class_id student_id], unique: true + end +end diff --git a/db/migrate/20240217144009_create_lessons.rb b/db/migrate/20240217144009_create_lessons.rb new file mode 100644 index 000000000..7de6e4207 --- /dev/null +++ b/db/migrate/20240217144009_create_lessons.rb @@ -0,0 +1,23 @@ +class CreateLessons < ActiveRecord::Migration[7.0] + def change + create_table :lessons, id: :uuid do |t| + t.references :school, type: :uuid, foreign_key: true, index: true + t.references :school_class, type: :uuid, foreign_key: true, index: true + t.references :copied_from, type: :uuid, foreign_key: { to_table: :lessons }, index: true + + t.uuid :user_id, null: false + t.string :name, null: false + t.string :description + t.string :visibility, null: false, default: 'private' + + t.datetime :due_date + t.datetime :archived_at + t.timestamps + end + + add_index :lessons, :user_id + add_index :lessons, :name + add_index :lessons, :visibility + add_index :lessons, :archived_at + end +end diff --git a/db/migrate/20240223113155_add_school_id_to_projects.rb b/db/migrate/20240223113155_add_school_id_to_projects.rb new file mode 100644 index 000000000..7af93cefd --- /dev/null +++ b/db/migrate/20240223113155_add_school_id_to_projects.rb @@ -0,0 +1,5 @@ +class AddSchoolIdToProjects < ActiveRecord::Migration[7.0] + def change + add_reference :projects, :school, type: :uuid, foreign_key: true, index: true + end +end diff --git a/db/migrate/20240223150228_add_lesson_id_to_projects.rb b/db/migrate/20240223150228_add_lesson_id_to_projects.rb new file mode 100644 index 000000000..85be34945 --- /dev/null +++ b/db/migrate/20240223150228_add_lesson_id_to_projects.rb @@ -0,0 +1,5 @@ +class AddLessonIdToProjects < ActiveRecord::Migration[7.0] + def change + add_reference :projects, :lesson, type: :uuid, foreign_key: true, index: true + end +end diff --git a/db/migrate/20240419102922_change_school_fields.rb b/db/migrate/20240419102922_change_school_fields.rb new file mode 100644 index 000000000..938eda690 --- /dev/null +++ b/db/migrate/20240419102922_change_school_fields.rb @@ -0,0 +1,8 @@ +class ChangeSchoolFields < ActiveRecord::Migration[7.0] + def change + add_column :schools, :rejected_at, :datetime, null: true + add_column :schools, :organisation_id, :uuid, null: true + add_column :schools, :user_id, :uuid, null: true + add_column :schools, :website, :string, null: false + end +end diff --git a/db/migrate/20240507162837_remove_organisation_id_from_schools.rb b/db/migrate/20240507162837_remove_organisation_id_from_schools.rb new file mode 100644 index 000000000..fdd7e2fe5 --- /dev/null +++ b/db/migrate/20240507162837_remove_organisation_id_from_schools.rb @@ -0,0 +1,5 @@ +class RemoveOrganisationIdFromSchools < ActiveRecord::Migration[7.0] + def change + remove_column :schools, :organisation_id + end +end diff --git a/db/migrate/20240509104834_add_roles.rb b/db/migrate/20240509104834_add_roles.rb new file mode 100644 index 000000000..683ae9fff --- /dev/null +++ b/db/migrate/20240509104834_add_roles.rb @@ -0,0 +1,12 @@ +class AddRoles < ActiveRecord::Migration[7.0] + def change + create_table :roles do |t| + t.belongs_to :user, type: :uuid + t.belongs_to :school, type: :uuid, foreign_key: true + t.integer :role + t.timestamps + end + + add_index :roles, [:user_id, :school_id, :role], unique: true + end +end diff --git a/db/migrate/20240514163045_drop_words.rb b/db/migrate/20240514163045_drop_words.rb new file mode 100644 index 000000000..babe5295e --- /dev/null +++ b/db/migrate/20240514163045_drop_words.rb @@ -0,0 +1,5 @@ +class DropWords < ActiveRecord::Migration[7.0] + def change + drop_table :words + end +end diff --git a/db/migrate/20240523111833_rename_school_user_to_creator.rb b/db/migrate/20240523111833_rename_school_user_to_creator.rb new file mode 100644 index 000000000..c6cf58dbc --- /dev/null +++ b/db/migrate/20240523111833_rename_school_user_to_creator.rb @@ -0,0 +1,5 @@ +class RenameSchoolUserToCreator < ActiveRecord::Migration[7.0] + def change + rename_column :schools, :user_id, :creator_id + end +end diff --git a/db/migrate/20240523113931_add_creator_details_fields.rb b/db/migrate/20240523113931_add_creator_details_fields.rb new file mode 100644 index 000000000..2d00b5dd0 --- /dev/null +++ b/db/migrate/20240523113931_add_creator_details_fields.rb @@ -0,0 +1,6 @@ +class AddCreatorDetailsFields < ActiveRecord::Migration[7.0] + def change + add_column :schools, :creator_role, :string + add_column :schools, :creator_department, :string + end +end diff --git a/db/migrate/20240523143911_add_consent_fields.rb b/db/migrate/20240523143911_add_consent_fields.rb new file mode 100644 index 000000000..5c52c6dc9 --- /dev/null +++ b/db/migrate/20240523143911_add_consent_fields.rb @@ -0,0 +1,6 @@ +class AddConsentFields < ActiveRecord::Migration[7.0] + def change + add_column :schools, :creator_agree_authority, :boolean + add_column :schools, :creator_agree_terms_and_conditions, :boolean + end +end diff --git a/db/migrate/20240524144838_add_class_description_field.rb b/db/migrate/20240524144838_add_class_description_field.rb new file mode 100644 index 000000000..dbab92d1f --- /dev/null +++ b/db/migrate/20240524144838_add_class_description_field.rb @@ -0,0 +1,5 @@ +class AddClassDescriptionField < ActiveRecord::Migration[7.0] + def change + add_column :school_classes, :description, :string + end +end diff --git a/db/migrate/20240605134411_create_invitations.rb b/db/migrate/20240605134411_create_invitations.rb new file mode 100644 index 000000000..fb150a348 --- /dev/null +++ b/db/migrate/20240605134411_create_invitations.rb @@ -0,0 +1,10 @@ +class CreateInvitations < ActiveRecord::Migration[7.0] + def change + create_table :invitations, id: :uuid do |t| + t.string :email_address + t.references :school, null: false, foreign_key: true, type: :uuid + + t.timestamps + end + end +end diff --git a/db/migrate/20240607120419_add_service_name_to_active_storage_blobs.active_storage.rb b/db/migrate/20240607120419_add_service_name_to_active_storage_blobs.active_storage.rb new file mode 100644 index 000000000..a15c6ce8e --- /dev/null +++ b/db/migrate/20240607120419_add_service_name_to_active_storage_blobs.active_storage.rb @@ -0,0 +1,22 @@ +# This migration comes from active_storage (originally 20190112182829) +class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] + def up + return unless table_exists?(:active_storage_blobs) + + unless column_exists?(:active_storage_blobs, :service_name) + add_column :active_storage_blobs, :service_name, :string + + if configured_service = ActiveStorage::Blob.service.name + ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) + end + + change_column :active_storage_blobs, :service_name, :string, null: false + end + end + + def down + return unless table_exists?(:active_storage_blobs) + + remove_column :active_storage_blobs, :service_name + end +end diff --git a/db/migrate/20240607120420_create_active_storage_variant_records.active_storage.rb b/db/migrate/20240607120420_create_active_storage_variant_records.active_storage.rb new file mode 100644 index 000000000..94ac83af0 --- /dev/null +++ b/db/migrate/20240607120420_create_active_storage_variant_records.active_storage.rb @@ -0,0 +1,27 @@ +# This migration comes from active_storage (originally 20191206030411) +class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + # Use Active Record's configured type for primary key + create_table :active_storage_variant_records, id: primary_key_type, if_not_exists: true do |t| + t.belongs_to :blob, null: false, index: false, type: blobs_primary_key_type + t.string :variation_digest, null: false + + t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end + + def blobs_primary_key_type + pkey_name = connection.primary_key(:active_storage_blobs) + pkey_column = connection.columns(:active_storage_blobs).find { |c| c.name == pkey_name } + pkey_column.bigint? ? :bigint : pkey_column.type + end +end diff --git a/db/migrate/20240607120421_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb b/db/migrate/20240607120421_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb new file mode 100644 index 000000000..93c8b85ad --- /dev/null +++ b/db/migrate/20240607120421_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb @@ -0,0 +1,8 @@ +# This migration comes from active_storage (originally 20211119233751) +class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + change_column_null(:active_storage_blobs, :checksum, true) + end +end diff --git a/db/migrate/20240611102154_add_unique_constraint_to_school_creator_id.rb b/db/migrate/20240611102154_add_unique_constraint_to_school_creator_id.rb new file mode 100644 index 000000000..e3562c85e --- /dev/null +++ b/db/migrate/20240611102154_add_unique_constraint_to_school_creator_id.rb @@ -0,0 +1,5 @@ +class AddUniqueConstraintToSchoolCreatorId < ActiveRecord::Migration[7.0] + def change + add_index :schools, :creator_id, unique: true + end +end diff --git a/db/migrate/20240612153258_create_good_job_settings.rb b/db/migrate/20240612153258_create_good_job_settings.rb new file mode 100644 index 000000000..c75c2983f --- /dev/null +++ b/db/migrate/20240612153258_create_good_job_settings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateGoodJobSettings < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_settings) + end + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + end +end diff --git a/db/migrate/20240612153259_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb b/db/migrate/20240612153259_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb new file mode 100644 index 000000000..158dc8f9f --- /dev/null +++ b/db/migrate/20240612153259_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?(:good_jobs, :index_good_jobs_jobs_on_priority_created_at_when_unfinished) + end + end + + add_index :good_jobs, [:priority, :created_at], order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished, + algorithm: :concurrently + end +end diff --git a/db/migrate/20240612153260_create_good_job_batches.rb b/db/migrate/20240612153260_create_good_job_batches.rb new file mode 100644 index 000000000..ceea2a535 --- /dev/null +++ b/db/migrate/20240612153260_create_good_job_batches.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateGoodJobBatches < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_batches) + end + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + change_table :good_jobs do |t| + t.uuid :batch_id + t.uuid :batch_callback_id + + t.index :batch_id, where: "batch_id IS NOT NULL" + t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL" + end + end +end diff --git a/db/migrate/20240612153261_create_good_job_executions.rb b/db/migrate/20240612153261_create_good_job_executions.rb new file mode 100644 index 000000000..84a15e22b --- /dev/null +++ b/db/migrate/20240612153261_create_good_job_executions.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutions < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_executions) + end + end + + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + + t.index [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at + end + + change_table :good_jobs do |t| + t.boolean :is_discrete + t.integer :executions_count + t.text :job_class + end + end +end diff --git a/db/migrate/20240612153262_create_good_jobs_error_event.rb b/db/migrate/20240612153262_create_good_jobs_error_event.rb new file mode 100644 index 000000000..e301ad751 --- /dev/null +++ b/db/migrate/20240612153262_create_good_jobs_error_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateGoodJobsErrorEvent < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :error_event) + end + end + + add_column :good_jobs, :error_event, :integer, limit: 2 + add_column :good_job_executions, :error_event, :integer, limit: 2 + end +end diff --git a/db/migrate/20240612153263_recreate_good_job_cron_indexes_with_conditional.rb b/db/migrate/20240612153263_recreate_good_job_cron_indexes_with_conditional.rb new file mode 100644 index 000000000..56cab35c3 --- /dev/null +++ b/db/migrate/20240612153263_recreate_good_job_cron_indexes_with_conditional.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class RecreateGoodJobCronIndexesWithConditional < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)", + name: :index_good_jobs_on_cron_key_and_created_at_cond, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at_cond, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + add_index :good_jobs, [:cron_key, :created_at], + name: :index_good_jobs_on_cron_key_and_created_at, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + add_index :good_jobs, [:cron_key, :cron_at], unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at_cond + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at_cond + end + end + end + end +end diff --git a/db/migrate/20240612153264_create_good_job_labels.rb b/db/migrate/20240612153264_create_good_job_labels.rb new file mode 100644 index 000000000..1ba514e8b --- /dev/null +++ b/db/migrate/20240612153264_create_good_job_labels.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobLabels < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :labels) + end + end + + add_column :good_jobs, :labels, :text, array: true + end +end diff --git a/db/migrate/20240612153265_create_good_job_labels_index.rb b/db/migrate/20240612153265_create_good_job_labels_index.rb new file mode 100644 index 000000000..65dedd477 --- /dev/null +++ b/db/migrate/20240612153265_create_good_job_labels_index.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateGoodJobLabelsIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", + name: :index_good_jobs_on_labels, algorithm: :concurrently + end + end + + dir.down do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + remove_index :good_jobs, name: :index_good_jobs_on_labels + end + end + end + end +end diff --git a/db/migrate/20240612153266_remove_good_job_active_id_index.rb b/db/migrate/20240612153266_remove_good_job_active_id_index.rb new file mode 100644 index 000000000..8601f071a --- /dev/null +++ b/db/migrate/20240612153266_remove_good_job_active_id_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveGoodJobActiveIdIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + remove_index :good_jobs, name: :index_good_jobs_on_active_job_id + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + add_index :good_jobs, :active_job_id, name: :index_good_jobs_on_active_job_id + end + end + end + end +end diff --git a/db/migrate/20240612153267_create_index_good_job_jobs_for_candidate_lookup.rb b/db/migrate/20240612153267_create_index_good_job_jobs_for_candidate_lookup.rb new file mode 100644 index 000000000..70e525626 --- /dev/null +++ b/db/migrate/20240612153267_create_index_good_job_jobs_for_candidate_lookup.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobJobsForCandidateLookup < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup) + end + end + + add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup, + algorithm: :concurrently + end +end diff --git a/db/migrate/20240612153268_create_good_job_execution_error_backtrace.rb b/db/migrate/20240612153268_create_good_job_execution_error_backtrace.rb new file mode 100644 index 000000000..6b054b640 --- /dev/null +++ b/db/migrate/20240612153268_create_good_job_execution_error_backtrace.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :error_backtrace) + end + end + + add_column :good_job_executions, :error_backtrace, :text, array: true + end +end diff --git a/db/migrate/20240612153269_create_good_job_process_lock_ids.rb b/db/migrate/20240612153269_create_good_job_process_lock_ids.rb new file mode 100644 index 000000000..da43683d4 --- /dev/null +++ b/db/migrate/20240612153269_create_good_job_process_lock_ids.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :locked_by_id) + end + end + + add_column :good_jobs, :locked_by_id, :uuid + add_column :good_jobs, :locked_at, :datetime + add_column :good_job_executions, :process_id, :uuid + add_column :good_job_processes, :lock_type, :integer, limit: 2 + end +end diff --git a/db/migrate/20240612153270_create_good_job_process_lock_indexes.rb b/db/migrate/20240612153270_create_good_job_process_lock_indexes.rb new file mode 100644 index 000000000..fb98f05a4 --- /dev/null +++ b/db/migrate/20240612153270_create_good_job_process_lock_indexes.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + add_index :good_jobs, [:priority, :scheduled_at], + order: { priority: "ASC NULLS LAST", scheduled_at: :asc }, + where: "finished_at IS NULL AND locked_by_id IS NULL", + name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + add_index :good_jobs, :locked_by_id, + where: "locked_by_id IS NOT NULL", + name: :index_good_jobs_on_locked_by_id, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + add_index :good_job_executions, [:process_id, :created_at], + name: :index_good_job_executions_on_process_id_and_created_at, + algorithm: :concurrently + end + end + + dir.down do + remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + end + end + end +end diff --git a/db/migrate/20240617104323_add_school_code.rb b/db/migrate/20240617104323_add_school_code.rb new file mode 100644 index 000000000..a9b9a6ac8 --- /dev/null +++ b/db/migrate/20240617104323_add_school_code.rb @@ -0,0 +1,6 @@ +class AddSchoolCode < ActiveRecord::Migration[7.1] + def change + add_column :schools, :code, :string + add_index :schools, :code, unique: true + end +end diff --git a/db/migrate/20240621082741_add_accepted_at_to_invitation.rb b/db/migrate/20240621082741_add_accepted_at_to_invitation.rb new file mode 100644 index 000000000..7525eab1d --- /dev/null +++ b/db/migrate/20240621082741_add_accepted_at_to_invitation.rb @@ -0,0 +1,5 @@ +class AddAcceptedAtToInvitation < ActiveRecord::Migration[7.1] + def change + add_column :invitations, :accepted_at, :datetime + end +end diff --git a/db/migrate/20240624122250_rename_invitations_to_teacher_invitations.rb b/db/migrate/20240624122250_rename_invitations_to_teacher_invitations.rb new file mode 100644 index 000000000..fbc12d79c --- /dev/null +++ b/db/migrate/20240624122250_rename_invitations_to_teacher_invitations.rb @@ -0,0 +1,5 @@ +class RenameInvitationsToTeacherInvitations < ActiveRecord::Migration[7.1] + def change + rename_table :invitations, :teacher_invitations + end +end diff --git a/db/migrate/20240704104847_default_lesson_visibility_to_teachers.rb b/db/migrate/20240704104847_default_lesson_visibility_to_teachers.rb new file mode 100644 index 000000000..6cef9c808 --- /dev/null +++ b/db/migrate/20240704104847_default_lesson_visibility_to_teachers.rb @@ -0,0 +1,9 @@ +class DefaultLessonVisibilityToTeachers < ActiveRecord::Migration[7.1] + def up + change_column_default :lessons, :visibility, 'teachers' + end + + def down + change_column_default :lessons, :visibility, 'private' + end +end diff --git a/db/migrate/20240828112433_create_versions.rb b/db/migrate/20240828112433_create_versions.rb new file mode 100644 index 000000000..cb9b67cbc --- /dev/null +++ b/db/migrate/20240828112433_create_versions.rb @@ -0,0 +1,39 @@ +# This migration creates the `versions` table, the only schema PT requires. +# All other migrations PT provides are optional. +class CreateVersions < ActiveRecord::Migration[7.1] + + # The largest text column available in all supported RDBMS is + # 1024^3 - 1 bytes, roughly one gibibyte. We specify a size + # so that MySQL will use `longtext` instead of `text`. Otherwise, + # when serializing very large objects, `text` might not be big enough. + TEXT_BYTES = 1_073_741_823 + + def change + create_table :versions, id: :uuid do |t| + t.string :item_type, null: false + t.string :item_id, null: false + t.string :event, null: false + t.string :whodunnit + # We're not using versioning, so exclude the original state by not having an options field (see docs) + # t.json :object + + # Known issue in MySQL: fractional second precision + # ------------------------------------------------- + # + # MySQL timestamp columns do not support fractional seconds unless + # defined with "fractional seconds precision". MySQL users should manually + # add fractional seconds precision to this migration, specifically, to + # the `created_at` column. + # (https://dev.mysql.com/doc/refman/5.6/en/fractional-seconds.html) + # + # MySQL users should also upgrade to at least rails 4.2, which is the first + # version of ActiveRecord with support for fractional seconds in MySQL. + # (https://github.com/rails/rails/pull/14359) + # + # MySQL users should use the following line for `created_at` + # t.datetime :created_at, limit: 6 + t.datetime :created_at + end + add_index :versions, %i[item_type item_id] + end +end diff --git a/db/migrate/20240828112434_add_object_changes_to_versions.rb b/db/migrate/20240828112434_add_object_changes_to_versions.rb new file mode 100644 index 000000000..a521ad12c --- /dev/null +++ b/db/migrate/20240828112434_add_object_changes_to_versions.rb @@ -0,0 +1,12 @@ +# This migration adds the optional `object_changes` column, in which PaperTrail +# will store the `changes` diff for each update event. See the readme for +# details. +class AddObjectChangesToVersions < ActiveRecord::Migration[7.1] + # The largest text column available in all supported RDBMS. + # See `create_versions.rb` for details. + TEXT_BYTES = 1_073_741_823 + + def change + add_column :versions, :object_changes, :json + end +end diff --git a/db/migrate/20240828122522_add_meta_columns_to_versions.rb b/db/migrate/20240828122522_add_meta_columns_to_versions.rb new file mode 100644 index 000000000..8cb03107d --- /dev/null +++ b/db/migrate/20240828122522_add_meta_columns_to_versions.rb @@ -0,0 +1,10 @@ +# This migration adds the optional `object_changes` column, in which PaperTrail +# will store the `changes` diff for each update event. See the readme for +# details. +class AddMetaColumnsToVersions < ActiveRecord::Migration[7.1] + def change + add_column :versions, :meta_project_id, :uuid + add_column :versions, :meta_school_id, :uuid + add_column :versions, :meta_remixed_from_id, :uuid + end +end diff --git a/db/migrate/20240902151414_add_finished_to_projects.rb b/db/migrate/20240902151414_add_finished_to_projects.rb new file mode 100644 index 000000000..0a1a6ebb6 --- /dev/null +++ b/db/migrate/20240902151414_add_finished_to_projects.rb @@ -0,0 +1,5 @@ +class AddFinishedToProjects < ActiveRecord::Migration[7.1] + def change + add_column :projects, :finished, :boolean + end +end diff --git a/db/migrate/20241008171501_add_jobs_finished_at_to_good_job_batches.rb b/db/migrate/20241008171501_add_jobs_finished_at_to_good_job_batches.rb new file mode 100644 index 000000000..09c3e832e --- /dev/null +++ b/db/migrate/20241008171501_add_jobs_finished_at_to_good_job_batches.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddJobsFinishedAtToGoodJobBatches < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_batches, :jobs_finished_at) + end + end + + change_table :good_job_batches do |t| + t.datetime :jobs_finished_at + end + end +end diff --git a/db/migrate/20241008171600_create_good_job_execution_duration.rb b/db/migrate/20241008171600_create_good_job_execution_duration.rb new file mode 100644 index 000000000..fef37f07b --- /dev/null +++ b/db/migrate/20241008171600_create_good_job_execution_duration.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :duration) + end + end + + add_column :good_job_executions, :duration, :interval + end +end diff --git a/db/migrate/20241009082132_add_concurrency_key_to_good_job_executions.rb b/db/migrate/20241009082132_add_concurrency_key_to_good_job_executions.rb new file mode 100644 index 000000000..be5ecc9e3 --- /dev/null +++ b/db/migrate/20241009082132_add_concurrency_key_to_good_job_executions.rb @@ -0,0 +1,6 @@ +class AddConcurrencyKeyToGoodJobExecutions < ActiveRecord::Migration[6.0] + def change + add_column :good_job_executions, :concurrency_key, :string + add_index :good_job_executions, :concurrency_key + end +end diff --git a/db/migrate/20241014110435_create_user_jobs.rb b/db/migrate/20241014110435_create_user_jobs.rb new file mode 100644 index 000000000..37532b866 --- /dev/null +++ b/db/migrate/20241014110435_create_user_jobs.rb @@ -0,0 +1,13 @@ +class CreateUserJobs < ActiveRecord::Migration[7.1] + def change + create_table :user_jobs, id: :uuid do |t| + t.uuid :user_id, null: false, type: :uuid + t.uuid :good_job_id, null: false, type: :uuid + + t.timestamps + end + + add_foreign_key :user_jobs, :good_jobs, column: :good_job_id + add_index :user_jobs, [:user_id, :good_job_id], unique: true + end +end diff --git a/db/migrate/20241101125219_create_class_teachers.rb b/db/migrate/20241101125219_create_class_teachers.rb new file mode 100644 index 000000000..37f53a8bf --- /dev/null +++ b/db/migrate/20241101125219_create_class_teachers.rb @@ -0,0 +1,12 @@ +class CreateClassTeachers < ActiveRecord::Migration[7.1] + def change + create_table :class_teachers, id: :uuid do |t| + t.references :school_class, type: :uuid, foreign_key: true, index: true, null: false + t.uuid :teacher_id, null: false + t.timestamps + end + + add_index :class_teachers, :teacher_id + add_index :class_teachers, %i[school_class_id teacher_id], unique: true + end +end diff --git a/db/migrate/20241101125505_move_teacher_data_to_class_teachers_table.rb b/db/migrate/20241101125505_move_teacher_data_to_class_teachers_table.rb new file mode 100644 index 000000000..5611a7746 --- /dev/null +++ b/db/migrate/20241101125505_move_teacher_data_to_class_teachers_table.rb @@ -0,0 +1,11 @@ +class MoveTeacherDataToClassTeachersTable < ActiveRecord::Migration[7.1] + def up + SchoolClass.find_each do |school_class| + ClassTeacher.create!(school_class: school_class, teacher_id: school_class.teacher_id) + end + end + + def down + ClassTeacher.destroy_all + end +end diff --git a/db/migrate/20241101125814_remove_class_teacher_id.rb b/db/migrate/20241101125814_remove_class_teacher_id.rb new file mode 100644 index 000000000..d380886f0 --- /dev/null +++ b/db/migrate/20241101125814_remove_class_teacher_id.rb @@ -0,0 +1,14 @@ +class RemoveClassTeacherId < ActiveRecord::Migration[7.1] + def up + remove_column :school_classes, :teacher_id + end + + def down + add_column :school_classes, :teacher_id, :uuid + ClassTeacher.find_each do |class_teacher| + school_class = class_teacher.school_class + school_class.update!(teacher_id: class_teacher.teacher_id) + end + change_column_null :school_classes, :teacher_id, false + end +end diff --git a/db/migrate/20241101142545_rename_class_members_to_class_students.rb b/db/migrate/20241101142545_rename_class_members_to_class_students.rb new file mode 100644 index 000000000..2557a095f --- /dev/null +++ b/db/migrate/20241101142545_rename_class_members_to_class_students.rb @@ -0,0 +1,9 @@ +class RenameClassMembersToClassStudents < ActiveRecord::Migration[7.1] + def up + rename_table :class_members, :class_students + end + + def down + rename_table :class_students, :class_members + end +end diff --git a/db/migrate/20241115154405_add_creator_agree_to_ux_contact.rb b/db/migrate/20241115154405_add_creator_agree_to_ux_contact.rb new file mode 100644 index 000000000..946a455b1 --- /dev/null +++ b/db/migrate/20241115154405_add_creator_agree_to_ux_contact.rb @@ -0,0 +1,5 @@ +class AddCreatorAgreeToUxContact < ActiveRecord::Migration[7.1] + def change + add_column :schools, :creator_agree_to_ux_contact, :boolean, default: false + end +end diff --git a/db/migrate/20250109140005_add_instructions_field_to_projects.rb b/db/migrate/20250109140005_add_instructions_field_to_projects.rb new file mode 100644 index 000000000..c51686826 --- /dev/null +++ b/db/migrate/20250109140005_add_instructions_field_to_projects.rb @@ -0,0 +1,5 @@ +class AddInstructionsFieldToProjects < ActiveRecord::Migration[7.1] + def change + add_column :projects, :instructions, :text + end +end diff --git a/db/migrate/20250206154935_create_school_projects.rb b/db/migrate/20250206154935_create_school_projects.rb new file mode 100644 index 000000000..54a328a61 --- /dev/null +++ b/db/migrate/20250206154935_create_school_projects.rb @@ -0,0 +1,10 @@ +class CreateSchoolProjects < ActiveRecord::Migration[7.1] + def change + create_table :school_projects, id: :uuid do |t| + t.references :school, foreign_key: true, type: :uuid + t.references :project, null: false, foreign_key: true, type: :uuid + t.boolean :finished, default: false + t.timestamps + end + end +end diff --git a/db/migrate/20250206160256_generate_school_projects.rb b/db/migrate/20250206160256_generate_school_projects.rb new file mode 100644 index 000000000..06a1c1c9b --- /dev/null +++ b/db/migrate/20250206160256_generate_school_projects.rb @@ -0,0 +1,13 @@ +class GenerateSchoolProjects < ActiveRecord::Migration[7.1] + def up + Project.all.each do |project| + if project.school.present? + SchoolProject.create!(school: project.school, project:) + end + end + end + + def down + SchoolProject.destroy_all + end +end diff --git a/db/migrate/20250207095204_remove_finished_column_from_projects.rb b/db/migrate/20250207095204_remove_finished_column_from_projects.rb new file mode 100644 index 000000000..4de461fb3 --- /dev/null +++ b/db/migrate/20250207095204_remove_finished_column_from_projects.rb @@ -0,0 +1,19 @@ +class RemoveFinishedColumnFromProjects < ActiveRecord::Migration[7.1] + def up + Project.all.each do |project| + if project.school_project.present? + project.school_project.update!(finished: project.finished) + end + end + remove_column :projects, :finished + end + + def down + add_column :projects, :finished, :boolean, default: false + Project.all.each do |project| + if project.school_project.present? + project.update!(finished: project.school_project.finished) + end + end + end +end diff --git a/db/migrate/20250307113438_add_school_class_code.rb b/db/migrate/20250307113438_add_school_class_code.rb new file mode 100644 index 000000000..c24917d1d --- /dev/null +++ b/db/migrate/20250307113438_add_school_class_code.rb @@ -0,0 +1,6 @@ +class AddSchoolClassCode < ActiveRecord::Migration[7.1] + def change + add_column :school_classes, :code, :string + add_index :school_classes, [:code, :school_id], unique: true + end +end diff --git a/db/migrate/20250307113710_assign_code_to_existing_classes.rb b/db/migrate/20250307113710_assign_code_to_existing_classes.rb new file mode 100644 index 000000000..77d59c374 --- /dev/null +++ b/db/migrate/20250307113710_assign_code_to_existing_classes.rb @@ -0,0 +1,12 @@ +class AssignCodeToExistingClasses < ActiveRecord::Migration[7.1] + def up + SchoolClass.find_each do |school_class| + school_class.assign_class_code + school_class.save! + end + end + + def down + SchoolClass.update_all(code: nil) + end +end diff --git a/db/migrate/20250415133546_add_creator_accepts_safeguarding_responsibilities.rb b/db/migrate/20250415133546_add_creator_accepts_safeguarding_responsibilities.rb new file mode 100644 index 000000000..7c371792d --- /dev/null +++ b/db/migrate/20250415133546_add_creator_accepts_safeguarding_responsibilities.rb @@ -0,0 +1,5 @@ +class AddCreatorAcceptsSafeguardingResponsibilities < ActiveRecord::Migration[7.1] + def change + add_column :schools, :creator_agree_responsible_safeguarding, :boolean, default: true + end +end diff --git a/db/migrate/20250515081023_add_user_origin_to_schools.rb b/db/migrate/20250515081023_add_user_origin_to_schools.rb new file mode 100644 index 000000000..7d839b676 --- /dev/null +++ b/db/migrate/20250515081023_add_user_origin_to_schools.rb @@ -0,0 +1,5 @@ +class AddUserOriginToSchools < ActiveRecord::Migration[7.1] + def change + add_column :schools, :user_origin, :integer, default: 0 + end +end diff --git a/db/schema.graphql b/db/schema.graphql index d421fb1b2..2399b33c1 100644 --- a/db/schema.graphql +++ b/db/schema.graphql @@ -567,7 +567,7 @@ type Query { ): Project """ - All viewable projects + All viewable personal projects """ projects( """ diff --git a/db/schema.rb b/db/schema.rb index fe23a335f..b9237c649 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_03_03_123149) do +ActiveRecord::Schema[7.1].define(version: 2025_05_15_081023) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -43,6 +43,26 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "class_students", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_class_id", null: false + t.uuid "student_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["school_class_id", "student_id"], name: "index_class_students_on_school_class_id_and_student_id", unique: true + t.index ["school_class_id"], name: "index_class_students_on_school_class_id" + t.index ["student_id"], name: "index_class_students_on_student_id" + end + + create_table "class_teachers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_class_id", null: false + t.uuid "teacher_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["school_class_id", "teacher_id"], name: "index_class_teachers_on_school_class_id_and_teacher_id", unique: true + t.index ["school_class_id"], name: "index_class_teachers_on_school_class_id" + t.index ["teacher_id"], name: "index_class_teachers_on_teacher_id" + end + create_table "components", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "project_id" t.string "name", null: false @@ -67,12 +87,34 @@ t.datetime "enqueued_at" t.datetime "discarded_at" t.datetime "finished_at" + t.datetime "jobs_finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" + t.string "concurrency_key" + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index ["concurrency_key"], name: "index_good_job_executions_on_concurrency_key" + t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" end create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "state" + t.integer "lock_type", limit: 2 end create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -100,19 +142,60 @@ t.datetime "cron_at" t.uuid "batch_id" t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" - t.index ["active_job_id"], name: "index_good_jobs_on_active_job_id" t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" - t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at" - t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at", unique: true + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end + create_table "lessons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_id" + t.uuid "school_class_id" + t.uuid "copied_from_id" + t.uuid "user_id", null: false + t.string "name", null: false + t.string "description" + t.string "visibility", default: "teachers", null: false + t.datetime "due_date" + t.datetime "archived_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["archived_at"], name: "index_lessons_on_archived_at" + t.index ["copied_from_id"], name: "index_lessons_on_copied_from_id" + t.index ["name"], name: "index_lessons_on_name" + t.index ["school_class_id"], name: "index_lessons_on_school_class_id" + t.index ["school_id"], name: "index_lessons_on_school_id" + t.index ["user_id"], name: "index_lessons_on_user_id" + t.index ["visibility"], name: "index_lessons_on_visibility" + end + + create_table "project_errors", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "project_id" + t.string "error", null: false + t.string "error_type" + t.uuid "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_project_errors_on_project_id" + end + create_table "projects", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id" t.string "name" @@ -122,17 +205,122 @@ t.datetime "updated_at", null: false t.uuid "remixed_from_id" t.string "locale" + t.string "remix_origin" + t.uuid "school_id" + t.uuid "lesson_id" + t.text "instructions" t.index ["identifier", "locale"], name: "index_projects_on_identifier_and_locale", unique: true t.index ["identifier"], name: "index_projects_on_identifier" + t.index ["lesson_id"], name: "index_projects_on_lesson_id" t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" + t.index ["school_id"], name: "index_projects_on_school_id" + end + + create_table "roles", force: :cascade do |t| + t.uuid "user_id" + t.uuid "school_id" + t.integer "role" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["school_id"], name: "index_roles_on_school_id" + t.index ["user_id", "school_id", "role"], name: "index_roles_on_user_id_and_school_id_and_role", unique: true + t.index ["user_id"], name: "index_roles_on_user_id" + end + + create_table "school_classes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_id", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "description" + t.string "code" + t.index ["code", "school_id"], name: "index_school_classes_on_code_and_school_id", unique: true + t.index ["school_id"], name: "index_school_classes_on_school_id" + end + + create_table "school_projects", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_id" + t.uuid "project_id", null: false + t.boolean "finished", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_school_projects_on_project_id" + t.index ["school_id"], name: "index_school_projects_on_school_id" + end + + create_table "schools", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "reference" + t.string "address_line_1", null: false + t.string "address_line_2" + t.string "municipality", null: false + t.string "administrative_area" + t.string "postal_code" + t.string "country_code", null: false + t.datetime "verified_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "rejected_at" + t.uuid "creator_id" + t.string "website", null: false + t.string "creator_role" + t.string "creator_department" + t.boolean "creator_agree_authority" + t.boolean "creator_agree_terms_and_conditions" + t.string "code" + t.boolean "creator_agree_to_ux_contact", default: false + t.boolean "creator_agree_responsible_safeguarding", default: true + t.integer "user_origin", default: 0 + t.index ["code"], name: "index_schools_on_code", unique: true + t.index ["creator_id"], name: "index_schools_on_creator_id", unique: true + t.index ["reference"], name: "index_schools_on_reference", unique: true + end + + create_table "teacher_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email_address" + t.uuid "school_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "accepted_at" + t.index ["school_id"], name: "index_teacher_invitations_on_school_id" + end + + create_table "user_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "good_job_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "good_job_id"], name: "index_user_jobs_on_user_id_and_good_job_id", unique: true end - create_table "words", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "word" - t.index ["word"], name: "index_words_on_word" + create_table "versions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "item_type", null: false + t.string "item_id", null: false + t.string "event", null: false + t.string "whodunnit" + t.datetime "created_at" + t.json "object_changes" + t.uuid "meta_project_id" + t.uuid "meta_school_id" + t.uuid "meta_remixed_from_id" + t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "class_students", "school_classes" + add_foreign_key "class_teachers", "school_classes" add_foreign_key "components", "projects" + add_foreign_key "lessons", "lessons", column: "copied_from_id" + add_foreign_key "lessons", "school_classes" + add_foreign_key "lessons", "schools" + add_foreign_key "project_errors", "projects" + add_foreign_key "projects", "lessons" + add_foreign_key "projects", "schools" + add_foreign_key "roles", "schools" + add_foreign_key "school_classes", "schools" + add_foreign_key "school_projects", "projects" + add_foreign_key "school_projects", "schools" + add_foreign_key "teacher_invitations", "schools" + add_foreign_key "user_jobs", "good_jobs" end diff --git a/db/seeds.rb b/db/seeds.rb index 67b07eff5..97eb33852 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,14 +1,8 @@ # frozen_string_literal: true -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Examples: -# -# movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) -# Character.create(name: "Luke", movie: movies.first) -words = File.readlines('words.txt').map { |w| w.delete!("\n") } +require 'rake' -words.each do |word| - Word.create(word: word) +if Rails.env.development? + Rake::Task['projects:create_all'].invoke + Rake::Task['for_education:seed_a_school_with_lessons_and_students'].invoke end diff --git a/docker-compose.yml b/docker-compose.yml index 4d5419007..3ab356b16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: db: image: postgres:14 @@ -8,7 +6,9 @@ services: - POSTGRES_PASSWORD - POSTGRES_USER ports: - - "5433:5432" + - "5434:5432" + volumes: + - postgres-data:/var/lib/postgresql/data api: build: @@ -26,17 +26,23 @@ services: # The cache should be on tmpfs too, to ensure it gets wiped between runs - type: tmpfs target: /app/tmp/cache - command: bin/rails server --port 3009 --binding 0.0.0.0 + command: bash bin/docker-entrypoint.sh # NB: The API runs on port 3009. ports: - "3009:3009" stdin_open: true # For docker run --interactive, i.e. keep STDIN open even if not attached tty: true # For docker run --tty, i.e. allocate a pseudo-TTY. Important to allow interactive byebug sessions environment: - - POSTGRES_HOST=db + - POSTGRES_HOST - POSTGRES_DB - POSTGRES_PASSWORD - POSTGRES_USER + extra_hosts: + - "host.docker.internal:host-gateway" smee: image: deltaprojects/smee-client + platform: linux/amd64 command: -u $SMEE_TUNNEL -t http://api:3009/github_webhooks + +volumes: + postgres-data: diff --git a/erd.pdf b/erd.pdf new file mode 100644 index 000000000..f871be993 Binary files /dev/null and b/erd.pdf differ diff --git a/lib/concepts/class_member/operations/create.rb b/lib/concepts/class_member/operations/create.rb new file mode 100644 index 000000000..0859ac1c0 --- /dev/null +++ b/lib/concepts/class_member/operations/create.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module ClassMember + class Create + class << self + def call(school_class:, students: [], teachers: []) + response = OperationResponse.new + response[:class_members] = [] + response[:errors] = {} + raise ArgumentError, 'No valid school members provided' if students.blank? && teachers.blank? + + create_class_teachers(school_class:, teachers:, response:) + create_class_students(school_class:, students:, response:) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error creating class members: #{e.message}" + response + end + + private + + def create_class_teachers(school_class:, teachers:, response:) + teachers.each do |teacher| + class_teacher = school_class.teachers.build({ teacher_id: teacher.id }) + class_teacher.teacher = teacher + class_teacher.save! + response[:class_members] << class_teacher + rescue StandardError => e + handle_class_teacher_error(e, class_teacher, teacher, response) + response + end + end + + def create_class_students(school_class:, students:, response:) + students.each do |student| + class_student = school_class.students.build({ student_id: student.id }) + class_student.student = student + class_student.save! + response[:class_members] << class_student + rescue StandardError => e + handle_class_student_error(e, class_student, student, response) + response + end + end + + def handle_class_teacher_error(exception, class_teacher, teacher, response) + Sentry.capture_exception(exception) + errors = class_teacher.errors.full_messages.join(',') + response[:errors][teacher.id] = "Error creating class member for teacher_id #{teacher.id}: #{errors}" + end + + def handle_class_student_error(exception, class_student, student, response) + Sentry.capture_exception(exception) + errors = class_student.errors.full_messages.join(',') + response[:errors][student.id] = "Error creating class member for student_id #{student.id}: #{errors}" + end + end + end +end diff --git a/lib/concepts/class_member/operations/delete.rb b/lib/concepts/class_member/operations/delete.rb new file mode 100644 index 000000000..f4827ea98 --- /dev/null +++ b/lib/concepts/class_member/operations/delete.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ClassMember + class Delete + class << self + def call(school_class:, class_member_id:) + response = OperationResponse.new + delete_class_member(school_class, class_member_id) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting class member: #{e}" + response + end + + private + + def delete_class_member(school_class, class_member_id) + class_member = school_class.students.find(class_member_id) + class_member.destroy! + end + end + end +end diff --git a/lib/concepts/class_member/operations/list.rb b/lib/concepts/class_member/operations/list.rb new file mode 100644 index 000000000..f6b2e8268 --- /dev/null +++ b/lib/concepts/class_member/operations/list.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ClassMember + class List + class << self + def call(school_class:, class_students:, token:) + response = OperationResponse.new + response[:class_members] = [] + + begin + school = school_class.school + student_ids = class_students.pluck(:student_id) + students = SchoolStudent::List.call(school:, token:, student_ids:).fetch(:school_students, []) + class_students.each do |member| + member.student = students.find { |student| student.id == member.student_id } + end + + teacher_ids = school_class.teacher_ids + teachers = SchoolTeacher::List.call(school:, teacher_ids:).fetch(:school_teachers, []) + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing class members: #{e}" + return response + end + + response[:class_members] = teachers + class_students.sort do |a, b| + a.student.name <=> b.student.name + end + + response + end + end + end +end diff --git a/lib/concepts/lesson/operations/archive.rb b/lib/concepts/lesson/operations/archive.rb new file mode 100644 index 000000000..3bd8bd547 --- /dev/null +++ b/lib/concepts/lesson/operations/archive.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Lesson + class Archive + class << self + def call(lesson:) + response = OperationResponse.new + lesson.archive! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error archiving lesson: #{e}" + response + end + end + end +end diff --git a/lib/concepts/lesson/operations/create.rb b/lib/concepts/lesson/operations/create.rb new file mode 100644 index 000000000..77ac1d55d --- /dev/null +++ b/lib/concepts/lesson/operations/create.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Lesson + class Create + class << self + def call(lesson_params:) + response = OperationResponse.new + response[:lesson] = build_lesson(lesson_params) + response[:lesson].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + if response[:lesson].nil? + response[:error] = "Error creating lesson #{e}" + else + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error creating lesson: #{errors}" + end + response + end + + private + + def build_lesson(lesson_hash) + new_lesson = Lesson.new(lesson_hash.except(:project_attributes)) + project_params = lesson_hash[:project_attributes].merge({ user_id: lesson_hash[:user_id], + school_id: lesson_hash[:school_id], + lesson_id: new_lesson.id }) + new_lesson.project = Project.new(project_params) + new_lesson + end + end + end +end diff --git a/lib/concepts/lesson/operations/create_copy.rb b/lib/concepts/lesson/operations/create_copy.rb new file mode 100644 index 000000000..4d09d70ee --- /dev/null +++ b/lib/concepts/lesson/operations/create_copy.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Lesson + class CreateCopy + class << self + def call(lesson:, lesson_params:) + response = OperationResponse.new + response[:lesson] = build_copy(lesson, lesson_params) + response[:lesson].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error creating copy of lesson: #{errors}" + response + end + + private + + def build_copy(lesson, lesson_params) + lesson_copy = Lesson.new(parent: lesson, name: lesson.name, description: lesson.description) + lesson_copy.assign_attributes(lesson_params) + + project_params = { name: lesson_copy.name, user_id: lesson_params[:user_id], lesson_id: lesson_copy.id } + lesson_copy.project = build_project_copy(lesson.project, project_params) + + lesson_copy + end + + def build_project_copy(project, project_params) + project_attributes = project.attributes.except('id', 'identifier', 'created_at', 'updated_at').merge(project_params) + project_copy = Project.new(project_attributes) + + project.images.each do |image| + project_copy.images.attach(image.blob) + end + + project.components.each do |component| + project_copy.components.build({ name: component.name, extension: component.extension, content: component.content }) + end + + project_copy + end + end + end +end diff --git a/lib/concepts/lesson/operations/unarchive.rb b/lib/concepts/lesson/operations/unarchive.rb new file mode 100644 index 000000000..f7c3170dc --- /dev/null +++ b/lib/concepts/lesson/operations/unarchive.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Lesson + class Unarchive + class << self + def call(lesson:) + response = OperationResponse.new + lesson.unarchive! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error unarchiving lesson: #{e}" + response + end + end + end +end diff --git a/lib/concepts/lesson/operations/update.rb b/lib/concepts/lesson/operations/update.rb new file mode 100644 index 000000000..6a0c86d44 --- /dev/null +++ b/lib/concepts/lesson/operations/update.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Lesson + class Update + class << self + def call(lesson:, lesson_params:) + ActiveRecord::Base.transaction do + response = OperationResponse.new + response[:lesson] = lesson + response[:lesson].assign_attributes(lesson_params) + response[:lesson].save! + if lesson_params[:name].present? + rename_lesson_project(lesson: response[:lesson], name: lesson_params[:name]) + rename_lesson_remixes(lesson: response[:lesson], name: lesson_params[:name]) + end + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error updating lesson: #{errors}" + response + end + end + + def rename_lesson_project(lesson:, name:) + return unless lesson.project + + lesson.project.update!(name:) + end + + def rename_lesson_remixes(lesson:, name:) + return unless lesson.project + + lesson_remixes = Project.where(remixed_from_id: lesson.project.id) + lesson_remixes.each do |remix| + remix.update!(name:) + end + end + end + end +end diff --git a/lib/concepts/project/operations/create.rb b/lib/concepts/project/operations/create.rb index 25b3dd11e..a2e942889 100644 --- a/lib/concepts/project/operations/create.rb +++ b/lib/concepts/project/operations/create.rb @@ -3,28 +3,23 @@ class Project class Create class << self - def call(project_hash:) + def call(project_hash:, current_user:) response = OperationResponse.new - response[:project] = build_project(project_hash) + response[:project] = build_project(project_hash, current_user) response[:project].save! response rescue StandardError => e Sentry.capture_exception(e) - response[:error] = 'Error creating project' + response[:error] = "Error creating project: #{e}" response end private - def build_project(project_hash) - identifier = PhraseIdentifier.generate - new_project = Project.new(project_hash.except(:components, :image_list).merge(identifier:)) + def build_project(project_hash, current_user) + project_hash[:identifier] = PhraseIdentifier.generate unless current_user&.experience_cs_admin? + new_project = Project.new(project_hash.except(:components)) new_project.components.build(project_hash[:components]) - - (project_hash[:image_list] || []).each do |image| - new_project.images.attach(image.blob) - end - new_project end end diff --git a/lib/concepts/project/operations/create_remix.rb b/lib/concepts/project/operations/create_remix.rb index 10a3dda5b..ef42defc3 100644 --- a/lib/concepts/project/operations/create_remix.rb +++ b/lib/concepts/project/operations/create_remix.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true -require 'operation_response' - class Project class CreateRemix class << self - def call(params:, user_id:, original_project:) + def call(params:, user_id:, original_project:, remix_origin:) response = OperationResponse.new - validate_params(response, params, user_id, original_project) - remix_project(response, params, user_id, original_project) if response.success? + validate_params(response, params, user_id, original_project, remix_origin) + remix_project(response, params, user_id, original_project, remix_origin) if response.success? response rescue StandardError => e Sentry.capture_exception(e) @@ -19,24 +17,32 @@ def call(params:, user_id:, original_project:) private - def validate_params(response, params, user_id, original_project) - valid = params[:identifier].present? && user_id.present? && original_project.present? + def validate_params(response, params, user_id, original_project, remix_origin) + valid = params[:identifier].present? && user_id.present? && original_project.present? && remix_origin.present? response[:error] = I18n.t('errors.project.remixing.invalid_params') unless valid end - def remix_project(response, params, user_id, original_project) - response[:project] = create_remix(original_project, params, user_id) + def remix_project(response, params, user_id, original_project, remix_origin) + response[:project] = create_remix(original_project, params, user_id, remix_origin) response[:project].save! response end - def create_remix(original_project, params, user_id) - remix = format_project(original_project, params, user_id) + def create_remix(original_project, params, user_id, remix_origin) + remix = format_project(original_project, params, user_id, remix_origin) original_project.images.each do |image| remix.images.attach(image.blob) end + original_project.videos.each do |video| + remix.videos.attach(video.blob) + end + + original_project.audio.each do |audio_file| + remix.audio.attach(audio_file.blob) + end + params[:components].each do |x| remix.components.build(x.slice(:name, :extension, :content)) end @@ -44,13 +50,15 @@ def create_remix(original_project, params, user_id) remix end - def format_project(original_project, params, user_id) + def format_project(original_project, params, user_id, remix_origin) original_project.dup.tap do |proj| proj.identifier = PhraseIdentifier.generate proj.locale = nil proj.name = params[:name] proj.user_id = user_id proj.remixed_from_id = original_project.id + proj.remix_origin = remix_origin + proj.lesson_id = nil # Only the original can have a lesson id end end end diff --git a/lib/concepts/project/operations/update.rb b/lib/concepts/project/operations/update.rb index 5b0276f51..03af7795b 100644 --- a/lib/concepts/project/operations/update.rb +++ b/lib/concepts/project/operations/update.rb @@ -3,17 +3,17 @@ class Project class Update class << self - def call(project:, update_hash:) + def call(project:, update_hash:, current_user:) response = setup_response(project) setup_deletions(response, update_hash) - update_project_attributes(response, update_hash) + update_project_attributes(response, update_hash, current_user) update_component_attributes(response, update_hash) persist_changes(response) response rescue StandardError => e Sentry.capture_exception(e) - response[:error] ||= 'Error persisting changes' + response[:error] ||= "Error persisting changes: #{e.message}" response end @@ -42,10 +42,24 @@ def validate_deletions(response) response[:error] = I18n.t 'errors.project.editing.delete_default_component' end - def update_project_attributes(response, update_hash) + def student_project_instructions_updated?(response, update_hash, current_user) + is_school_project = response[:project].school.present? + user_is_student = current_user.student? + instructions_updated = response[:project].instructions != update_hash[:instructions] + is_school_project && user_is_student && instructions_updated + end + + def validate_update(response, update_hash, current_user) + return unless student_project_instructions_updated?(response, update_hash, current_user) + + response[:error] = I18n.t 'errors.project.editing.student_update_instructions' + end + + def update_project_attributes(response, update_hash, current_user) + validate_update(response, update_hash, current_user) return if response.failure? - response[:project].assign_attributes(update_hash.slice(:name)) + response[:project].assign_attributes(update_hash.slice(:name, :instructions)) end def update_component_attributes(response, update_hash) diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb new file mode 100644 index 000000000..86477e4da --- /dev/null +++ b/lib/concepts/school/operations/create.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class School + class Create + class << self + def call(school_params:, creator_id:) + response = OperationResponse.new + response[:school] = build_school(school_params.merge!(creator_id:)) + response[:school].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = response[:school].errors + response + end + + private + + def build_school(school_params) + School.new(school_params) + end + end + end +end diff --git a/lib/concepts/school/operations/delete.rb b/lib/concepts/school/operations/delete.rb new file mode 100644 index 000000000..24b49f7f8 --- /dev/null +++ b/lib/concepts/school/operations/delete.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class School + class Delete + class << self + def call(school_id:) + response = OperationResponse.new + delete_school(school_id) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting school: #{e}" + response + end + + private + + def delete_school(school_id) + school = School.find(school_id) + school.destroy! + end + end + end +end diff --git a/lib/concepts/school/operations/update.rb b/lib/concepts/school/operations/update.rb new file mode 100644 index 000000000..439f30d64 --- /dev/null +++ b/lib/concepts/school/operations/update.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class School + class Update + class << self + def call(school:, school_params:) + response = OperationResponse.new + response[:school] = school + response[:school].assign_attributes(school_params) + response[:school].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:school].errors.full_messages.join(',') + response[:error] = "Error updating school: #{errors}" + response + end + end + end +end diff --git a/lib/concepts/school_class/operations/create.rb b/lib/concepts/school_class/operations/create.rb new file mode 100644 index 000000000..3b0dafd22 --- /dev/null +++ b/lib/concepts/school_class/operations/create.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class SchoolClass + class Create + class << self + def call(school:, school_class_params:, current_user:) + response = OperationResponse.new + response[:school_class] = build_class(school, school_class_params, current_user) + response[:school_class].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:school_class].errors.full_messages.join(',') + response[:error] = "Error creating school class: #{errors}" + response + end + + private + + def build_class(school, school_class_params, current_user) + new_class = school.classes.build(school_class_params) + new_class.teachers.build(teacher_id: current_user.id) + new_class + end + end + end +end diff --git a/lib/concepts/school_class/operations/delete.rb b/lib/concepts/school_class/operations/delete.rb new file mode 100644 index 000000000..f189f9caf --- /dev/null +++ b/lib/concepts/school_class/operations/delete.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SchoolClass + class Delete + class << self + def call(school:, school_class_id:) + response = OperationResponse.new + delete_school_class(school, school_class_id) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting school class: #{e}" + response + end + + private + + def delete_school_class(school, school_class_id) + school_class = school.classes.find(school_class_id) + school_class.destroy! + end + end + end +end diff --git a/lib/concepts/school_class/operations/update.rb b/lib/concepts/school_class/operations/update.rb new file mode 100644 index 000000000..3ee94a0e5 --- /dev/null +++ b/lib/concepts/school_class/operations/update.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SchoolClass + class Update + class << self + def call(school_class:, school_class_params:) + response = OperationResponse.new + response[:school_class] = school_class + response[:school_class].assign_attributes(school_class_params) + response[:school_class].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:school_class].errors.full_messages.join(',') + response[:error] = "Error updating school class: #{errors}" + response + end + end + end +end diff --git a/lib/concepts/school_member/list.rb b/lib/concepts/school_member/list.rb new file mode 100644 index 000000000..930ddc238 --- /dev/null +++ b/lib/concepts/school_member/list.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module SchoolMember + class List + SchoolMember = Struct.new(:id, :name, :username, :email, :type) + + class << self + # rubocop:disable Metrics/CyclomaticComplexity + def call(school:, token:) + response = OperationResponse.new + response[:school_members] = [] + + students = teachers = owners = [] + + begin + students_response = SchoolStudent::List.call(school:, token:).fetch(:school_students, []) + teachers_response = SchoolTeacher::List.call(school:).fetch(:school_teachers, []) + owners_response = SchoolOwner::List.call(school:).fetch(:school_owners, []) + + students = students_response.map do |student| + SchoolMember.new(student.id, student.name, student.username, nil, :student) + end + owners = owners_response.map do |owner| + SchoolMember.new(owner.id, owner.name, nil, owner.email, :owner) + end + owner_ids = owners.map(&:id) + teachers = teachers_response.reject { |teacher| owner_ids.include?(teacher.id) }.map do |teacher| + SchoolMember.new(teacher.id, teacher.name, nil, teacher.email, :teacher) + end + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school members: #{e}" + response + end + + response[:school_members] = (owners + teachers + students).sort_by(&:name) + response + end + end + # rubocop:enable Metrics/CyclomaticComplexity + end +end diff --git a/lib/concepts/school_owner/invite.rb b/lib/concepts/school_owner/invite.rb new file mode 100644 index 000000000..bcb8b594e --- /dev/null +++ b/lib/concepts/school_owner/invite.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SchoolOwner + class Invite + class << self + def call(school:, school_owner_params:, token:) + response = OperationResponse.new + invite_owner(school, school_owner_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error inviting school owner: #{e}" + response + end + + private + + def invite_owner(school, school_owner_params, token) + email_address = school_owner_params.fetch(:email_address) + + raise ArgumentError, 'school is not verified' unless school.verified? + raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) + + ProfileApiClient.invite_school_owner(token:, email_address:, organisation_id: school.id) + end + end + end +end diff --git a/lib/concepts/school_owner/list.rb b/lib/concepts/school_owner/list.rb new file mode 100644 index 000000000..3c682d3cd --- /dev/null +++ b/lib/concepts/school_owner/list.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module SchoolOwner + class List + class << self + def call(school:, owner_ids: nil) + response = OperationResponse.new + owner_ids = school.roles.where(role: :owner)&.pluck(:user_id) if owner_ids.blank? + response[:school_owners] = list_owners(owner_ids) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school owners: #{e}" + response + end + + private + + def list_owners(ids) + User.from_userinfo(ids:) + end + end + end +end diff --git a/lib/concepts/school_owner/remove.rb b/lib/concepts/school_owner/remove.rb new file mode 100644 index 000000000..94d3d6d22 --- /dev/null +++ b/lib/concepts/school_owner/remove.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SchoolOwner + class Remove + class << self + def call(school:, owner_id:, token:) + response = OperationResponse.new + remove_owner(school, owner_id, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error removing school owner: #{e}" + response + end + + private + + def remove_owner(school, owner_id, token) + ProfileApiClient.remove_school_owner(token:, owner_id:, organisation_id: school.id) + end + end + end +end diff --git a/lib/concepts/school_project/set_finished.rb b/lib/concepts/school_project/set_finished.rb new file mode 100644 index 000000000..24e6347d6 --- /dev/null +++ b/lib/concepts/school_project/set_finished.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class SchoolProject + class SetFinished + class << self + def call(school_project:, finished:) + response = OperationResponse.new + response[:school_project] = school_project + response[:school_project].assign_attributes(finished:) + response[:school_project].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = response[:school_project]&.errors + response + end + end + end +end diff --git a/lib/concepts/school_student/create.rb b/lib/concepts/school_student/create.rb new file mode 100644 index 000000000..22ba9b1fc --- /dev/null +++ b/lib/concepts/school_student/create.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module SchoolStudent + class Create + class << self + def call(school:, school_student_params:, token:) + response = OperationResponse.new + response[:student_id] = create_student(school, school_student_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error creating school student: #{e}" + response + end + + private + + def create_student(school, school_student_params, token) + school_id = school.id + username = school_student_params.fetch(:username) + encrypted_password = school_student_params.fetch(:password) + password = DecryptionHelpers.decrypt_password(encrypted_password) + name = school_student_params.fetch(:name) + + validate(school:, username:, password:, name:) + + response = ProfileApiClient.create_school_student(token:, username:, password:, name:, school_id:) + user_id = response[:created].first + Role.student.create!(school:, user_id:) + user_id + end + + def validate(school:, username:, password:, name:) + raise ArgumentError, 'school is not verified' unless school.verified? + raise ArgumentError, "username '#{username}' is invalid" if username.blank? + raise ArgumentError, "password '#{password}' is invalid" if password.size < 8 + raise ArgumentError, "name '#{name}' is invalid" if name.blank? + end + end + end +end diff --git a/lib/concepts/school_student/create_batch.rb b/lib/concepts/school_student/create_batch.rb new file mode 100644 index 000000000..e4074af2e --- /dev/null +++ b/lib/concepts/school_student/create_batch.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module SchoolStudent + class Error < StandardError; end + + class ValidationError < StandardError + attr_reader :errors + + def initialize(errors) + @errors = errors + super() + end + end + + class ConcurrencyExceededForSchool < StandardError; end + + class CreateBatch + class << self + def call(school:, school_students_params:, token:, user_id:) + response = OperationResponse.new + response[:job_id] = create_batch(school, school_students_params, token, user_id) + response + rescue ValidationError => e + response[:error] = e.errors + response[:error_type] = :validation_error + response + rescue ConcurrencyExceededForSchool => e + response[:error] = e + response[:error_type] = :job_concurrency_error + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error creating school students: #{e}" + response[:error_type] = :standard_error + response + end + + private + + def create_batch(school, students, token, user_id) + # Ensure that nil values are empty strings, else Profile will swallow validations + students = students.map do |student| + student.transform_values { |value| value.nil? ? '' : value } + end + + validate(school:, students:, token:) + + job = CreateStudentsJob.attempt_perform_later(school_id: school.id, students:, token:, user_id:) + job&.job_id + end + + def validate(school:, students:, token:) + decrypted_students = decrypt_students(students) + ProfileApiClient.create_school_students(token:, students: decrypted_students, school_id: school.id, preflight: true) + rescue ProfileApiClient::Student422Error => e + handle_student422_error(e.errors) + end + + def decrypt_students(students) + students.deep_dup.each do |student| + student[:password] = DecryptionHelpers.decrypt_password(student[:password]) if student[:password].present? + end + end + + def handle_student422_error(errors) + formatted_errors = errors.each_with_object({}) do |error, hash| + username = error['username'] || error['path'] + field = error['path'].split('.').last + + hash[username] ||= [] + hash[username] << I18n.t( + "validations.school_student.#{error['errorCode'].underscore}", + field:, + default: error['message'] + ) + + # Ensure uniqueness to avoid repeat errors with duplicate usernames + hash[username] = hash[username].uniq + end + + raise ValidationError, formatted_errors unless formatted_errors.nil? || formatted_errors.blank? + end + end + end +end diff --git a/lib/concepts/school_student/delete.rb b/lib/concepts/school_student/delete.rb new file mode 100644 index 000000000..3f06ac1f6 --- /dev/null +++ b/lib/concepts/school_student/delete.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SchoolStudent + class Delete + class << self + def call(school:, student_id:, token:) + response = OperationResponse.new + delete_student(school, student_id, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting school student: #{e}" + response + end + + private + + def delete_student(school, student_id, token) + ProfileApiClient.delete_school_student(token:, student_id:, school_id: school.id) + end + end + end +end diff --git a/lib/concepts/school_student/list.rb b/lib/concepts/school_student/list.rb new file mode 100644 index 000000000..6ca467f15 --- /dev/null +++ b/lib/concepts/school_student/list.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SchoolStudent + class List + class << self + def call(school:, token:, student_ids: nil) + response = OperationResponse.new + response[:school_students] = list_students(school, token, student_ids) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school students: #{e}" + response + end + + private + + def list_students(school, token, student_ids) + student_ids ||= Role.student.where(school:).map(&:user_id) + ProfileApiClient.list_school_students(token:, school_id: school.id, student_ids:).map do |student| + User.new(student.to_h.slice(:id, :username, :name)) + end + end + end + end +end diff --git a/lib/concepts/school_student/update.rb b/lib/concepts/school_student/update.rb new file mode 100644 index 000000000..2a5b8742f --- /dev/null +++ b/lib/concepts/school_student/update.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module SchoolStudent + class Update + class << self + def call(school:, student_id:, school_student_params:, token:) + response = OperationResponse.new + update_student(school, student_id, school_student_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error updating school student: #{e}" + response + end + + private + + def update_student(school, student_id, school_student_params, token) + username = school_student_params.fetch(:username, nil) + name = school_student_params.fetch(:name, nil) + password = school_student_params.fetch(:password, nil) + password = DecryptionHelpers.decrypt_password(password) if password.present? + + validate(username:, password:, name:) + + ProfileApiClient.update_school_student( + token:, school_id: school.id, student_id:, username:, password:, name: + ) + end + + def validate(username:, password:, name:) + raise ArgumentError, "username '#{username}' is invalid" if !username.nil? && username.blank? + raise ArgumentError, "password '#{password}' is invalid" if !password.nil? && password.size < 8 + raise ArgumentError, "name '#{name}' is invalid" if !name.nil? && name.blank? + end + end + end +end diff --git a/lib/concepts/school_teacher/invite.rb b/lib/concepts/school_teacher/invite.rb new file mode 100644 index 000000000..29ee31c2e --- /dev/null +++ b/lib/concepts/school_teacher/invite.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module SchoolTeacher + class Invite + class << self + def call(school:, school_teacher_params:, token:) + response = OperationResponse.new + invite_teacher(school, school_teacher_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error inviting school teacher: #{e}" + response + end + + private + + def invite_teacher(school, school_teacher_params, _token) + email_address = school_teacher_params.fetch(:email_address) + TeacherInvitation.create!(school:, email_address:) + end + end + end +end diff --git a/lib/concepts/school_teacher/list.rb b/lib/concepts/school_teacher/list.rb new file mode 100644 index 000000000..69466c33f --- /dev/null +++ b/lib/concepts/school_teacher/list.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module SchoolTeacher + class List + class << self + def call(school:, teacher_ids: nil) + response = OperationResponse.new + teacher_ids = school.roles.where(role: :teacher)&.pluck(:user_id) if teacher_ids.blank? + response[:school_teachers] = list_teachers(teacher_ids) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school teachers: #{e}" + response + end + + private + + def list_teachers(ids) + User.from_userinfo(ids:) + end + end + end +end diff --git a/lib/concepts/school_teacher/remove.rb b/lib/concepts/school_teacher/remove.rb new file mode 100644 index 000000000..505f331e3 --- /dev/null +++ b/lib/concepts/school_teacher/remove.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module SchoolTeacher + class Remove + class << self + def call(school:, teacher_id:, token:) + response = OperationResponse.new + remove_teacher(school, teacher_id, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error removing school teacher: #{e}" + response + end + + private + + def remove_teacher(school, teacher_id, token) + # TODO: This has not been implemented yet + ProfileApiClient.remove_school_teacher(token:, teacher_id:, organisation_id: school.id) + end + end + end +end diff --git a/lib/corp_middleware.rb b/lib/corp_middleware.rb new file mode 100644 index 000000000..8eaacd36e --- /dev/null +++ b/lib/corp_middleware.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative 'origin_parser' + +class CorpMiddleware + def initialize(app) + @app = app + end + + def call(env) + status, headers, response = @app.call(env) + request_origin = env['HTTP_HOST'] + allowed_origins = OriginParser.parse_origins + + if env['PATH_INFO'].start_with?('/rails/active_storage') && allowed_origins.any? do |origin| + origin.is_a?(Regexp) ? origin =~ request_origin : origin == request_origin + end + headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + end + + [status, headers, response] + end +end diff --git a/lib/for_education_code_generator.rb b/lib/for_education_code_generator.rb new file mode 100644 index 000000000..4130a01f5 --- /dev/null +++ b/lib/for_education_code_generator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ForEducationCodeGenerator + MAX_CODE = 1_000_000 + + def self.generate + number = Random.new.rand(MAX_CODE) + code = format('%06d', number) + + code.match(/(\d\d)(\d\d)(\d\d)/) do |m| + [m[1], m[2], m[3]].join('-') + end + end +end diff --git a/lib/helpers/decryption_helpers.rb b/lib/helpers/decryption_helpers.rb new file mode 100644 index 000000000..f9b53ba49 --- /dev/null +++ b/lib/helpers/decryption_helpers.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'openssl' +require 'base64' + +class DecryptionHelpers + def self.decrypt_password(encrypted_password) + hex_key = ENV.fetch('http://github.com/EDITOR_ENCRYPTION_KEY') + key = [hex_key].pack('H*') # Convert the hex key to binary + + begin + cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher.decrypt + cipher.key = key + + encrypted_data = Base64.decode64(encrypted_password) + iv = encrypted_data[0, 16] + encrypted_password = encrypted_data[16..] + + cipher.iv = iv + cipher.update(encrypted_password) + cipher.final + rescue StandardError => e + raise "Decryption failed: #{e.message}" + end + end +end diff --git a/lib/hydra_admin_api.rb b/lib/hydra_admin_api.rb deleted file mode 100644 index 3d88bec45..000000000 --- a/lib/hydra_admin_api.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'faraday' - -class HydraAdminApi - ADMIN_URL = ENV.fetch('http://github.com/HYDRA_ADMIN_URL', 'http://localhost:9002/') - ADMIN_API_KEY = ENV.fetch('http://github.com/HYDRA_ADMIN_API_KEY', 'test-key') - - # The "bypass" user ID from - # https://github.com/RaspberryPiFoundation/rpi-auth/blob/main/lib/rpi_auth/engine.rb#L17 - BYPASS_AUTH_USER_ID = 'b6301f34-b970-4d4f-8314-f877bad8b150' - - class << self - def fetch_oauth_user_id(...) - new.fetch_oauth_user_id(...) - end - end - - def fetch_oauth_user_id(token:) - return nil if token.blank? - - return BYPASS_AUTH_USER_ID if ENV.fetch('http://github.com/BYPASS_AUTH', nil) == 'yes' - - response = post('oauth2/introspect', { token: }, { apikey: ADMIN_API_KEY }) - response.body['sub'] - end - - private - - def conn - @conn ||= Faraday.new(ADMIN_URL) do |f| - f.request :url_encoded - f.response :raise_error - f.response :json - end - end - - def post(...) - conn.post(...) - end -end diff --git a/lib/hydra_public_api_client.rb b/lib/hydra_public_api_client.rb new file mode 100644 index 000000000..8d0921104 --- /dev/null +++ b/lib/hydra_public_api_client.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'faraday' + +class HydraPublicApiClient + # Allows us to use a different URL for API calls to auth locally + HYDRA_PUBLIC_API_URL = ENV.fetch('http://github.com/HYDRA_PUBLIC_API_URL', nil) + API_URL = HYDRA_PUBLIC_API_URL || ENV.fetch('http://github.com/HYDRA_PUBLIC_URL', 'http://localhost:9001/') + + class << self + def fetch_oauth_user(...) + new.fetch_oauth_user(...) + end + end + + def fetch_oauth_user(token:) + return stubbed_user if bypass_oauth? + + response = get('userinfo', {}, { Authorization: "Bearer #{token}" }) + response.body.to_h + rescue Faraday::UnauthorizedError => e + Sentry.capture_exception(e) + nil + end + + private + + def bypass_oauth? + ENV.fetch('http://github.com/BYPASS_OAUTH', nil) == 'true' + end + + def stubbed_user + { + id: '00000000-0000-0000-0000-000000000000', + email: 'school-owner@example.com', + username: nil, + parentalEmail: nil, + name: 'School Owner', + nickname: 'Owner', + country: 'United Kingdom', + country_code: 'GB', + postcode: nil, + dateOfBirth: nil, + verifiedAt: '2024-01-01T12:00:00.000Z', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', + discardedAt: nil, + lastLoggedInAt: '2024-01-01T12:00:00.000Z', + roles: '' + } + end + + def conn + @conn ||= Faraday.new(API_URL) do |f| + f.request :url_encoded + f.response :raise_error + f.response :json + end + end + + def get(...) + conn.get(...) + end +end diff --git a/lib/locales.rb b/lib/locales.rb new file mode 100644 index 000000000..bb286baf8 --- /dev/null +++ b/lib/locales.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'i18n' +require 'i18n/backend/fallbacks' + +# TODO: 25-02-25: This is lifted from https://github.com/RaspberryPiFoundation/aws-lambdas/blob/dca3894a81f8e1a83aa939a91a948b198e6e70a4/projects-build/lib/locales.rb, this should be centralised (perhaps as a gem) and both places updated to utilise it. +class Locales + class << self + def load_locales + I18n::Backend::Simple.include I18n::Backend::Fallbacks + two_letter_locales = %i[ + en + ] + + four_letter_locales = %i[ + af-ZA am-ET ar-SA az-AZ bg-BG bn-BD bn-IN ca-ES cs-CZ cy-GB da-DK de-DE el-GR es-ES es-LA et-EE fa-IR fi-FI fil-PH fr-CA + fr-FR ga-IE gd-GB gu-IN ha-HG he-IL hi-IN hr-HR hu-HU id-ID ig-NG it-IT ja-JP kn-IN ko-KR lv-LV me-ME ml-IN mn-MN mr-IN ms-MY mt-MT my-MM ne-NP nl-NL no-NO pl-PL ps-AF pt-BR + pt-PT ro-RO ru-RU sh-ZW si-LK sk-SK sl-SI so-SO sq-AL sr-SP sv-SE sw-KE ta-IN te-IN th-TH tr-TR tt-RU uk-UA ur-PK vi-VN vls-BE xh-ZA zh-CN zh-TW + ] + I18n.default_locale = :en + I18n.available_locales = two_letter_locales + four_letter_locales + end + end +end diff --git a/lib/origin_parser.rb b/lib/origin_parser.rb new file mode 100644 index 000000000..1e6472643 --- /dev/null +++ b/lib/origin_parser.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# fetch origins from the environment in a comma-separated string +# these can be literal strings or regexes +# regexes must be wrapped in forward slashes eg. /https?:\/\/localhost(:[0-9]*)?$/ +module OriginParser + def self.parse_origins + ENV['ALLOWED_ORIGINS']&.split(',')&.map do |origin| + stripped_origin = origin.strip + if stripped_origin.start_with?('/') && stripped_origin.end_with?('/') + Regexp.new(stripped_origin[1..-2]) + else + stripped_origin + end + end || [] + end +end diff --git a/lib/phrase_identifier.rb b/lib/phrase_identifier.rb index 421a3c1ac..8cab707d4 100644 --- a/lib/phrase_identifier.rb +++ b/lib/phrase_identifier.rb @@ -6,7 +6,7 @@ class Error < RuntimeError def self.generate 10.times do - phrase = Word.order(Arel.sql('RANDOM()')).take(3).pluck(:word).join('-') + phrase = words.shuffle.take(3).join('-') # Uh-oh, no words found? raise PhraseIdentifier::Error, 'Unable to generate a random phrase identifier' if phrase.blank? @@ -21,4 +21,8 @@ def self.generate def self.unique?(phrase) phrase.present? && Project.where(identifier: phrase).none? end + + def self.words + @words ||= File.readlines('words.txt', chomp: true) + end end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb new file mode 100644 index 000000000..4d9892313 --- /dev/null +++ b/lib/profile_api_client.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +class ProfileApiClient + SAFEGUARDING_FLAGS = { + teacher: 'school:teacher', + owner: 'school:owner' + }.freeze + + School = Data.define(:id, :schoolCode, :updatedAt, :createdAt, :discardedAt) + SafeguardingFlag = Data.define(:id, :userId, :flag, :email, :createdAt, :updatedAt, :discardedAt) + Student = Data.define(:id, :schoolId, :name, :username, :createdAt, :updatedAt, :discardedAt) + + class Error < StandardError; end + + class Student422Error < StandardError + attr_reader :errors + + def initialize(errors) + @errors = errors + if errors.is_a?(Hash) + super(errors['message']) + else + super() + end + end + end + + class UnexpectedResponse < Error + attr_reader :response_status, :response_headers, :response_body + + def initialize(response) + @response_status = response.status + @response_headers = response.headers + @response_body = response.body + + super "Unexpected response from Profile API (status code #{response.status})" + end + end + + class << self + def create_school(token:, id:, code:) + return { 'id' => id, 'schoolCode' => code } if ENV['BYPASS_OAUTH'].present? + + response = connection(token).post('/api/v1/schools') do |request| + request.body = { + id:, + schoolCode: code + } + end + + raise UnexpectedResponse, response unless response.status == 201 + + School.new(**response.body) + end + + def list_school_owners(*) + {} + end + + def invite_school_owner(*) + {} + end + + def remove_school_owner(*) + {} + end + + def remove_school_teacher(*) + {} + end + + def school_student(token:, school_id:, student_id:) + response = connection(token).get("/api/v1/schools/#{school_id}/students/#{student_id}") + + raise UnexpectedResponse, response unless response.status == 200 + + Student.new(**response.body) + end + + def list_school_students(token:, school_id:, student_ids:) + return [] if token.blank? + + response = connection(token).post("/api/v1/schools/#{school_id}/students/list") do |request| + request.body = student_ids + end + + raise UnexpectedResponse, response unless response.status == 200 + + response.body.map { |attrs| Student.new(**attrs.symbolize_keys) } + end + + def create_school_student(token:, username:, password:, name:, school_id:) + return nil if token.blank? + + response = connection(token).post("/api/v1/schools/#{school_id}/students") do |request| + request.body = [{ + name: name.strip, + username: username.strip, + password: password.strip + }] + end + + raise UnexpectedResponse, response unless response.status == 201 + + response.body.deep_symbolize_keys + rescue Faraday::BadRequestError => e + raise Student422Error, JSON.parse(e.response_body)['errors'].first + end + + def create_school_students(token:, students:, school_id:, preflight: false) + return nil if token.blank? + + students = Array(students) + endpoint = "/api/v1/schools/#{school_id}/students" + endpoint += '/preflight' if preflight + response = connection(token).post(endpoint) do |request| + request.body = students.to_json + request.headers['Content-Type'] = 'application/json' + end + + raise UnexpectedResponse, response unless [200, 201].include?(response.status) + + response.body.deep_symbolize_keys + rescue Faraday::BadRequestError => e + raw_error = JSON.parse(e.response_body) + # Profile returns an array for standard errors, and json for bulk validations + if raw_error.is_a?(Array) + raise Error, raw_error.first['message'] + elsif raw_error['errors'] + raise Student422Error, raw_error['errors'] + else + raise Student422Error, 'An unknown error occurred' + end + end + + def update_school_student(token:, school_id:, student_id:, name: nil, username: nil, password: nil) # rubocop:disable Metrics/ParameterLists + return nil if token.blank? + + response = connection(token).patch("/api/v1/schools/#{school_id}/students/#{student_id}") do |request| + request.body = { + name: name&.strip, + username: username&.strip, + password: password&.strip + }.compact + end + + raise UnexpectedResponse, response unless response.status == 200 + + Student.new(**response.body) + rescue Faraday::BadRequestError => e + raise Student422Error, JSON.parse(e.response_body)['errors'].first + end + + def delete_school_student(token:, school_id:, student_id:) + return nil if token.blank? + + response = connection(token).delete("/api/v1/schools/#{school_id}/students/#{student_id}") + + raise UnexpectedResponse, response unless response.status == 204 + end + + def safeguarding_flags(token:) + response = connection(token).get('/api/v1/safeguarding-flags') + + raise UnexpectedResponse, response unless response.status == 200 + + response.body.map { |flag| SafeguardingFlag.new(**flag.symbolize_keys) } + end + + def create_safeguarding_flag(token:, flag:, email:) + response = connection(token).post('/api/v1/safeguarding-flags') do |request| + request.body = { flag:, email: } + end + + raise UnexpectedResponse, response unless [201, 303].include?(response.status) + end + + def delete_safeguarding_flag(token:, flag:) + response = connection(token).delete("/api/v1/safeguarding-flags/#{flag}") + + raise UnexpectedResponse, response unless response.status == 204 + end + + private + + def connection(token) + Faraday.new(ENV.fetch('http://github.com/IDENTITY_URL')) do |faraday| + faraday.request :json + faraday.response :json + faraday.response :raise_error + faraday.headers = { + 'Accept' => 'application/json', + 'Authorization' => "Bearer #{token}", + 'X-API-KEY' => ENV.fetch('http://github.com/PROFILE_API_KEY') + } + end + end + end +end diff --git a/lib/project_importer.rb b/lib/project_importer.rb index 306571f84..2669336ba 100644 --- a/lib/project_importer.rb +++ b/lib/project_importer.rb @@ -1,13 +1,16 @@ # frozen_string_literal: true class ProjectImporter - attr_reader :name, :identifier, :images, :components, :type, :locale + attr_reader :name, :identifier, :images, :videos, :audio, :media, :components, :type, :locale def initialize(**kwargs) @name = kwargs[:name] @identifier = kwargs[:identifier] @components = kwargs[:components] @images = kwargs[:images] + @videos = kwargs[:videos] + @audio = kwargs[:audio] + @media = Array(images) + Array(videos) + Array(audio) @type = kwargs[:type] @locale = kwargs[:locale] end @@ -17,8 +20,8 @@ def import! setup_project delete_components create_components - delete_removed_images - attach_images_if_needed + delete_removed_media + attach_media_if_needed project.save! end @@ -46,37 +49,57 @@ def create_components end end - def delete_removed_images - return if removed_image_names.empty? + def delete_removed_media + return if removed_media_names.empty? - removed_image_names.each do |filename| - img = project.images.find { |i| i.blob.filename == filename } - img.purge + removed_media_names.each do |filename| + media_file = project.media.find { |i| i.blob.filename == filename } + media_file.purge end end - def removed_image_names - existing_images = project.images.map { |x| x.blob.filename.to_s } - existing_images - images.pluck(:filename) + def removed_media_names + existing_media = project.media.map { |x| x.blob.filename.to_s } + existing_media - media.pluck(:filename) end - def attach_images_if_needed - images.each do |image| - existing_image = find_existing_image(image[:filename]) - if existing_image - next if existing_image.blob.checksum == image_checksum(image[:io]) + def attach_media_if_needed + media.each do |media_file| + existing_media_file = find_existing_media_file(media_file[:filename]) + if existing_media_file + next if existing_media_file.blob.checksum == media_checksum(media_file[:io]) - existing_image.purge + existing_media_file.purge + end + + if images.include?(media_file) + blob = create_blob(media_file) + project.images.attach(blob) + elsif videos.include?(media_file) + blob = create_blob(media_file) + project.videos.attach(blob) + elsif audio.include?(media_file) + blob = create_blob(media_file) + project.audio.attach(blob) + else + raise "Unsupported media file: #{media_file[:filename]}" end - project.images.attach(**image) end end - def find_existing_image(filename) - project.images.find { |i| i.blob.filename == filename } + def create_blob(media_file) + ActiveStorage::Blob.create_and_upload!( + io: media_file[:io], + filename: media_file[:filename], + content_type: media_file[:content_type] + ) + end + + def find_existing_media_file(filename) + project.media.find { |i| i.blob.filename == filename } end - def image_checksum(io) + def media_checksum(io) OpenSSL::Digest.new('MD5').tap do |checksum| while (chunk = io.read(5.megabytes)) checksum << chunk diff --git a/lib/project_loader.rb b/lib/project_loader.rb index 0d029d7f6..16ff5222d 100644 --- a/lib/project_loader.rb +++ b/lib/project_loader.rb @@ -8,8 +8,9 @@ def initialize(identifier, locales) @locales = [*locales, 'en', nil] end - def load - projects = Project.where(identifier:, locale: @locales) - projects.min_by { |project| @locales.find_index(project.locale) } + def load(include_images: false) + query = Project.where(identifier:, locale: @locales) + query = query.includes(images_attachments: :blob) if include_images + query.min_by { |project| @locales.find_index(project.locale) } end end diff --git a/lib/tasks/README.md b/lib/tasks/README.md index 9d7208d98..c24d751c0 100644 --- a/lib/tasks/README.md +++ b/lib/tasks/README.md @@ -9,7 +9,7 @@ Please note that in this document, to avoid confusion, `project` refers to an en ## Creating the project directory Each version of a Projects site 'project' such as a starter or finished example needs its own directory in `/project_components` as they will form separate `project`s in the database. Although the naming of these directories is inconsequential, at the moment they are roughly named in the form `{project_name}_starter` and `{project_name}_example` for the sake of consistency. -Each directory for a `project` should contain copies of the `python` files and any `text`, `csv` and image files that the `project` should contain. Any content reused across multiple `project`s should be duplicated in the relevant directory for each `project`. +Each directory for a `project` should contain copies of the `python` files and any `text`, `csv` and image files that the `project` should contain. Any content reused across multiple `project`s should be duplicated in the relevant directory for each `project`. ### Populating `project_config.yml` Every directory representing a `project` must contain a `project_config.yml`. This should include the following information: @@ -27,7 +27,7 @@ Every directory representing a `project` must contain a `project_config.yml`. Th An example `project_config.yml` with all of the above properties can be seen [here](https://github.com/RaspberryPiFoundation/editor-api/blob/main/lib/tasks/project_components/persuasive_data_presentation_iss_starter/project_config.yml). ## Getting the projects created in the database -Please commit the required changes to a branch in the [`editor-api` repository](https://github.com/RaspberryPiFoundation/editor-ui/) and create a pull request to merge your branch into `main`. Once merged, we will run the task to create your `project`s in the database. +Please commit the required changes to a branch in the [`editor-api` repository](https://github.com/RaspberryPiFoundation/editor-api/) and create a pull request to merge your branch into `main`. Once merged, we will run the task to create your `project`s in the database. ## Amending existing projects Existing `project`s can be ammended by updating the content in the directory corresponding to that `project`. Please create a pull request with the required changes as described above and we will ensure they are applied once the pull request has been merged. diff --git a/lib/tasks/default.rake b/lib/tasks/default.rake new file mode 100644 index 000000000..982d9bf2f --- /dev/null +++ b/lib/tasks/default.rake @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +return if Rails.env.production? + +Rake::Task[:default].clear_prerequisites +task default: %i[rubocop spec] diff --git a/lib/tasks/for_education.rake b/lib/tasks/for_education.rake new file mode 100644 index 000000000..0e4eda93f --- /dev/null +++ b/lib/tasks/for_education.rake @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require_relative './seeds_helper' + +Rails.logger = Logger.new($stdout) unless Rails.env.test? + +# To override uuids call with: +# `SEEDING_CREATOR_ID=00000000-0000-0000-0000-000000000000 rails for_education:seed_an_unverified_school` +# `SEEDING_TEACHER_ID=00000000-0000-0000-0000-000000000000 rails for_education:seed_a_school_with_lessons` + +# For students to match up the school needs to match with the school defined in profile (hard coded in the helper) + +namespace :for_education do + include SeedsHelper + + desc 'Destroy existing data' + task destroy_seed_data: :environment do + ActiveRecord::Base.transaction do + Rails.logger.info 'Destroying existing seeds...' + creator_id = ENV.fetch('http://github.com/SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) + teacher_id = ENV.fetch('http://github.com/SEEDING_TEACHER_ID', TEST_USERS[:john_doe]) + + # Hard coded as the student's school needs to match + student_ids = [TEST_USERS[:jane_smith], TEST_USERS[:john_smith]] + school_id = TEST_SCHOOL + + # Remove the roles first + Role.where(user_id: [creator_id, teacher_id] + student_ids).destroy_all + + # Destroy the project and then the lesson itself (The lesson's `before_destroy` prevents us using destroy) + lesson_ids = Lesson.where(school_id:).pluck(:id) + Project.where(lesson_id: [lesson_ids]).destroy_all + Lesson.where(id: [lesson_ids]).delete_all + + # Destroy the class members and then the class itself + school_class_ids = SchoolClass.where(school_id:).pluck(:id) + ClassStudent.where(school_class_id: [school_class_ids]).destroy_all + SchoolClass.where(id: [school_class_ids]).destroy_all + + # Destroy the school + School.find(school_id).destroy + + Rails.logger.info 'Done...' + rescue StandardError => e + Rails.logger.error "Failed: #{e.message}" + raise ActiveRecord::Rollback + end + end + + desc 'Create an unverified school' + task seed_an_unverified_school: :environment do + if School.find_by(code: TEST_SCHOOL) + puts "Test school (#{TEST_SCHOOL}) already exists, run the destroy_seed_data task to start over)." + return + end + + ActiveRecord::Base.transaction do + Rails.logger.info 'Attempting to seed data...' + creator_id = ENV.fetch('http://github.com/SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) + create_school(creator_id, TEST_SCHOOL) + + Rails.logger.info 'Done...' + rescue StandardError => e + Rails.logger.error "Failed: #{e.message}" + raise ActiveRecord::Rollback + end + end + + desc 'Create a verified school' + task seed_a_verified_school: :environment do + if School.find_by(code: TEST_SCHOOL) + puts "Test school (#{TEST_SCHOOL}) already exists, run the destroy_seed_data task to start over)." + return + end + + ActiveRecord::Base.transaction do + Rails.logger.info 'Attempting to seed data...' + creator_id = ENV.fetch('http://github.com/SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) + + school = create_school(creator_id, TEST_SCHOOL) + verify_school(school) + Rails.logger.info 'Done...' + rescue StandardError => e + Rails.logger.error "Failed: #{e.message}" + raise ActiveRecord::Rollback + end + end + + desc 'Create a school with lessons and students' + task seed_a_school_with_lessons_and_students: :environment do + if School.find_by(code: TEST_SCHOOL) + puts "Test school (#{TEST_SCHOOL}) already exists, run the destroy_seed_data task to start over)." + return + end + + ActiveRecord::Base.transaction do + Rails.logger.info 'Attempting to seed data...' + creator_id = ENV.fetch('http://github.com/SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) + teacher_id = ENV.fetch('http://github.com/SEEDING_TEACHER_ID', TEST_USERS[:john_doe]) + + school = create_school(creator_id, TEST_SCHOOL) + verify_school(school) + assign_a_teacher(teacher_id, school) + + school_class = create_school_class(creator_id, school) + assign_students(school_class, school) + + lessons = create_lessons(creator_id, school, school_class) + lessons.each do |lesson| + create_project(creator_id, school, lesson) + end + Rails.logger.info 'Done...' + rescue StandardError => e + Rails.logger.error "Failed: #{e.message}" + raise ActiveRecord::Rollback + end + end +end diff --git a/lib/tasks/project_components/anime_expressions-solution/annoyed.png b/lib/tasks/project_components/anime_expressions-solution/annoyed.png new file mode 100644 index 000000000..b69aef496 Binary files /dev/null and b/lib/tasks/project_components/anime_expressions-solution/annoyed.png differ diff --git a/lib/tasks/project_components/anime_expressions-solution/candy.css b/lib/tasks/project_components/anime_expressions-solution/candy.css new file mode 100644 index 000000000..cd311c2da --- /dev/null +++ b/lib/tasks/project_components/anime_expressions-solution/candy.css @@ -0,0 +1,20 @@ + +/* Candy colour palette & fonts */ + +:root { + --primary: #ebeaeb; + --onprimary: #625d61; + --secondary: #f5bdd5; + --onsecondary: #1D3D58; + --tertiary: #b5a9b2; + --ontertiary: #422215; + --page: #ffffff; + --onpage: #000000; + --detail: #e697b9; + --detail2: #415a89; + + --body-font: 1rem 'Verdana', sans-serif; + --header-font: 3rem 'Fredoka One', cursive; + --title-font: 2rem 'Fredoka One', cursive; + --quote-font: lighter 1.5rem 'Chewy', cursive; +} \ No newline at end of file diff --git a/lib/tasks/project_components/anime_expressions-solution/default.css b/lib/tasks/project_components/anime_expressions-solution/default.css new file mode 100644 index 000000000..c1efa8d03 --- /dev/null +++ b/lib/tasks/project_components/anime_expressions-solution/default.css @@ -0,0 +1,20 @@ + +/* Set up colour palette and fonts using variables */ + +:root { + --primary: #bccad0; + --onprimary:#4f4e4e; + --secondary: #495054; + --onsecondary:#ffffff; + --tertiary:#747474; + --ontertiary: #ffffff; + --page:#ffffff; + --onpage:#000000; + --detail: #9ba8ae; + --detail2: #000000; + + --body-font: 1rem Verdana, sans-serif; + --header-font: lighter 3rem 'Luckiest Guy', cursive; + --title-font: lighter 2rem 'Luckiest Guy', cursive; + --quote-font: lighter 1.5rem 'Luckiest Guy', cursive; +} \ No newline at end of file diff --git a/lib/tasks/project_components/anime_expressions-solution/happy.png b/lib/tasks/project_components/anime_expressions-solution/happy.png new file mode 100644 index 000000000..ad926798c Binary files /dev/null and b/lib/tasks/project_components/anime_expressions-solution/happy.png differ diff --git a/lib/tasks/project_components/anime_expressions-solution/index.html b/lib/tasks/project_components/anime_expressions-solution/index.html new file mode 100644 index 000000000..88d0805fd --- /dev/null +++ b/lib/tasks/project_components/anime_expressions-solution/index.html @@ -0,0 +1,63 @@ + + + + + + + + + Add a title here + + + + + + + + + + + + + +
+

Draw anime with me

+
+ + +
+
+

Facial expressions

+

Take a look at these facial expressions and try them in your own drawings.

+
+ + +
+ The love facial expression. +

To make your anime character look like they are in love, replace the eyes with two rounded hearts. You can add three more hearts inside for a fun effect.

+
+ + + + + + + + + + +
+ + +
+ +
+ + + diff --git a/lib/tasks/project_components/anime_expressions-solution/love.png b/lib/tasks/project_components/anime_expressions-solution/love.png new file mode 100644 index 000000000..ad2bc07a7 Binary files /dev/null and b/lib/tasks/project_components/anime_expressions-solution/love.png differ diff --git a/lib/tasks/project_components/anime_expressions-solution/project_config.yml b/lib/tasks/project_components/anime_expressions-solution/project_config.yml new file mode 100644 index 000000000..ae2a212ae --- /dev/null +++ b/lib/tasks/project_components/anime_expressions-solution/project_config.yml @@ -0,0 +1,33 @@ +NAME: 'Anime expressions solution' +IDENTIFIER: 'anime-expressions-solution' +TYPE: 'html' +COMPONENTS: + - name: 'index' + extension: 'html' + location: 'index.html' + index: 0 + default: true + - name: 'style' + extension: 'css' + location: style.css + index: 1 + default: false + - name: 'candy' + extension: 'css' + location: candy.css + index: 2 + default: false + - name: 'default' + extension: 'css' + location: default.css + index: 3 + default: false + - name: 'vivid' + extension: 'css' + location: vivid.css + index: 4 + default: false +IMAGES: + - "annoyed.png" + - "happy.png" + - "love.png" diff --git a/lib/tasks/project_components/anime_expressions-solution/style.css b/lib/tasks/project_components/anime_expressions-solution/style.css new file mode 100644 index 000000000..15c87df91 --- /dev/null +++ b/lib/tasks/project_components/anime_expressions-solution/style.css @@ -0,0 +1,310 @@ +/* Colour pairings */ + +.primary { + background: var(--primary); + color: var(--onprimary); +} + +.secondary { + background: var(--secondary); + color: var(--onsecondary); +} + +.tertiary { + background: var(--tertiary); + color: var(--ontertiary); +} + +.page { + background: var(--page); + color: var(--onpage); +} + +/* Apply everywhere */ + +* { + margin: 0rem; + padding: 0rem; + box-sizing: border-box; /* Padding and border don't make a box bigger */ +} + +html, +body { + width: 100%; + margin: 0rem auto; + background: var(--page); + color: var(--onpage); + font: var(--body-font); + min-height: 100vh; /* Make the content fill the page so the footer is at the bottom */ + display: flex; + flex-direction: column; + justify-content: space-between; +} + +/* add a background image to body */ + +body { + /*background-image: url('http://github.com/name.jpg');*/ /* Uncomment and change filename to add a background image */ + /*background-repeat: repeat;*/ /* Make the image repeat */ + /*background-size: cover;*/ /* Make the image cover the whole container */ +} + +/* The main content of the page between the header and footer */ +main { + background: var(--primary); /* Colour the background */ + color: var(--onprimary); /* Colour the text */ + margin: 0 auto; /* Center if the browser is really wide */ + min-width: 25rem; /* Don't let the content get too narrow */ + max-width: 70rem; /* Don't let the content get too wide */ + padding: 0; + padding-top: 0.5rem; /* Padding at the top */ + margin-bottom: 1em; /* Gap before the footer */ +} + +/* Header and footer element styles */ + +header, +footer { + text-align: center; + width: 100%; /* Fill the full width of the window */ + margin: 0; /* Remove the default margin */ + min-height: 3rem; + padding-top: 1rem; + padding-bottom: 1rem; +} + +/* Section styles */ + +section { + padding: 1rem 2rem; + margin: 1rem auto; +} + +/* Border styles */ + +.border-bottom { + border-bottom: 20px solid var(--detail); /* Add a solid */ +} + +.border-top { + border-top: 10px solid var(--detail2); /* Add a solid line above the footer */ +} + +/* Add a transparent effect */ + +.transparent { + opacity: 0.95; +} + +/* Styles just for h1 elements */ + +h1 { + font: var(--header-font); /* Font style stored in the header-font variable */ + padding: 2rem; + margin: 0; /* Center if the browser is really wide */ +} + +/* Styles just for h2 elements */ + +h2 { + font: var(--title-font); /* Font style stored in the title-font variable */ + text-align: center; /* Align the text */ + padding: 1.5rem; /* Add some space all around the heading */ +} + +/* Highlight key words in bold and apply an alternative text colour */ + +strong { + color: var(--detail2); /* Text colour stored in the caption variable */ + font-weight: bold; /* Makes text weight stronger than the default*/ +} + +/* Style for ordered and unordered lists */ + +ol, +ul { + display: inline-block; + text-align: left; + padding-left: 2rem; +} + +/* Padding around paragraphs */ + +p { + padding: 1rem 1rem; +} + +/* Style for links */ + +a:link, +a:visited { + font-weight: bold; + color: inherit; /* Use the colour of the parent element */ +} + +.xcenter { + text-align: center; +} + +.ycenter { + display: flex; + justify-content: center; + flex-flow: column; +} + +/* Styles just for the .wrap class */ + +.wrap { + /* Make content wrap over mutiple rows */ + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + box-sizing: border-box; + gap: 0rem 0rem; /* horizontal and vertical gap */ +} + +/* For creating fancy boxes */ + +.dashed-border { + border: 0.25rem dashed var(--detail2); +} + +.solid-border { + border: 0.25rem solid var(--detail2); +} + +/* Styles for the div tags that are inside a .wrap class */ + +.wrap > div { + width: 14rem; + padding: 0rem; +} + +/* Styles for the img tags that are inside a .wrap class */ + +.wrap > img { + width: 14rem; + display: block; +} + +/* Styles for the p tags that are inside a .wrap class */ + +.wrap > p, +.wrap > span { + width: 14rem; + display: block; +} + +/* Specific styles for this project */ + +.bigfont { + font-size: 3rem; +} + +.hugefont { + /* Used to make a large emoji */ + font-size: 6rem; + text-align: center; + padding: 1rem; +} + +.wrap .narrow { + width: 10rem; +} + +.wrap .wide { + width: 20rem; +} + +blockquote { + font: var(--quote-font); + color: var(--detail); + text-align: center; + padding: 0rem; + max-width: 25rem; +} + +cite { + color: var(--detail); + font-size: small; +} + +/* Specific styles for this project */ + +.tile { + height: 9.4rem; +} + +.rounded { + border-radius: 1rem; +} + +.gradient1 { + background-image: linear-gradient( + to bottom right, + var(--secondary), + var(--detail) + ); + color: var(--onsecondary); +} + +.gradient2 { + background-image: linear-gradient( + to top right, + var(--tertiary), + var(--detail2) + ); + color: var(--ontertiary); +} + +.shadow { + box-shadow: 5px 5px 3px 0px #888888; /* right and bottom shadow size, blur, spread and colour */ + /*box-shadow: 5px 5px 4px 2px var(--detail);*/ +} + +.wrap .card { + width: 12rem; + height: 12rem; + border: 0.1rem solid transparent; +} + +.card-content { + position: relative; + width: 100%; + height: 100%; + text-align: center; + transition: transform 1s; + transform-style: preserve-3d; + perspective: 60rem; +} + +.card:hover .card-content { + transform: rotateY(180deg); +} + +.card-face { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; +} + +.card p { + padding: 0.5rem; +} + +/* Printed photo style */ + +.photo { + border: 1px solid #D0D0D0; /* Add a solid border */ + width: 14rem; + height: 15rem; + background: #ffffff; + padding-top: 1rem; + padding-left: 1rem; + padding-right: 1rem; + padding-bottom: 3rem; + box-shadow: 8px 8px 10px 4px #888888; /* right and bottom shadow, blur, spread and colour */ + transform: rotate(3deg); +} diff --git a/lib/tasks/project_components/anime_expressions-solution/vivid.css b/lib/tasks/project_components/anime_expressions-solution/vivid.css new file mode 100644 index 000000000..9bf847d82 --- /dev/null +++ b/lib/tasks/project_components/anime_expressions-solution/vivid.css @@ -0,0 +1,20 @@ + +/* Vivid colour palette & fonts */ + +:root { + --primary: #68bbe5; + --onprimary: #000000; + --secondary: #e2008a; + --onsecondary: #000000; + --tertiary: #fdf100; + --ontertiary: #000000; + --page: #ffffff; + --onpage: #000000; + --detail: #ffa71a; + --detail2: #41063c; + + --body-font: 1rem Verdana, sans-serif; + --header-font: lighter 3rem "Bangers", cursive; + --title-font: lighter 2rem "Bangers", cursive; + --quote-font: lighter 1.5rem 'Chewy', cursive; +} \ No newline at end of file diff --git a/lib/tasks/project_components/audio-video/hello_world.m4a b/lib/tasks/project_components/audio-video/hello_world.m4a new file mode 100644 index 000000000..05e648881 Binary files /dev/null and b/lib/tasks/project_components/audio-video/hello_world.m4a differ diff --git a/lib/tasks/project_components/audio-video/highlandCow.mp4 b/lib/tasks/project_components/audio-video/highlandCow.mp4 new file mode 100644 index 000000000..163aff31e Binary files /dev/null and b/lib/tasks/project_components/audio-video/highlandCow.mp4 differ diff --git a/lib/tasks/project_components/audio-video/main.py b/lib/tasks/project_components/audio-video/main.py new file mode 100644 index 000000000..00950d9ac --- /dev/null +++ b/lib/tasks/project_components/audio-video/main.py @@ -0,0 +1 @@ +print('hello world') \ No newline at end of file diff --git a/lib/tasks/project_components/audio-video/music.mp3 b/lib/tasks/project_components/audio-video/music.mp3 new file mode 100644 index 000000000..5b7a89f9d Binary files /dev/null and b/lib/tasks/project_components/audio-video/music.mp3 differ diff --git a/lib/tasks/project_components/audio-video/project_config.yml b/lib/tasks/project_components/audio-video/project_config.yml new file mode 100644 index 000000000..223625016 --- /dev/null +++ b/lib/tasks/project_components/audio-video/project_config.yml @@ -0,0 +1,3 @@ +NAME: 'Audio Video Test' +IDENTIFIER: 'audio-video' +TYPE: 'python' diff --git a/lib/tasks/project_components/audio-video/toy.mov b/lib/tasks/project_components/audio-video/toy.mov new file mode 100644 index 000000000..c9810a130 Binary files /dev/null and b/lib/tasks/project_components/audio-video/toy.mov differ diff --git a/lib/tasks/project_components/html_starter/index.html b/lib/tasks/project_components/html_starter/index.html index 6ff2928ff..694e0d3ae 100644 --- a/lib/tasks/project_components/html_starter/index.html +++ b/lib/tasks/project_components/html_starter/index.html @@ -5,6 +5,6 @@ -

Hello world

> +

Hello world

- \ No newline at end of file + diff --git a/lib/tasks/project_components/rocket_launch_upgrade/main.py b/lib/tasks/project_components/rocket_launch_upgrade/main.py index bed643e3e..22c98ccbf 100644 --- a/lib/tasks/project_components/rocket_launch_upgrade/main.py +++ b/lib/tasks/project_components/rocket_launch_upgrade/main.py @@ -21,7 +21,7 @@ def draw_rocket(): if fuel >= burn and rocket_y > high_orbit_y: # Still flying rocket_y -= speed # Move the rocket fuel -= burn # Burn fuel - print(‘Fuel left: ’, fuel) + print('Fuel left: ', fuel) no_stroke() # Turn off the stroke @@ -69,8 +69,8 @@ def setup(): size(screen_size, screen_size) image_mode(CENTER) global planet, rocket - planet = load_image(‘orange_planet.png’) # Your chosen planet - rocket = load_image(‘rocket.png’) + planet = load_image('orange_planet.png') # Your chosen planet + rocket = load_image('rocket.png') def draw(): @@ -79,7 +79,7 @@ def draw(): draw_rocket() -fuel = int(input(‘How many kilograms of fuel do you want to use?’)) -burn = int(input(‘How much fuel should the rocket burn each frame?’)) -speed = int(input(‘How far should the rocket travel each frame?’)) +fuel = int(input('How many kilograms of fuel do you want to use?')) +burn = int(input('How much fuel should the rocket burn each frame?')) +speed = int(input('How far should the rocket travel each frame?')) run() diff --git a/lib/tasks/projects.rake b/lib/tasks/projects.rake index 663d61871..cd9f84697 100644 --- a/lib/tasks/projects.rake +++ b/lib/tasks/projects.rake @@ -1,50 +1,77 @@ # frozen_string_literal: true -require 'yaml' -require 'project_importer' - -CODE_FORMATS = ['.py', '.csv', '.txt', '.html', '.css'].freeze -IMAGE_FORMATS = ['.png', '.jpg', '.jpeg'].freeze - namespace :projects do - desc 'Import starter projects' - task create_starter: :environment do - Dir.each_child("#{File.dirname(__FILE__)}/project_components") do |dir| - proj_config = YAML.safe_load(File.read("#{File.dirname(__FILE__)}/project_components/#{dir}/project_config.yml")) - files = Dir.children("#{File.dirname(__FILE__)}/project_components/#{dir}") - code_files = files.filter { |file| CODE_FORMATS.include? File.extname(file) } - image_files = files.filter { |file| IMAGE_FORMATS.include? File.extname(file) } - - components = [] - code_files.each do |file| - components << component(file, dir) - end + desc 'Import starter & example projects' + task create_all: :environment do + FilesystemProject.import_all! + end - images = [] - image_files.each do |file| - images << image(file, dir) + desc "Create example Scratch projects for Experience CS (if they don't already exist)" + task create_experience_cs_examples: :environment do + projects = [ + { + identifier: 'experience-cs-example', + locale: 'en', + project_type: Project::Types::SCRATCH, + name: 'Experience CS example', + user_id: nil + }, + { + identifier: 'dialogue-in-scratch', + locale: 'en', + project_type: Project::Types::SCRATCH, + name: 'Dialogue in Scratch', + user_id: nil + }, + { + identifier: 'ten-block-mission', + locale: 'en', + project_type: Project::Types::SCRATCH, + name: 'Ten block mission', + user_id: nil + }, + { + identifier: 'blank-scratch-starter', + locale: 'en', + project_type: Project::Types::SCRATCH, + name: 'Blank Scratch starter', + user_id: nil + }, + { + identifier: 'lets-explore-scratch', + locale: 'en', + project_type: Project::Types::SCRATCH, + name: "Let's explore Scratch", + user_id: nil + }, + { + identifier: 'transforming-sprites', + locale: 'en', + project_type: Project::Types::SCRATCH, + name: 'Transforming sprites', + user_id: nil + } + ] + projects.each do |attributes| + identifier = attributes[:identifier] + project = Project.find_by(attributes.slice(:identifier, :locale, :project_type)) + if project.present? + puts "Scratch project with identifier '#{identifier}' already exists" + project.assign_attributes(attributes.except(:identifier, :locale)) + if project.changed? + if project.save + puts "Scratch project with identifier '#{identifier}' updated successfully" + else + puts "Scratch project with identifier '#{identifier}' update failed" + end + else + puts "Scratch project with identifier '#{identifier}' has not changed" + end + elsif Project.create(attributes) + puts "Scratch project with identifier '#{identifier}' created successfully" + else + puts "Scratch project with identifier '#{identifier}' creation failed" end - - project_importer = ProjectImporter.new(name: proj_config['NAME'], identifier: proj_config['IDENTIFIER'], - type: proj_config['TYPE'] ||= 'python', - locale: proj_config['LOCALE'] ||= 'en', components:, images:) - project_importer.import! end end end - -private - -def component(file, dir) - name = File.basename(file, '.*') - extension = File.extname(file).delete('.') - code = File.read(File.dirname(__FILE__) + "/project_components/#{dir}/#{File.basename(file)}") - default = (File.basename(file) == 'main.py') - component = { name:, extension:, content: code, default: } -end - -def image(file, dir) - filename = File.basename(file) - io = File.open(File.dirname(__FILE__) + "/project_components/#{dir}/#{filename}") - image = { filename:, io: } -end diff --git a/lib/tasks/rubocop.rake b/lib/tasks/rubocop.rake new file mode 100644 index 000000000..e0a626f6c --- /dev/null +++ b/lib/tasks/rubocop.rake @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +return if Rails.env.production? + +require 'rubocop/rake_task' +RuboCop::RakeTask.new diff --git a/lib/tasks/seeds_helper.rb b/lib/tasks/seeds_helper.rb new file mode 100644 index 000000000..049734f15 --- /dev/null +++ b/lib/tasks/seeds_helper.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module SeedsHelper + TEST_USERS = { + jane_doe: '583ba872-b16e-46e1-9f7d-df89d267550d', # jane.doe@example.com + john_doe: 'bbb9b8fd-f357-4238-983d-6f87b99bdbb2', # john.doe@example.com + jane_smith: 'e52de409-9210-4e94-b08c-dd11439e07d9', # student + john_smith: '0d488bec-b10d-46d3-b6f3-4cddf5d90c71' # student + }.freeze + + # Match the school in profile... + TEST_SCHOOL = 'e52de409-9210-4e94-b08c-dd11439e07d9' # e52de409-9210-4e94-b08c-dd11439e07d9 + SCHOOL_CODE = '12-34-56' + + def create_school(creator_id, school_id = nil) + School.find_or_create_by!(creator_id:, id: school_id) do |school| + Rails.logger.info 'Seeding a school...' + school.name = Faker::Educator.secondary_school + school.website = Faker::Internet.url(/service/scheme: 'https') + school.address_line_1 = Faker::Address.street_address + school.municipality = Faker::Address.city + school.country_code = Faker::Address.country_code + school.creator_id = creator_id + school.creator_agree_authority = true + school.creator_agree_terms_and_conditions = true + school.creator_agree_to_ux_contact = true + end + end + + def verify_school(school) + if school.verified? + Rails.logger.info "School #{school.code} is already verified." + return + end + + Rails.logger.info 'Verifying the school...' + + School.transaction do + school.verify! + Role.owner.create!(user_id: school.creator_id, school:) + Role.teacher.create!(user_id: school.creator_id, school:) + end + + # rubocop:disable Rails/SkipsModelValidations + school.update_column(:code, SCHOOL_CODE) # The code needs to match the one in the profile + # rubocop:enable Rails/SkipsModelValidations + end + + def create_school_class(teacher_id, school, name = Faker::Educator.course_name, description = Faker::Hacker.phrases.sample) + SchoolClass.joins(:teachers) + .where(teachers: { teacher_id: }, school:) + .first_or_create! do |school_class| + Rails.logger.info 'Seeding a class...' + school_class.name = name + school_class.description = description + school_class.school = school + school_class.teachers = [ClassTeacher.new(teacher_id:)] + end + end + + def assign_a_teacher(user_id, school) + Rails.logger.info 'Adding a teacher...' + Role.teacher.find_or_create_by!(user_id:, school:) + end + + def assign_students(school_class, school) + [TEST_USERS[:jane_smith], TEST_USERS[:john_smith]].map do |student_id| + Rails.logger.info 'Assigning student role...' + Role.student.find_or_create_by!(user_id: student_id, school:) + + ClassStudent.find_or_create_by!(student_id:, school_class:) do |class_student| + Rails.logger.info 'Adding student...' + class_student.student_id = student_id + class_student.school_class = school_class + end + end + end + + def create_lessons(user_id, school, school_class, visibility = 'students') + 2.times.map do |i| + lesson_name = "Lesson #{i + 1}: #{Faker::ProgrammingLanguage.name}" + Lesson.find_or_create_by!(school:, school_class:, name: lesson_name, user_id:) do |lesson| + Rails.logger.info "Seeding Lesson #{i + 1}..." + lesson.user_id = user_id + lesson.school = school + lesson.school_class = school_class + lesson.name = lesson_name + lesson.visibility = visibility + end + end + end + + def create_project(user_id, school, lesson, code = '') + Project.find_or_create_by!(user_id:, school:, lesson:) do |project| + Rails.logger.info "Seeding a project for #{lesson.name}..." + project.name = lesson.name + project.user_id = user_id + project.school = school + project.lesson = lesson + project.locale = 'en' + project.project_type = Project::Types::PYTHON + project.components << Component.new({ extension: 'py', name: 'main', + content: code }) + end + end +end diff --git a/lib/tasks/test_seeds.rake b/lib/tasks/test_seeds.rake new file mode 100644 index 000000000..6989b1299 --- /dev/null +++ b/lib/tasks/test_seeds.rake @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative './seeds_helper' + +namespace :test_seeds do + include SeedsHelper + + desc 'Destroy existing data' + task destroy: :environment do + ActiveRecord::Base.transaction do + Rails.logger.info 'Destroying existing seeds...' + creator_id = ENV.fetch('http://github.com/SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) + teacher_id = ENV.fetch('http://github.com/SEEDING_TEACHER_ID', TEST_USERS[:john_doe]) + + # Hard coded as the student's school needs to match + student_ids = [TEST_USERS[:jane_smith], TEST_USERS[:john_smith]] + school_id = TEST_SCHOOL + + # Remove the roles first + Role.where(user_id: [creator_id, teacher_id] + student_ids).destroy_all + + # Destroy the project and then the lesson itself (The lesson's `before_destroy` prevents us using destroy) + lesson_ids = Lesson.where(school_id:).pluck(:id) + Project.where(lesson_id: [lesson_ids]).destroy_all + Lesson.where(id: [lesson_ids]).delete_all + + # Destroy the class members and then the class itself + school_class_ids = SchoolClass.where(school_id:).pluck(:id) + ClassStudent.where(school_class_id: [school_class_ids]).destroy_all + SchoolClass.where(id: [school_class_ids]).destroy_all + + # Destroy the school + School.find(school_id).destroy + + Rails.logger.info 'Done...' + rescue StandardError => e + Rails.logger.error "Failed: #{e.message}" + raise ActiveRecord::Rollback + end + end + + desc 'Create a school with lessons and students' + task create: :environment do + if School.find_by(code: TEST_SCHOOL) + puts "Test school (#{TEST_SCHOOL}) already exists, run the destroy_seed_data task to start over)." + return + end + + ActiveRecord::Base.transaction do + Rails.logger.info 'Attempting to seed data...' + creator_id = ENV.fetch('http://github.com/SEEDING_CREATOR_ID', TEST_USERS[:jane_doe]) + teacher_id = ENV.fetch('http://github.com/SEEDING_TEACHER_ID', TEST_USERS[:john_doe]) + + school = create_school(creator_id, TEST_SCHOOL) + verify_school(school) + assign_a_teacher(teacher_id, school) + + # for each of the owner and teacher, create a class and assign students + [creator_id, teacher_id].each do |user_id| + teacher_name = user_id == creator_id ? 'Jane Doe' : 'John Doe' + school_class = create_school_class(user_id, school, "#{teacher_name}'s Class", "A class for #{teacher_name}'s students") + assign_students(school_class, school) + + lessons = create_lessons(user_id, school, school_class) + lessons.each do |lesson| + create_project(user_id, school, lesson, 'print("Hello World!")') + end + end + Rails.logger.info 'Done...' + rescue StandardError => e + Rails.logger.error "Failed: #{e.message}" + raise ActiveRecord::Rollback + end + end +end diff --git a/lib/tasks/upload_task.rake b/lib/tasks/upload_task.rake new file mode 100644 index 000000000..d18975ca6 --- /dev/null +++ b/lib/tasks/upload_task.rake @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +return unless Rails.env.development? + +namespace :upload_job_test do + desc 'Test trigger the UploadJob' + task trigger: :environment do + # Paste in a payload from the GitHub webhook (https://github.com/organizations/raspberrypilearning/settings/hooks) + payload = {} + + abort('Stopping as no payload was provided (expects a payload from the GitHub webhook: https://github.com/organizations/raspberrypilearning/settings/hooks)') if payload.blank? + + if edited_code?(payload) + UploadJob.perform_now(payload) + else + abort('Stopping as nothing under `/code` was edited in the push') + end + end + + def edited_code?(payload) + commits = payload[:commits] + modified_paths = commits.map { |commit| commit[:added] | commit[:modified] | commit[:removed] }.flatten + modified_paths.count { |path| path.split('/')[1] == 'code' }.positive? + end +end diff --git a/lib/user_info_api_client.rb b/lib/user_info_api_client.rb new file mode 100644 index 000000000..25a84c826 --- /dev/null +++ b/lib/user_info_api_client.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class UserInfoApiClient + API_URL = ENV.fetch('http://github.com/USERINFO_API_URL', 'http://localhost:6000/') + API_KEY = ENV.fetch('http://github.com/USERINFO_API_KEY', '1234') + + class << self + def fetch_by_ids(user_ids) + return [] if user_ids.blank? + return stubbed_users(user_ids) if bypass_oauth? + + response = conn.get do |r| + r.url '/users' + r.body = { userIds: user_ids } + end + return if response.body.blank? + + transform_result(response.body.fetch('http://github.com/users', [])) + end + + private + + def bypass_oauth? + ENV.fetch('http://github.com/BYPASS_OAUTH', nil) == 'true' + end + + def transform_result(result) + { result: }.transform_keys { |k| k.to_s.underscore.to_sym }.fetch(:result) + end + + def conn + Faraday.new( + headers: { authorization: "Bearer #{API_KEY}" }, + url: API_URL + ) do |f| + f.request :instrumentation + f.request :json # encode req bodies as JSON + f.response :raise_error + f.response :json # decode response bodies as JSON + end + end + + def stubbed_users(user_ids) + user_ids.map do |user_id| + { + id: user_id, + email: "user-#{user_id}@example.com", + username: nil, + parentalEmail: nil, + name: 'School Owner', + nickname: 'Owner', + country: 'United Kingdom', + country_code: 'GB', + postcode: nil, + dateOfBirth: nil, + verifiedAt: '2024-01-01T12:00:00.000Z', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', + discardedAt: nil, + lastLoggedInAt: '2024-01-01T12:00:00.000Z', + roles: '' + } + end + end + end +end diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 9c03de995..000000000 --- a/public/index.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - -
Loading...
- - - - diff --git a/spec/concepts/class_member/create_spec.rb b/spec/concepts/class_member/create_spec.rb new file mode 100644 index 000000000..aa36d24a4 --- /dev/null +++ b/spec/concepts/class_member/create_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassMember::Create, type: :unit do + let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:students) { create_list(:student, 3, school:) } + let(:teacher) { create(:teacher, school:) } + let(:teachers) { [create(:teacher, school:), create(:teacher, school:)] } + + let(:student_ids) { students.map(&:id) } + + it 'returns a successful operation response' do + response = described_class.call(school_class:, students:, teachers:) + expect(response.success?).to be(true) + end + + it 'creates class students' do + expect { described_class.call(school_class:, students:, teachers:) }.to change(ClassStudent, :count).by(3) + end + + it 'creates class teachers' do + expect { described_class.call(school_class:, students:, teachers:) }.to change(ClassTeacher, :count).by(2) + end + + it 'returns a class members JSON array' do + response = described_class.call(school_class:, students:, teachers:) + expect(response[:class_members].size).to eq(5) + end + + it 'returns class students in the operation response' do + response = described_class.call(school_class:, students:, teachers:) + class_students_count = response[:class_members].count { |member| member.is_a?(ClassStudent) } + expect(class_students_count).to eq(3) + end + + it 'returns class teachers in the operation response' do + response = described_class.call(school_class:, students:, teachers:) + class_teachers_count = response[:class_members].count { |member| member.is_a?(ClassTeacher) } + expect(class_teachers_count).to eq(2) + end + + it 'assigns the school_class' do + response = described_class.call(school_class:, students:) + expect(response[:class_members]).to all(have_attributes(school_class:)) + end + + it 'assigns the student_id' do + response = described_class.call(school_class:, students:, teachers:) + response_students = response[:class_members].select { |member| member.is_a?(ClassStudent) } + expect(response_students.map(&:student_id)).to match_array(student_ids) + end + + it 'assigns the teacher_id' do + teacher_ids = teachers.map(&:id) + response = described_class.call(school_class:, students:, teachers:) + response_teachers = response[:class_members].select { |member| member.is_a?(ClassTeacher) } + expect(response_teachers.map(&:teacher_id)).to match_array(teacher_ids) + end + + context 'when creations fail' do + before do + allow(Sentry).to receive(:capture_exception) + end + + context 'with malformed members' do + let(:students) { nil } + let(:teachers) { nil } + + it 'does not create a class student' do + expect { described_class.call(school_class:, students:, teachers:) }.not_to change(ClassStudent, :count) + end + + it 'does not create a class teacher' do + expect { described_class.call(school_class:, students:, teachers:) }.not_to change(ClassTeacher, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(school_class:, students:, teachers:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_class:, students:, teachers:) + expect(response[:error]).to match(/No valid school members provided/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, students:, teachers:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'with a student from a different school' do + let(:different_school_student) { create(:student, school: create(:school)) } + + context 'with non existent students' do + let(:students) { [different_school_student] } + + it 'does not create a class member' do + expect { described_class.call(school_class:, students:) }.not_to change(ClassStudent, :count) + end + + it 'returns a successful operation response' do + response = described_class.call(school_class:, students:) + expect(response.success?).to be(true) + end + + it 'returns an empty class members array' do + response = described_class.call(school_class:, students:) + expect(response[:class_members]).to eq([]) + end + + it 'returns the error messages in the operation response' do + response = described_class.call(school_class:, students:) + expect(response[:errors][different_school_student.id]).to include("Error creating class member for student_id #{different_school_student.id}: Student '#{different_school_student.id}' does not have the 'school-student' role for organisation '#{school.id}'") + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, students:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when one creation fails' do + let(:new_students) { students + [different_school_student] } + + it 'returns a successful operation response' do + response = described_class.call(school_class:, students: new_students, teachers:) + expect(response.success?).to be(true) + end + + it 'returns a class members JSON array' do + response = described_class.call(school_class:, students: new_students, teachers:) + expect(response[:class_members].size).to eq(5) + end + + it 'returns class students in the operation response' do + response = described_class.call(school_class:, students: new_students, teachers:) + class_students_count = response[:class_members].count { |member| member.is_a?(ClassStudent) } + expect(class_students_count).to eq(3) + end + + it 'returns class teachers in the operation response' do + response = described_class.call(school_class:, students: new_students, teachers:) + class_teachers_count = response[:class_members].count { |member| member.is_a?(ClassTeacher) } + expect(class_teachers_count).to eq(2) + end + + it 'assigns the school_class' do + response = described_class.call(school_class:, students: new_students, teachers:) + expect(response[:class_members]).to all(have_attributes(school_class:)) + end + + it 'assigns the successful students' do + response = described_class.call(school_class:, students: new_students, teachers:) + response_students = response[:class_members].select { |member| member.is_a?(ClassStudent) } + expect(response_students.map(&:student_id)).to match_array(student_ids) + end + + it 'assigns the successful teachers' do + teacher_ids = teachers.map(&:id) + response = described_class.call(school_class:, students: new_students, teachers:) + response_teachers = response[:class_members].select { |member| member.is_a?(ClassTeacher) } + expect(response_teachers.map(&:teacher_id)).to match_array(teacher_ids) + end + + it 'returns the error messages in the operation response' do + response = described_class.call(school_class:, students: new_students, teachers:) + expect(response[:errors][different_school_student.id]).to eq("Error creating class member for student_id #{different_school_student.id}: Student '#{different_school_student.id}' does not have the 'school-student' role for organisation '#{school.id}'") + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, students: new_students, teachers:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + end + end +end diff --git a/spec/concepts/class_member/delete_spec.rb b/spec/concepts/class_member/delete_spec.rb new file mode 100644 index 000000000..3d87ff4fa --- /dev/null +++ b/spec/concepts/class_member/delete_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassMember::Delete, type: :unit do + let!(:class_member) { create(:class_student, student_id: student.id, school_class:) } + let(:class_member_id) { class_member.id } + let(:school_class) { build(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + + it 'returns a successful operation response' do + response = described_class.call(school_class:, class_member_id:) + expect(response.success?).to be(true) + end + + it 'deletes a class member' do + expect { described_class.call(school_class:, class_member_id:) }.to change(ClassStudent, :count).by(-1) + end + + context 'when deletion fails' do + let(:class_member_id) { 'does-not-exist' } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school_class:, class_member_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_class:, class_member_id:) + expect(response[:error]).to match(/does-not-exist/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, class_member_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/class_member/list_spec.rb b/spec/concepts/class_member/list_spec.rb new file mode 100644 index 000000000..1a84271fb --- /dev/null +++ b/spec/concepts/class_member/list_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassMember::List, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:students) { create_list(:student, 3, school:) } + let(:teacher) { create(:teacher, school:) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:class_students) { school_class.students } + + let(:student_ids) { students.map(&:id) } + let(:teacher_ids) { [teacher.id] } + + context 'with students and a teacher' do + before do + student_ids.each do |student_id| + create(:class_student, school_class:, student_id:) + end + + student_attributes = students.map do |student| + { id: student.id, name: student.name, username: student.username } + end + stub_profile_api_list_school_students(school:, student_attributes:) + stub_user_info_api_for(teacher) + end + + it 'returns a successful operation response' do + response = described_class.call(school_class:, class_students:, token:) + expect(response.success?).to be(true) + end + + it 'returns class members in the operation response' do + response = described_class.call(school_class:, class_students:, token:) + expect(response[:class_members].count { |member| member.is_a?(ClassStudent) }).to eq(3) + end + + it 'returns the teacher in the operation response' do + response = described_class.call(school_class:, class_students:, token:) + expect(response[:class_members].count { |member| member.is_a?(User) }).to eq(1) + end + + it 'contains the expected students' do + response = described_class.call(school_class:, class_students:, token:) + class_students.each do |class_student| + expect(response[:class_members].map(&:id)).to include(class_student.id) + end + end + + it 'contains the expected teacher' do + response = described_class.call(school_class:, class_students:, token:) + expect(response[:class_members].map(&:id)).to include(teacher.id) + end + end + + context 'when errors occur' do + before do + allow(Sentry).to receive(:capture_exception) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'captures and handles errors' do + allow(SchoolStudent::List).to receive(:call).and_raise(StandardError.new('forced error')) + + response = described_class.call(school_class:, class_students:, token:) + + expect(response[:error]).to eq('Error listing class members: forced error') + expect(Sentry).to have_received(:capture_exception).with(instance_of(StandardError)) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'returns an empty array when no ids match' do + allow(SchoolStudent::List).to receive(:call).and_return({ school_students: [] }) + allow(SchoolTeacher::List).to receive(:call).and_return({ school_teachers: [] }) + + response = described_class.call(school_class:, class_students:, token:) + + expect(response[:class_members]).to eq([]) + end + end +end diff --git a/spec/concepts/lesson/archive_spec.rb b/spec/concepts/lesson/archive_spec.rb new file mode 100644 index 000000000..14113f6b0 --- /dev/null +++ b/spec/concepts/lesson/archive_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Archive, type: :unit do + let(:lesson) { create(:lesson) } + + it 'returns a successful operation response' do + response = described_class.call(lesson:) + expect(response.success?).to be(true) + end + + it 'archives the lesson' do + described_class.call(lesson:) + expect(lesson.reload.archived?).to be(true) + end +end diff --git a/spec/concepts/lesson/create_copy_spec.rb b/spec/concepts/lesson/create_copy_spec.rb new file mode 100644 index 000000000..23c51d991 --- /dev/null +++ b/spec/concepts/lesson/create_copy_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::CreateCopy, type: :unit do + let(:teacher_id) { SecureRandom.uuid } + + let!(:lesson) do + create(:lesson, :with_project_components, :with_project_image, name: 'Test Lesson', description: 'Description', user_id: teacher_id) + end + + let(:lesson_params) do + { user_id: teacher_id } + end + + it 'returns a successful operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.success?).to be(true) + end + + it 'creates a lesson' do + expect { described_class.call(lesson:, lesson_params:) }.to change(Lesson, :count).by(1) + end + + it 'returns the new lesson in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson]).to be_a(Lesson) + end + + it 'assigns the parent' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].parent).to eq(lesson) + end + + it 'assigns the name from the parent lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].name).to eq('Test Lesson') + end + + it 'assigns the description from the parent lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].description).to eq('Description') + end + + it 'can specify the name of the new copy' do + new_params = lesson_params.merge(name: 'New Name') + response = described_class.call(lesson:, lesson_params: new_params) + expect(response[:lesson].name).to eq('New Name') + end + + it 'can specify the description of the new copy' do + new_params = lesson_params.merge(description: 'New Description') + response = described_class.call(lesson:, lesson_params: new_params) + expect(response[:lesson].description).to eq('New Description') + end + + it 'creates a new project' do + expect { described_class.call(lesson:, lesson_params:) }.to change(Project, :count).by(1) + end + + it 'changes the project identifier' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].project.identifier).not_to eq(lesson.project.identifier) + end + + it 'generates a new project identifier' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].project.identifier).to be_present + end + + it 'gives the project the correct name' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].project.name).to eq(lesson.project.name) + end + + it 'gives the project the correct user_id' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].project.user_id).to eq(teacher_id) + end + + it 'gives the project the correct lesson_id' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].project.lesson_id).to eq(response[:lesson].id) + end + + it 'copies the images from the parent project' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].project.images.length).to eq(lesson.project.images.length) + end + + it 'copies the components from the parent project with the correct name' do + original_component = lesson.project.components.first + response = described_class.call(lesson:, lesson_params:) + copied_component = response[:lesson].project.components.first + expect(copied_component.name).to eq(original_component.name) + end + + it 'copies the components from the parent project with the correct extension' do + original_component = lesson.project.components.first + response = described_class.call(lesson:, lesson_params:) + copied_component = response[:lesson].project.components.first + expect(copied_component.extension).to eq(original_component.extension) + end + + it 'copies the components from the parent project with the correct content' do + original_component = lesson.project.components.first + response = described_class.call(lesson:, lesson_params:) + copied_component = response[:lesson].project.components.first + expect(copied_component.content).to eq(original_component.content) + end + + context 'when creating a copy fails' do + let(:lesson_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a lesson' do + expect { described_class.call(lesson:, lesson_params:) }.not_to change(Lesson, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:error]).to match(/Error creating copy of lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson:, lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/lesson/create_spec.rb b/spec/concepts/lesson/create_spec.rb new file mode 100644 index 000000000..c9d6b073c --- /dev/null +++ b/spec/concepts/lesson/create_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Create, type: :unit do + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + + let(:lesson_params) do + { + name: 'Test Lesson', + user_id: teacher.id, + school_id: school.id, + project_attributes: { + name: 'Hello world project', + project_type: Project::Types::PYTHON, + components: [ + { name: 'main.py', extension: 'py', content: 'print("Hello, world!")' } + ] + } + } + end + + context 'when a teacher' do + before do + allow(User).to receive(:from_userinfo).with(ids: teacher.id).and_return([teacher]) + end + + it 'returns a successful operation response' do + response = described_class.call(lesson_params:) + expect(response.success?).to be(true) + end + + it 'creates a lesson' do + expect { described_class.call(lesson_params:) }.to change(Lesson, :count).by(1) + end + + it 'returns the lesson in the operation response' do + response = described_class.call(lesson_params:) + expect(response[:lesson]).to be_a(Lesson) + end + + it 'assigns the name' do + response = described_class.call(lesson_params:) + expect(response[:lesson].name).to eq('Test Lesson') + end + + it 'assigns the user_id' do + response = described_class.call(lesson_params:) + expect(response[:lesson].user_id).to eq(teacher.id) + end + + it 'assigns the school_id' do + response = described_class.call(lesson_params:) + expect(response[:lesson].school_id).to eq(school.id) + end + + it 'creates a project for the lesson' do + expect { described_class.call(lesson_params:) }.to change(Project, :count).by(1) + end + + it 'associates the project to the lesson' do + response = described_class.call(lesson_params:) + expect(response[:lesson].project).to be_a(Project) + end + + it 'assigns the user id to the project' do + response = described_class.call(lesson_params:) + expect(response[:lesson].project.user_id).to eq(response[:lesson].user_id) + end + + it 'assigns the school id to the project' do + response = described_class.call(lesson_params:) + expect(response[:lesson].project.school_id).to eq(response[:lesson].school_id) + end + + it 'assigns the lesson id to the project' do + response = described_class.call(lesson_params:) + expect(response[:lesson].project.lesson_id).to eq(response[:lesson].id) + end + end + + context 'when lesson creation fails' do + let(:lesson_params) do + { + project_attributes: { + name: 'Hello world project', + project_type: Project::Types::PYTHON, + components: [ + { name: 'main.py', extension: 'py', content: 'print("Hello, world!")' } + ] + } + } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a lesson' do + expect { described_class.call(lesson_params:) }.not_to change(Lesson, :count) + end + + it 'does not create a project' do + expect { described_class.call(lesson_params:) }.not_to change(Project, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson_params:) + expect(response[:error]).to match(/Error creating lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when project creation fails' do + let(:lesson_params) do + { + name: 'Test Lesson', + project_attributes: { + invalid_attribute: 'blah blah blah' + } + } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a lesson' do + expect { described_class.call(lesson_params:) }.not_to change(Lesson, :count) + end + + it 'does not create a project' do + expect { described_class.call(lesson_params:) }.not_to change(Project, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson_params:) + expect(response[:error]).to match(/Error creating lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/lesson/unarchive_spec.rb b/spec/concepts/lesson/unarchive_spec.rb new file mode 100644 index 000000000..99dac2333 --- /dev/null +++ b/spec/concepts/lesson/unarchive_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Unarchive, type: :unit do + let(:lesson) { create(:lesson, archived_at: Time.now.utc) } + + it 'returns a successful operation response' do + response = described_class.call(lesson:) + expect(response.success?).to be(true) + end + + it 'unarchives the lesson' do + described_class.call(lesson:) + expect(lesson.reload.archived?).to be(false) + end +end diff --git a/spec/concepts/lesson/update_spec.rb b/spec/concepts/lesson/update_spec.rb new file mode 100644 index 000000000..fb1d6c5ee --- /dev/null +++ b/spec/concepts/lesson/update_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Update, type: :unit do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + let(:lesson) { create(:lesson, name: 'Test Lesson', user_id: teacher.id) } + let!(:student_project) { create(:project, remixed_from_id: lesson.project.id, user_id: student.id) } + + let(:lesson_params) do + { name: 'New Name' } + end + + it 'returns a successful operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.success?).to be(true) + end + + it 'updates the lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].name).to eq('New Name') + end + + it 'updates the project name' do + described_class.call(lesson:, lesson_params:) + expect(lesson.project.name).to eq('New Name') + end + + it 'updates the student project name' do + described_class.call(lesson:, lesson_params:) + expect(student_project.reload.name).to eq('New Name') + end + + it 'returns the lesson in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson]).to be_a(Lesson) + end + + context 'when updating fails' do + let(:lesson_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not update the lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].reload.name).to eq('Test Lesson') + end + + it 'returns a failed operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:error]).to match(/Error updating lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson:, lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/project/create_remix_spec.rb b/spec/concepts/project/create_remix_spec.rb index 21cd21d65..035cf65b9 100644 --- a/spec/concepts/project/create_remix_spec.rb +++ b/spec/concepts/project/create_remix_spec.rb @@ -3,10 +3,11 @@ require 'rails_helper' RSpec.describe Project::CreateRemix, type: :unit do - subject(:create_remix) { described_class.call(params: remix_params, user_id:, original_project:) } + subject(:create_remix) { described_class.call(params: remix_params, user_id:, original_project:, remix_origin:) } + let(:remix_origin) { 'editor.com' } let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } - let!(:original_project) { create(:project, :with_components, :with_attached_image) } + let!(:original_project) { create(:project, :with_components, :with_attached_image, :with_attached_video, :with_attached_audio) } let(:remix_params) do component = original_project.components.first { @@ -68,19 +69,39 @@ expect(remixed_attrs).to eq(original_attrs) end + it 'sets remix origin' do + remixed_project = create_remix[:project] + expect(remixed_project.remix_origin).to eq(remix_origin) + end + it 'links remix to attached images' do remixed_project = create_remix[:project] expect(remixed_project.images.length).to eq(original_project.images.length) end - it 'creates a new attachment' do - expect { create_remix }.to change(ActiveStorage::Attachment, :count).by(1) + it 'links remix to attached videos' do + remixed_project = create_remix[:project] + expect(remixed_project.videos.length).to eq(original_project.videos.length) + end + + it 'links remix to attached audio' do + remixed_project = create_remix[:project] + expect(remixed_project.audio.length).to eq(original_project.audio.length) + end + + it 'creates new attachments' do + expect { create_remix }.to change(ActiveStorage::Attachment, :count).by(3) end - it 'does not create a new image' do + it 'does not create new media' do expect { create_remix }.not_to change(ActiveStorage::Blob, :count) end + it 'does not copy the lesson id' do + remixed_project = create_remix[:project] + expect(remixed_project.lesson_id).to be_nil + end + it 'creates new components' do expect { create_remix }.to change(Component, :count).by(1) end @@ -127,7 +148,7 @@ end context 'when original project is not present' do - subject(:create_remix) { described_class.call(params: remix_params, user_id:, original_project: nil) } + subject(:create_remix) { described_class.call(params: remix_params, user_id:, original_project: nil, remix_origin:) } it 'returns failure' do result = create_remix diff --git a/spec/concepts/project/create_spec.rb b/spec/concepts/project/create_spec.rb index aa7d35019..519b7d788 100644 --- a/spec/concepts/project/create_spec.rb +++ b/spec/concepts/project/create_spec.rb @@ -3,9 +3,10 @@ require 'rails_helper' RSpec.describe Project::Create, type: :unit do - subject(:create_project) { described_class.call(project_hash:) } + subject(:create_project) { described_class.call(project_hash:, current_user:) } - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } + let(:current_user) { create(:user) } + let(:user_id) { current_user.id } before do mock_phrase_generation @@ -16,18 +17,17 @@ let(:project_hash) { ActionController::Parameters.new({}).merge(user_id:) } context 'with valid content' do - subject(:create_project_with_content) { described_class.call(project_hash:) } + subject(:create_project_with_content) { described_class.call(project_hash:, current_user:) } let(:project_hash) do { - project_type: 'python', + project_type: Project::Types::PYTHON, components: [{ name: 'main', extension: 'py', content: 'print("hello world")', default: true }], - image_list: [], user_id: } end @@ -55,7 +55,7 @@ end it 'returns error message' do - expect(create_project[:error]).to eq('Error creating project') + expect(create_project[:error]).to eq('Error creating project: Some error') end it 'sent the exception to Sentry' do diff --git a/spec/concepts/project/update_default_component_spec.rb b/spec/concepts/project/update_default_component_spec.rb index 5960fcc61..6685977ca 100644 --- a/spec/concepts/project/update_default_component_spec.rb +++ b/spec/concepts/project/update_default_component_spec.rb @@ -3,8 +3,9 @@ require 'rails_helper' RSpec.describe Project::Update, type: :unit do - subject(:update) { described_class.call(project:, update_hash:) } + subject(:update) { described_class.call(project:, update_hash:, current_user:) } + let(:current_user) { create(:user) } let!(:project) { create(:project, :with_default_component) } let(:default_component) { project.components.first } diff --git a/spec/concepts/project/update_delete_components_spec.rb b/spec/concepts/project/update_delete_components_spec.rb index 18a1aadb0..0fb64af55 100644 --- a/spec/concepts/project/update_delete_components_spec.rb +++ b/spec/concepts/project/update_delete_components_spec.rb @@ -3,8 +3,9 @@ require 'rails_helper' RSpec.describe Project::Update, type: :unit do - subject(:update) { described_class.call(project:, update_hash:) } + subject(:update) { described_class.call(project:, update_hash:, current_user:) } + let(:current_user) { create(:user) } let!(:project) { create(:project, :with_default_component, :with_components) } let(:component_to_delete) { project.components.last } let(:default_component) { project.components.first } diff --git a/spec/concepts/project/update_invalid_spec.rb b/spec/concepts/project/update_invalid_spec.rb index 7c2560f56..84a825140 100644 --- a/spec/concepts/project/update_invalid_spec.rb +++ b/spec/concepts/project/update_invalid_spec.rb @@ -8,9 +8,10 @@ name: 'updated project name', components: [default_component_hash, edited_component_hash, new_component_hash] } - described_class.call(project:, update_hash:) + described_class.call(project:, update_hash:, current_user:) end + let(:current_user) { create(:user) } let!(:project) { create(:project, :with_default_component, :with_components, component_count: 2) } let(:editable_component) { project.components.last } let(:default_component) { project.components.first } diff --git a/spec/concepts/project/update_spec.rb b/spec/concepts/project/update_spec.rb index 4598e578e..e30d0035e 100644 --- a/spec/concepts/project/update_spec.rb +++ b/spec/concepts/project/update_spec.rb @@ -6,14 +6,18 @@ subject(:update) do update_hash = { name: 'updated project name', - components: component_hash + components: component_hash, + instructions: } - described_class.call(project:, update_hash:) + described_class.call(project:, update_hash:, current_user:) end + let(:current_user) { create(:user) } let!(:project) { create(:project, :with_default_component, :with_components) } let(:editable_component) { project.components.last } let(:default_component) { project.components.first } + let(:component_hash) { project.components.map { |component| hash(component) } } + let(:instructions) { project.instructions } describe '.call' do let(:edited_component_hash) do @@ -76,6 +80,60 @@ expect { update }.to change { project.reload.name }.to('updated project name') end end + + context 'when updating the instructions if project does not belong to a school' do + let(:instructions) { 'new instructions' } + + it 'returns success? false' do + expect(update.success?).to be(false) + end + + it 'updates project instructions' do + expect { update }.not_to change { project.reload.instructions } + end + + it 'returns an error message' do + expect(update[:error]).to match(/Projects with instructions must belong to a school/) + end + end + + context 'when the instructions have changed and the current user is a teacher' do + let(:school) { create(:school) } + let!(:current_user) { create(:teacher, school:) } + let!(:project) { create(:project, :with_instructions, school:, user_id: current_user.id) } + let(:instructions) { 'new instructions' } + + it 'returns success? true' do + expect(update.success?).to be(true) + end + + it 'updates project instructions' do + expect { update }.to change { project.reload.instructions }.to('new instructions') + end + end + + context 'when the instructions have changed and the current user is a student' do + let(:school) { create(:school) } + let!(:current_user) { create(:student, school:) } + let!(:project) { create(:project, :with_instructions, school:, user_id: current_user.id) } + let(:instructions) { 'new instructions' } + + it 'returns success? false' do + expect(update.success?).to be(false) + end + + it 'does not update project name' do + expect { update }.not_to change { project.reload.name } + end + + it 'does not update project instructions' do + expect { update }.not_to change { project.reload.instructions } + end + + it 'returns an error message' do + expect(update[:error]).to eq('Student cannot update project instructions') + end + end end def component_properties_hash(component) @@ -86,6 +144,15 @@ def component_properties_hash(component) ) end + def hash(component) + component.attributes.symbolize_keys.slice( + :id, + :name, + :content, + :extension + ) + end + def default_component_hash default_component.attributes.symbolize_keys.slice( :id, diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb new file mode 100644 index 000000000..26a5226dc --- /dev/null +++ b/spec/concepts/school/create_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School::Create, type: :unit do + let(:school_params) do + { + name: 'Test School', + website: 'http://www.example.com/', + address_line_1: 'Address Line 1', + municipality: 'Greater London', + country_code: 'GB', + creator_agree_authority: true, + creator_agree_terms_and_conditions: true, + creator_agree_to_ux_contact: true, + creator_agree_responsible_safeguarding: true + } + end + + let(:token) { UserProfileMock::TOKEN } + let(:creator_id) { SecureRandom.uuid } + + it 'returns a successful operation response' do + response = described_class.call(school_params:, creator_id:) + expect(response.success?).to be(true) + end + + it 'creates a school' do + expect { described_class.call(school_params:, creator_id:) }.to change(School, :count).by(1) + end + + it 'returns the school in the operation response' do + response = described_class.call(school_params:, creator_id:) + expect(response[:school]).to be_a(School) + end + + it 'assigns the name' do + response = described_class.call(school_params:, creator_id:) + expect(response[:school].name).to eq('Test School') + end + + it 'assigns the creator_id' do + response = described_class.call(school_params:, creator_id:) + expect(response[:school].creator_id).to eq(creator_id) + end + + context 'when creation fails' do + let(:school_params) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a school' do + expect { described_class.call(school_params:, creator_id:) }.not_to change(School, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(school_params:, creator_id:) + expect(response.failure?).to be(true) + end + + it 'returns the correct number of objects in the operation response' do + response = described_class.call(school_params:, creator_id:) + expect(response[:error].count).to eq(9) + end + + it 'returns the correct type of object in the operation response' do + response = described_class.call(school_params:, creator_id:) + expect(response[:error].first).to be_a(ActiveModel::Error) + end + + it 'sent the exception to Sentry' do + described_class.call(school_params:, creator_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school/delete_spec.rb b/spec/concepts/school/delete_spec.rb new file mode 100644 index 000000000..cdc102f85 --- /dev/null +++ b/spec/concepts/school/delete_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School::Delete, type: :unit do + before do + create(:class_student, student_id: student.id, school_class:) + end + + let(:school_class) { build(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:school_id) { school.id } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + + it 'returns a successful operation response' do + response = described_class.call(school_id:) + expect(response.success?).to be(true) + end + + it 'deletes a school' do + expect { described_class.call(school_id:) }.to change(School, :count).by(-1) + end + + it 'deletes a school classes in the school' do + expect { described_class.call(school_id:) }.to change(SchoolClass, :count).by(-1) + end + + it 'deletes class students in the school' do + expect { described_class.call(school_id:) }.to change(ClassStudent, :count).by(-1) + end + + it 'deletes class teachers in the school' do + expect { described_class.call(school_id:) }.to change(ClassTeacher, :count).by(-1) + end + + context 'when deletion fails' do + let(:school_id) { 'does-not-exist' } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_id:) + expect(response[:error]).to match(/does-not-exist/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school/update_spec.rb b/spec/concepts/school/update_spec.rb new file mode 100644 index 000000000..74c77db5e --- /dev/null +++ b/spec/concepts/school/update_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School::Update, type: :unit do + let(:school) { create(:school, name: 'Test School Name') } + let(:school_params) { { name: 'New Name' } } + + it 'returns a successful operation response' do + response = described_class.call(school:, school_params:) + expect(response.success?).to be(true) + end + + it 'updates the school' do + response = described_class.call(school:, school_params:) + expect(response[:school].name).to eq('New Name') + end + + it 'returns the school in the operation response' do + response = described_class.call(school:, school_params:) + expect(response[:school]).to be_a(School) + end + + context 'when updating fails' do + let(:school_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not update the school' do + response = described_class.call(school:, school_params:) + expect(response[:school].reload.name).to eq('Test School Name') + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_params:) + expect(response[:error]).to match(/Error updating school/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_class/create_spec.rb b/spec/concepts/school_class/create_spec.rb new file mode 100644 index 000000000..4a16a84e3 --- /dev/null +++ b/spec/concepts/school_class/create_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass::Create, type: :unit do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + + let(:school_class_params) do + { name: 'Test School Class' } + end + + it 'returns a successful operation response' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response.success?).to be(true) + end + + it 'creates a school class' do + expect { described_class.call(school:, school_class_params:, current_user: teacher) }.to change(SchoolClass, :count).by(1) + end + + it 'returns the school class in the operation response' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response[:school_class]).to be_a(SchoolClass) + end + + it 'assigns the school' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response[:school_class].school).to eq(school) + end + + it 'assigns the name' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response[:school_class].name).to eq('Test School Class') + end + + it 'assigns the teacher' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response[:school_class].teacher_ids).to eq([teacher.id]) + end + + context 'when creation fails' do + let(:school_class_params) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a school class' do + expect { described_class.call(school:, school_class_params:, current_user: teacher) }.not_to change(SchoolClass, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_class_params:, current_user: teacher) + expect(response[:error]).to match(/Error creating school class/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_class_params:, current_user: teacher) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_class/delete_spec.rb b/spec/concepts/school_class/delete_spec.rb new file mode 100644 index 000000000..347906f92 --- /dev/null +++ b/spec/concepts/school_class/delete_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass::Delete, type: :unit do + before do + create(:class_student, student_id: student.id, school_class:) + end + + let(:school_class) { build(:school_class, teacher_ids: [teacher.id], school:) } + let(:school_class_id) { school_class.id } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + + it 'returns a successful operation response' do + response = described_class.call(school:, school_class_id:) + expect(response.success?).to be(true) + end + + it 'deletes a school class' do + expect { described_class.call(school:, school_class_id:) }.to change(SchoolClass, :count).by(-1) + end + + it 'deletes class students in the school class' do + expect { described_class.call(school:, school_class_id:) }.to change(ClassStudent, :count).by(-1) + end + + it 'deletes class teachers in the school class' do + expect { described_class.call(school:, school_class_id:) }.to change(ClassTeacher, :count).by(-1) + end + + context 'when deletion fails' do + let(:school_class_id) { 'does-not-exist' } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_class_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_class_id:) + expect(response[:error]).to match(/does-not-exist/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_class_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_class/update_spec.rb b/spec/concepts/school_class/update_spec.rb new file mode 100644 index 000000000..5ce9011ab --- /dev/null +++ b/spec/concepts/school_class/update_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass::Update, type: :unit do + let(:school) { create(:school) } + let(:school_class) { create(:school_class, name: 'Test School Class Name', teacher_ids: [teacher.id], school:) } + let(:school_class_params) { { name: 'New Name' } } + let(:teacher) { create(:teacher, school:) } + + it 'returns a successful operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response.success?).to be(true) + end + + it 'updates the school class' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:school_class].name).to eq('New Name') + end + + it 'returns the school class in the operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:school_class]).to be_a(SchoolClass) + end + + context 'when updating fails' do + let(:school_class_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not update the school class' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:school_class].reload.name).to eq('Test School Class Name') + end + + it 'returns a failed operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:error]).to match(/Error updating school/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, school_class_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_member/list_spec.rb b/spec/concepts/school_member/list_spec.rb new file mode 100644 index 000000000..d7dd4b628 --- /dev/null +++ b/spec/concepts/school_member/list_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolMember::List, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:students) { create_list(:student, 3, school:) } + let(:teacher) { create(:teacher, school:) } + + let(:student_ids) { students.map(&:id) } + let(:teacher_ids) { [teacher.id] } + + context 'with students and a teacher' do + before do + student_attributes = students.map do |student| + { id: student.id, name: student.name, username: student.username } + end + stub_profile_api_list_school_students(school:, student_attributes:) + stub_user_info_api_for(teacher) + end + + it 'returns a successful operation response' do + response = described_class.call(school:, token:) + expect(response.success?).to be(true) + end + + it 'contains the expected students' do + response = described_class.call(school:, token:) + students.each do |student| + expect(response[:school_members].map(&:id)).to include(student.id) + end + end + + it 'contains the expected teacher' do + response = described_class.call(school:, token:) + expect(response[:school_members].map(&:id)).to include(teacher.id) + end + end + + context 'when errors occur' do + before do + allow(Sentry).to receive(:capture_exception) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'captures and handles errors' do + allow(SchoolStudent::List).to receive(:call).and_raise(StandardError.new('forced error')) + + response = described_class.call(school:, token:) + + expect(response[:error]).to eq('Error listing school members: forced error') + expect(Sentry).to have_received(:capture_exception).with(instance_of(StandardError)) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'returns an empty array when no ids match' do + allow(SchoolStudent::List).to receive(:call).and_return({ school_students: [] }) + allow(SchoolTeacher::List).to receive(:call).and_return({ school_teachers: [] }) + + response = described_class.call(school:, token:) + + expect(response[:school_members]).to eq([]) + end + end +end diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb new file mode 100644 index 000000000..cf29320a2 --- /dev/null +++ b/spec/concepts/school_owner/invite_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwner::Invite, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:verified_school) } + let(:owner_id) { SecureRandom.uuid } + + let(:school_owner_params) do + { email_address: 'owner-to-invite@example.com' } + end + + before do + stub_profile_api_invite_school_owner + end + + it 'returns a successful operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, school_owner_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:invite_school_owner) + .with(token:, email_address: 'owner-to-invite@example.com', organisation_id: school.id) + end + + context 'when creation fails' do + let(:school_owner_params) do + { email_address: 'invalid' } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, school_owner_params:, token:) + expect(ProfileApiClient).not_to have_received(:invite_school_owner) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response[:error]).to match(/email address 'invalid' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_owner_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response[:error]).to match(/school is not verified/) + end + end +end diff --git a/spec/concepts/school_owner/list_spec.rb b/spec/concepts/school_owner/list_spec.rb new file mode 100644 index 000000000..118bc4e95 --- /dev/null +++ b/spec/concepts/school_owner/list_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwner::List, type: :unit do + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:owner_ids) { [owner.id] } + + before do + stub_profile_api_list_school_owners(user_id: owner.id) + stub_user_info_api_for(owner) + end + + it 'returns a successful operation response' do + response = described_class.call(school:) + expect(response.success?).to be(true) + end + + it 'returns the school owners in the operation response' do + response = described_class.call(school:) + expect(response[:school_owners].first).to be_a(User) + end + + context 'when an error occurs' do + let(:response) { described_class.call(school:, owner_ids:) } + + let(:error_message) { 'Error listing school owners: some error' } + + before do + allow(User).to receive(:from_userinfo).with(ids: owner_ids).and_raise(StandardError.new('some error')) + allow(Sentry).to receive(:capture_exception) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'captures the exception and returns an error response' do + # Call the method to ensure the error is raised and captured + response + expect(Sentry).to have_received(:capture_exception).with(instance_of(StandardError)) + expect(response[:school_owners]).to be_nil + expect(response[:error]).to eq(error_message) + end + # rubocop:enable RSpec/MultipleExpectations + end +end diff --git a/spec/concepts/school_owner/remove_spec.rb b/spec/concepts/school_owner/remove_spec.rb new file mode 100644 index 000000000..7f5f8cfe7 --- /dev/null +++ b/spec/concepts/school_owner/remove_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwner::Remove, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:owner_id) { SecureRandom.uuid } + + before do + stub_profile_api_remove_school_owner + end + + it 'returns a successful operation response' do + response = described_class.call(school:, owner_id:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, owner_id:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:remove_school_owner) + .with(token:, owner_id:, organisation_id: school.id) + end + + context 'when removal fails' do + before do + allow(ProfileApiClient).to receive(:remove_school_owner).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, owner_id:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, owner_id:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, owner_id:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_student/create_batch_spec.rb b/spec/concepts/school_student/create_batch_spec.rb new file mode 100644 index 000000000..4ec03ec04 --- /dev/null +++ b/spec/concepts/school_student/create_batch_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::CreateBatch, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:verified_school) } + let(:user_id) { create(:teacher, school:).id } + + let(:school_students_params) do + [ + { + username: 'student-to-create', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student' + }, + { + username: 'second-student-to-create', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student 2' + } + ] + end + + before do + ActiveJob::Base.queue_adapter = :test + end + + context 'when queuing a job' do + before do + stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid]) + end + + it 'queues CreateStudentsJob' do + expect do + described_class.call(school:, school_students_params:, token:, user_id:) + end.to have_enqueued_job(CreateStudentsJob).with(school_id: school.id, students: school_students_params, token:) + end + end + + context 'when a job has been queued' do + before do + stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid]) + allow(CreateStudentsJob).to receive(:attempt_perform_later).and_return( + instance_double(CreateStudentsJob, job_id: SecureRandom.uuid) + ) + end + + it 'returns a successful operation response' do + response = described_class.call(school:, school_students_params:, token:, user_id:) + expect(response.success?).to be(true) + end + + it 'returns the job id' do + response = described_class.call(school:, school_students_params:, token:, user_id:) + expect(response[:job_id]).to be_truthy + end + end + + context 'when a normal error occurs' do + let(:school_students_params) do + [ + { + username: 'a-student', + password: 'Password', + name: 'School Student' + }, + { + username: 'second-student-to-create', + password: 'Password', + name: 'School Student 2' + } + ] + end + + before do + stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid, SecureRandom.uuid]) + allow(Sentry).to receive(:capture_exception) + end + + it 'does not queue a new job' do + expect do + described_class.call(school:, school_students_params:, token:, user_id:) + end.not_to have_enqueued_job(CreateStudentsJob) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_students_params:, token:, user_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_students_params:, token:, user_id:) + error_message = response[:error] + expect(error_message).to match(/Error creating school students: Decryption failed: iv must be 16 bytes/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_students_params:, token:, user_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when a validation error occurs' do + before do + stub_profile_api_create_school_students_validation_error + end + + it 'returns the expected formatted errors' do + response = described_class.call(school:, school_students_params:, token:, user_id:) + expect(response[:error]).to eq( + { 'student-to-create' => ['Username must be unique in the batch data', 'Password is too simple (it should not be easily guessable, need password help?)', 'You must supply a name'], 'another-student-to-create-2' => ['Password must be at least 8 characters', 'You must supply a name'] } + ) + end + end +end diff --git a/spec/concepts/school_student/create_spec.rb b/spec/concepts/school_student/create_spec.rb new file mode 100644 index 000000000..17ef3807b --- /dev/null +++ b/spec/concepts/school_student/create_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::Create, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:verified_school) } + let(:user_id) { SecureRandom.uuid } + + let(:school_student_params) do + { + username: 'student-to-create', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student' + } + end + + before do + stub_profile_api_create_school_student(user_id:) + end + + it 'returns a successful operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, school_student_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:create_school_student) + .with(token:, username: 'student-to-create', password: 'Student2024', name: 'School Student', school_id: school.id) + end + + it 'creates a role associating the student with the school' do + described_class.call(school:, school_student_params:, token:) + expect(Role.student.where(school:, user_id:)).to exist + end + + it 'returns the ID of the student created in Profile API' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:student_id]).to eq(user_id) + end + + context 'when creation fails' do + let(:school_student_params) do + { + username: '', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student' + } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, school_student_params:, token:) + expect(ProfileApiClient).not_to have_received(:create_school_student) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to match(/username '' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_student_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to match(/school is not verified/) + end + end + + context 'when the student cannot be created in profile api because of a 422 response' do + let(:error) { { 'message' => "something's up with the username" } } + let(:exception) { ProfileApiClient::Student422Error.new(error) } + + before do + allow(ProfileApiClient).to receive(:create_school_student).and_raise(exception) + end + + it 'adds a useful error message' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to eq("Error creating school student: something's up with the username") + end + end + + context 'when the student cannot be created in profile api because of a response other than 422' do + before do + allow(ProfileApiClient).to receive(:create_school_student) + .and_raise('Student not created in Profile API (status code 401)') + end + + it 'adds a useful error message' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to eq('Error creating school student: Student not created in Profile API (status code 401)') + end + end +end diff --git a/spec/concepts/school_student/delete_spec.rb b/spec/concepts/school_student/delete_spec.rb new file mode 100644 index 000000000..32c26daba --- /dev/null +++ b/spec/concepts/school_student/delete_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::Delete, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:student_id) { SecureRandom.uuid } + + before do + stub_profile_api_delete_school_student + end + + it 'returns a successful operation response' do + response = described_class.call(school:, student_id:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, student_id:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:delete_school_student) + .with(token:, student_id:, school_id: school.id) + end + + context 'when removal fails' do + before do + allow(ProfileApiClient).to receive(:delete_school_student).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, student_id:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, student_id:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, student_id:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_student/list_spec.rb b/spec/concepts/school_student/list_spec.rb new file mode 100644 index 000000000..d5dc495b3 --- /dev/null +++ b/spec/concepts/school_student/list_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::List, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:students) { create_list(:student, 3, school:) } + + context 'without student_ids' do + before do + student_attributes = students.map do |student| + { id: student.id, name: student.name, username: student.username } + end + stub_profile_api_list_school_students(school:, student_attributes:) + end + + it 'returns a successful operation response' do + response = described_class.call(school:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:list_school_students).with(token:, school_id: school.id, student_ids: students.map(&:id)) + end + + it 'returns a school students JSON array' do + response = described_class.call(school:, token:) + expect(response[:school_students].size).to eq(3) + end + + it 'returns the school students in the operation response' do + response = described_class.call(school:, token:) + students.each do |student| + expected_user = User.new(id: student.id, name: student.name, username: student.username) + expect(response[:school_students]).to include(expected_user) + end + end + end + + context 'with student_ids' do + let(:student_ids) { students.map(&:id).take(2) } + let(:filtered_students) { students.select { |student| student_ids.include?(student.id) } } + + before do + student_attributes = filtered_students.map do |student| + { id: student.id, name: student.name, username: student.username } + end + stub_profile_api_list_school_students(school:, student_attributes:) + end + + it 'returns a successful operation response' do + response = described_class.call(school:, token:, student_ids:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, token:, student_ids:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:list_school_students).with(token:, school_id: school.id, student_ids:) + end + + it 'returns a filtered school students JSON array' do + response = described_class.call(school:, token:, student_ids:) + expect(response[:school_students].size).to eq(2) + end + + it 'returns the filtered school students in the operation response' do + response = described_class.call(school:, token:, student_ids:) + filtered_students.each do |student| + expected_user = User.new(id: student.id, name: student.name, username: student.username) + expect(response[:school_students]).to include(expected_user) + end + end + end + + context 'when listing fails' do + let(:student_ids) { [123] } + + before do + allow(ProfileApiClient).to receive(:list_school_students).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, token:, student_ids:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, token:, student_ids:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, token:, student_ids:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_student/update_spec.rb b/spec/concepts/school_student/update_spec.rb new file mode 100644 index 000000000..4618f35af --- /dev/null +++ b/spec/concepts/school_student/update_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::Update, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:student_id) { SecureRandom.uuid } + + let(:school_student_params) do + { + username: 'new-username', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'New Name' + } + end + + before do + stub_profile_api_update_school_student + end + + it 'returns a successful operation response' do + response = described_class.call(school:, student_id:, school_student_params:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, student_id:, school_student_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:update_school_student) + .with(token:, username: 'new-username', password: 'Student2024', name: 'New Name', school_id: school.id, student_id:) + end + + context 'when updating fails' do + let(:school_student_params) do + { + username: ' ', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'New Name' + } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, student_id:, school_student_params:, token:) + expect(ProfileApiClient).not_to have_received(:update_school_student) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, student_id:, school_student_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, student_id:, school_student_params:, token:) + expect(response[:error]).to match(/username ' ' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, student_id:, school_student_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/school_teacher/invite_spec.rb b/spec/concepts/school_teacher/invite_spec.rb new file mode 100644 index 000000000..6cb579110 --- /dev/null +++ b/spec/concepts/school_teacher/invite_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolTeacher::Invite, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:verified_school) } + let(:teacher_id) { SecureRandom.uuid } + + let(:school_teacher_params) do + { email_address: 'teacher-to-invite@example.com' } + end + + it 'returns a successful operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response.success?).to be(true) + end + + it 'creates a TeacherInvitation' do + expect { described_class.call(school:, school_teacher_params:, token:) }.to change(TeacherInvitation, :count) + end + + context 'when creation fails' do + let(:school_teacher_params) do + { email_address: 'invalid' } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response[:error]).to match(/Email address 'invalid' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_teacher_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response[:error]).to match(/School is not verified/) + end + end +end diff --git a/spec/concepts/school_teacher/list_spec.rb b/spec/concepts/school_teacher/list_spec.rb new file mode 100644 index 000000000..d6e002eef --- /dev/null +++ b/spec/concepts/school_teacher/list_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolTeacher::List, type: :unit do + let(:school) { create(:school) } + let(:teachers) { create_list(:teacher, 3, school:) } + let(:teacher_ids) { teachers.map(&:id) } + + context 'when successful' do + context 'when not passing teacher_ids' do + let(:response) { described_class.call(school:) } + + before do + stub_user_info_api_for_users(teacher_ids, users: teachers) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'returns a successful response with school teachers' do + expect(response[:school_teachers]).to eq(teachers) + expect(response[:error]).to be_nil + end + # rubocop:enable RSpec/MultipleExpectations + end + + context 'when passing teacher_ids' do + let(:response) { described_class.call(school:, teacher_ids:) } + + before do + stub_user_info_api_for(teachers[1]) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'returns a successful response with school teachers' do + expect(response[:school_teachers].first.id).to eq(teachers[1].id) + expect(response[:error]).to be_nil + end + # rubocop:enable RSpec/MultipleExpectations + end + end + + context 'when an error occurs' do + let(:response) { described_class.call(school:, teacher_ids:) } + + let(:error_message) { 'Error listing school teachers: some error' } + + before do + allow(User).to receive(:from_userinfo).with(ids: teacher_ids).and_raise(StandardError.new('some error')) + allow(Sentry).to receive(:capture_exception) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'captures the exception and returns an error response' do + # Call the method to ensure the error is raised and captured + response + expect(Sentry).to have_received(:capture_exception).with(instance_of(StandardError)) + expect(response[:school_teachers]).to be_nil + expect(response[:error]).to eq(error_message) + end + # rubocop:enable RSpec/MultipleExpectations + end +end diff --git a/spec/concepts/school_teacher/remove_spec.rb b/spec/concepts/school_teacher/remove_spec.rb new file mode 100644 index 000000000..e3d365b62 --- /dev/null +++ b/spec/concepts/school_teacher/remove_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolTeacher::Remove, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:teacher_id) { SecureRandom.uuid } + + before do + stub_profile_api_remove_school_teacher + end + + it 'returns a successful operation response' do + response = described_class.call(school:, teacher_id:, token:) + expect(response.success?).to be(true) + end + + it 'makes a profile API call' do + described_class.call(school:, teacher_id:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:remove_school_teacher) + .with(token:, teacher_id:, organisation_id: school.id) + end + + context 'when removal fails' do + before do + allow(ProfileApiClient).to receive(:remove_school_teacher).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, teacher_id:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, teacher_id:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, teacher_id:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/factories/class_student.rb b/spec/factories/class_student.rb new file mode 100644 index 000000000..47c473aca --- /dev/null +++ b/spec/factories/class_student.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :class_student do + school_class + student_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/class_teacher.rb b/spec/factories/class_teacher.rb new file mode 100644 index 000000000..2d28d0889 --- /dev/null +++ b/spec/factories/class_teacher.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :class_teacher do + school_class + teacher_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/good_job.rb b/spec/factories/good_job.rb new file mode 100644 index 000000000..d919fa99c --- /dev/null +++ b/spec/factories/good_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :good_job, class: 'GoodJob::Job' do + # Add necessary attributes here + queue_name { 'default' } + priority { 0 } + serialized_params { {} } + scheduled_at { Time.current } + performed_at { nil } + finished_at { nil } + error { nil } + end +end diff --git a/spec/factories/lesson.rb b/spec/factories/lesson.rb new file mode 100644 index 000000000..68f0bce41 --- /dev/null +++ b/spec/factories/lesson.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :lesson do + user_id { SecureRandom.uuid } + sequence(:name) { |n| "Lesson #{n}" } + description { 'Description' } + visibility { 'teachers' } + project { create(:project, user_id:, name:) } + + trait :with_project_components do + transient do + component_count { 1 } + end + + after(:create) do |object, evaluator| + object.project.components << FactoryBot.create_list(:component, + evaluator.component_count, + project: object.project) + end + end + + trait :with_project_image do + after(:build) do |object| + object.project.images.attach(io: Rails.root.join('spec/fixtures/files/test_image_1.png').open, + filename: 'test_image', + content_type: 'image/png') + end + end + end +end diff --git a/spec/factories/project.rb b/spec/factories/project.rb index e1da44760..678b70782 100644 --- a/spec/factories/project.rb +++ b/spec/factories/project.rb @@ -5,9 +5,22 @@ user_id { SecureRandom.uuid } name { Faker::Book.title } identifier { "#{Faker::Verb.base}-#{Faker::Verb.base}-#{Faker::Verb.base}" } - project_type { 'python' } + project_type { Project::Types::PYTHON } locale { %w[en es-LA fr-FR].sample } + transient do + # school { nil } + # school_id { nil } + finished { nil } + end + + after(:create) do |project, evaluator| + if evaluator.finished.present? + project.school_project.finished = evaluator.finished + project.school_project.save! + end + end + trait :with_components do transient do component_count { 1 } @@ -33,5 +46,26 @@ content_type: 'image/png') end end + + trait :with_attached_video do + after(:build) do |object| + object.videos.attach(io: Rails.root.join('spec/fixtures/files/test_video_1.mp4').open, + filename: 'test_video', + content_type: 'video/mp4') + end + end + + trait :with_attached_audio do + after(:build) do |object| + object.audio.attach(io: Rails.root.join('spec/fixtures/files/test_audio_1.mp3').open, + filename: 'test_audio', + content_type: 'audio/mp3') + end + end + + trait :with_instructions do + instructions { Faker::Lorem.paragraph } + school { create(:school) } + end end end diff --git a/spec/factories/project_errors.rb b/spec/factories/project_errors.rb new file mode 100644 index 000000000..10ea20596 --- /dev/null +++ b/spec/factories/project_errors.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_error do + error { Faker::Lorem.words(number: rand(2..10)).join(' ') } + end +end diff --git a/spec/factories/role.rb b/spec/factories/role.rb new file mode 100644 index 000000000..2056230eb --- /dev/null +++ b/spec/factories/role.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :role, aliases: [:owner_role] do + school + user_id { SecureRandom.uuid } + role { :owner } + end + + factory :teacher_role, parent: :role do + role { :teacher } + end + + factory :student_role, parent: :role do + role { :student } + end +end diff --git a/spec/factories/school.rb b/spec/factories/school.rb new file mode 100644 index 000000000..ce447c43b --- /dev/null +++ b/spec/factories/school.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :school do + sequence(:name) { |n| "School #{n}" } + website { 'http://www.example.com/' } + address_line_1 { 'Address Line 1' } + municipality { 'Greater London' } + country_code { 'GB' } + creator_id { SecureRandom.uuid } + creator_agree_authority { true } + creator_agree_terms_and_conditions { true } + creator_agree_responsible_safeguarding { true } + end + + factory :verified_school, parent: :school do + verified_at { Time.current } + code { ForEducationCodeGenerator.generate } + end +end diff --git a/spec/factories/school_class.rb b/spec/factories/school_class.rb new file mode 100644 index 000000000..9dfcad3c1 --- /dev/null +++ b/spec/factories/school_class.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :school_class do + sequence(:name) { |n| "Class #{n}" } + code { ForEducationCodeGenerator.generate } + + transient do + teacher_ids { [SecureRandom.uuid] } + end + + after(:build) do |school_class, evaluator| + teachers = evaluator.teacher_ids.map do |teacher_id| + build(:class_teacher, school_class:, teacher_id:) + end + school_class.teachers = teachers + end + end +end diff --git a/spec/factories/school_projects.rb b/spec/factories/school_projects.rb new file mode 100644 index 000000000..2f7cf19fc --- /dev/null +++ b/spec/factories/school_projects.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :school_project do + school_id { SecureRandom.uuid } + project_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/teacher_invitation.rb b/spec/factories/teacher_invitation.rb new file mode 100644 index 000000000..a41cb1261 --- /dev/null +++ b/spec/factories/teacher_invitation.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :teacher_invitation do + email_address { 'teacher@example.com' } + school factory: :verified_school + end +end diff --git a/spec/factories/user.rb b/spec/factories/user.rb new file mode 100644 index 000000000..d7470f672 --- /dev/null +++ b/spec/factories/user.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user do + id { SecureRandom.uuid } + name { Faker::Name.name } + email { Faker::Internet.email } + username { nil } + + factory :admin_user do + roles { 'editor-admin' } + end + + factory :experience_cs_admin_user do + roles { 'experience-cs-admin' } + end + + factory :student do + email { nil } + username { Faker::Internet.username } + + transient do + school { nil } + end + + after(:create) do |user, context| + create(:student_role, user_id: user.id, school: context.school) + end + end + + factory :teacher do + transient do + school { nil } + end + + after(:create) do |user, context| + create(:teacher_role, user_id: user.id, school: context.school) + end + end + + factory :owner do + transient do + school { nil } + end + + after(:create) do |user, context| + create(:owner_role, user_id: user.id, school: context.school) + end + end + + skip_create + end +end diff --git a/spec/factories/user_job.rb b/spec/factories/user_job.rb new file mode 100644 index 000000000..5181abc2a --- /dev/null +++ b/spec/factories/user_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user_job do + association :good_job, factory: :good_job + user_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/word.rb b/spec/factories/word.rb deleted file mode 100644 index f5bc702bb..000000000 --- a/spec/factories/word.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :word do - word { Faker::Verb.base } - end -end diff --git a/spec/features/admin/schools_spec.rb b/spec/features/admin/schools_spec.rb new file mode 100644 index 000000000..581d4e16a --- /dev/null +++ b/spec/features/admin/schools_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Schools', type: :request do + let(:admin_user) { create(:admin_user) } + + before do + sign_in_as(admin_user) + end + + describe 'GET #index' do + it 'responds 200' do + get admin_schools_path + expect(response).to have_http_status(:success) + end + end + + describe 'GET #show' do + let(:creator) { create(:user) } + let(:verified_at) { nil } + let(:rejected_at) { nil } + let(:code) { nil } + let(:school) { create(:school, creator_id: creator.id, verified_at:, rejected_at:, code:) } + + before do + stub_user_info_api_for(creator) + get admin_school_path(school) + end + + it 'responds 200' do + expect(response).to have_http_status(:success) + end + + it 'includes link to verify school' do + expect(response.body).to include(I18n.t('administrate.actions.verify_school')) + end + + it 'includes link to reject school' do + expect(response.body).to include(I18n.t('administrate.actions.reject_school')) + end + + describe 'when the school is verified' do + let(:verified_at) { Time.zone.now } + let(:code) { '00-00-00' } + + it 'does not include a link to verify school' do + expect(response.body).not_to include(I18n.t('administrate.actions.verify_school')) + end + + it 'does not include a link to reject school' do + expect(response.body).not_to include(I18n.t('administrate.actions.reject_school')) + end + + it 'does not include a link to reopen school' do + expect(response.body).not_to include(I18n.t('administrate.actions.reopen_school')) + end + end + + describe 'when the school is rejected' do + let(:rejected_at) { Time.zone.now } + + it 'does not include a link to verify school' do + expect(response.body).not_to include(I18n.t('administrate.actions.verify_school')) + end + + it 'does not include a link to reject school' do + expect(response.body).not_to include(I18n.t('administrate.actions.reject_school')) + end + + it 'includes link to reopen school' do + expect(response.body).to include(I18n.t('administrate.actions.reopen_school')) + end + end + end + + describe 'POST #verify' do + let(:creator) { create(:user) } + let(:verified_at) { nil } + let(:school) { create(:school, creator_id: creator.id, verified_at:) } + let(:verification_result) { nil } + let(:verification_service) { instance_double(SchoolVerificationService, verify: verification_result) } + + before do + stub_user_info_api_for(creator) + allow(SchoolVerificationService).to receive(:new).with(school).and_return(verification_service) + + post verify_admin_school_path(school) + end + + it 'redirects to school path' do + expect(response).to redirect_to(admin_school_path(school)) + end + + describe 'when verification was successful' do + let(:verification_result) { true } + + before do + follow_redirect! + end + + it 'displays success message' do + expect(response.body).to include(I18n.t('administrate.controller.verify_school.success')) + end + end + + describe 'when verification was unsuccessful' do + let(:verification_result) { false } + + before do + follow_redirect! + end + + it 'displays failure message' do + expect(response.body).to include(I18n.t('administrate.controller.verify_school.error')) + end + end + end + + describe 'PUT #reject' do + let(:creator) { create(:user) } + let(:school) { create(:school, creator_id: creator.id) } + let(:rejection_result) { nil } + let(:verification_service) { instance_double(SchoolVerificationService, reject: rejection_result) } + + before do + stub_user_info_api_for(creator) + allow(SchoolVerificationService).to receive(:new).with(school).and_return(verification_service) + + patch reject_admin_school_path(school) + end + + it 'redirects to school path' do + expect(response).to redirect_to(admin_school_path(school)) + end + + describe 'when rejection was successful' do + let(:rejection_result) { true } + + before do + follow_redirect! + end + + it 'displays success message' do + expect(response.body).to include(I18n.t('administrate.controller.reject_school.success')) + end + end + + describe 'when rejection was unsuccessful' do + let(:rejection_result) { false } + + before do + follow_redirect! + end + + it 'displays failure message' do + expect(response.body).to include(I18n.t('administrate.controller.reject_school.error')) + end + end + end + + describe 'PUT #reopen' do + let(:creator) { create(:user) } + let(:school) { create(:verified_school, creator_id: creator.id) } + let(:reopen_result) { nil } + let(:verification_service) { instance_double(SchoolVerificationService, reopen: reopen_result) } + + before do + stub_user_info_api_for(creator) + allow(SchoolVerificationService).to receive(:new).with(school).and_return(verification_service) + + patch reopen_admin_school_path(school) + end + + it 'redirects to school path' do + expect(response).to redirect_to(admin_school_path(school)) + end + + describe 'when reopen was successful' do + let(:reopen_result) { true } + + before do + follow_redirect! + end + + it 'displays success message' do + expect(response.body).to include(I18n.t('administrate.controller.reopen_school.success')) + end + end + + describe 'when reopen was unsuccessful' do + let(:reopen_result) { false } + + before do + allow(verification_service).to receive(:reopen).and_raise(StandardError) + follow_redirect! + end + + it 'displays failure message' do + expect(response.body).to include(I18n.t('administrate.controller.reopen_school.error')) + end + end + end + + private + + def sign_in_as(user) + allow(User).to receive(:from_omniauth).and_return(user) + get '/auth/callback' + end +end diff --git a/spec/features/class_member/creating_a_batch_of_class_members_spec.rb b/spec/features/class_member/creating_a_batch_of_class_members_spec.rb new file mode 100644 index 000000000..56fbd3ab9 --- /dev/null +++ b/spec/features/class_member/creating_a_batch_of_class_members_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a class member', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:students) { create_list(:student, 3, school:) } + let(:teacher) { create(:teacher, school:) } + + context 'with valid params' do + let(:student_attributes) do + students.map do |student| + { id: student.id, name: student.name, username: student.username, type: 'student' } + end + end + + context 'when adding another teacher' do + let(:another_teacher) { create(:teacher, school:) } + let(:params) do + { + class_members: [{ user_id: another_teacher.id, type: 'teacher' }] + students.map { |student| { user_id: student.id, type: 'student' } } + } + end + + before do + authenticated_in_hydra_as(teacher) + stub_profile_api_list_school_students(school:, student_attributes:) + stub_user_info_api_for(another_teacher) + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the class members JSON array' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + expect(data.size).to eq(4) + end + + it 'responds with the class member JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + class_member_ids = data.map { |member| member[:student_id] || member[:teacher_id] } + expect(class_member_ids).to eq(params[:class_members].pluck(:user_id)) + end + + it 'responds with the teacher/student JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + response_members = data.map { |member| member[:student] || member[:teacher] } + teacher_attributes = [{ id: another_teacher.id, name: another_teacher.name, email: another_teacher.email, type: 'teacher' }] + expect(response_members).to eq(teacher_attributes + student_attributes) + end + end + + context 'when adding an owner as another teacher' do + let(:owner_teacher) { create(:teacher, school:, id: create(:owner, school:).id) } + + let(:params) do + { + class_members: [{ user_id: owner_teacher.id, type: 'owner' }] + students.map { |student| { user_id: student.id, type: 'student' } } + } + end + + before do + authenticated_in_hydra_as(teacher) + stub_profile_api_list_school_students(school:, student_attributes:) + stub_user_info_api_for(owner_teacher) + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the class members JSON array' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + expect(data.size).to eq(4) + end + + it 'responds with the class member JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + class_member_ids = data.map { |member| member[:student_id] || member[:teacher_id] } + expect(class_member_ids).to eq(params[:class_members].pluck(:user_id)) + end + + it 'responds with the teacher/student JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + response_members = data.map { |member| member[:student] || member[:teacher] || member[:owner] } + teacher_attributes = [{ id: owner_teacher.id, name: owner_teacher.name, email: owner_teacher.email, type: 'owner' }] + expect(response_members).to eq(teacher_attributes + student_attributes) + end + end + end + + context 'with invalid params' do + unknown_user_id = SecureRandom.uuid + + let(:invalid_params) do + { + class_members: [{ user_id: unknown_user_id }] + } + end + + before do + authenticated_in_hydra_as(teacher) + stub_user_info_api_for_unknown_users(user_id: unknown_user_id) + end + + it 'responds 422 Unprocessable Entity when params are missing' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params: invalid_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns the error message in the operation response' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params: invalid_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:error]).to match(/No valid school members provided/) + end + end + + context 'when the user is not authorized' do + let(:another_teacher) { create(:teacher, school:) } + let(:params) do + { + class_members: [{ user_id: another_teacher.id, type: 'teacher' }] + students.map { |student| { user_id: student.id, type: 'student' } } + } + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a teacher from a different school' do + authenticated_in_hydra_as(teacher) + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb new file mode 100644 index 000000000..5fe778d5b --- /dev/null +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a class member', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:student) { create(:student, school:, name: 'School Student', username: 'school-student') } + let(:teacher) { create(:teacher, school:) } + + before do + owner = create(:owner, school:) + authenticated_in_hydra_as(owner) + stub_user_info_api_for(student) + end + + context 'with valid params' do + context 'when new class member is a student' do + let(:student_params) do + { + class_member: { + user_id: student.id, + type: 'student' + } + } + end + let(:student_attributes) { { id: student.id, name: student.name, username: student.username, type: 'student' } } + + before do + stub_profile_api_list_school_students(school:, student_attributes: [student_attributes]) + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + expect(response).to have_http_status(:created) + end + + it 'responds with the class member JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:class_member][:student_id]).to eq(student.id) + end + + it 'responds with the student JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + data = JSON.parse(response.body, symbolize_names: true) + + response_student = data[:class_member][:student] + + expect(response_student).to eq(student_attributes) + end + end + + context 'when new class member is a teacher' do + let(:another_teacher) { create(:teacher, school:) } + let(:teacher_params) do + { + class_member: { + user_id: another_teacher.id, + type: 'teacher' + } + } + end + + before do + stub_user_info_api_for(another_teacher) + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: teacher_params) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: teacher_params) + expect(response).to have_http_status(:created) + end + + it 'responds with the class member JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: teacher_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:class_member][:teacher_id]).to eq(another_teacher.id) + end + + it 'responds with the teacher JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: teacher_params) + data = JSON.parse(response.body, symbolize_names: true) + + response_teacher = data[:class_member][:teacher] + teacher_attributes = { id: another_teacher.id, name: another_teacher.name, email: another_teacher.email, type: 'teacher' } + expect(response_teacher).to eq(teacher_attributes) + end + end + + context 'when new class member is an owner' do + let(:owner) { create(:owner, school:) } + let(:owner_teacher) { create(:teacher, school:, id: owner.id, name: owner.name, email: owner.email) } + + let(:owner_params) do + { + class_member: { + user_id: owner.id, + type: 'owner' + } + } + end + + before do + stub_user_info_api_for(owner_teacher) + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: owner_params) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: owner_params) + expect(response).to have_http_status(:created) + end + + it 'responds with the class member JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: owner_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:class_member][:teacher_id]).to eq(owner.id) + end + + it 'responds with the teacher JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: owner_params) + data = JSON.parse(response.body, symbolize_names: true) + + response_teacher = data[:class_member][:owner] + owner_attributes = { id: owner.id, name: owner.name, email: owner.email, type: 'owner' } + expect(response_teacher).to eq(owner_attributes) + end + end + end + + context 'with invalid params' do + let(:invalid_params) { { class_member: { user_id: SecureRandom.uuid } } } + + before do + stub_user_info_api_for_unknown_users(user_id: invalid_params[:class_member][:user_id]) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: invalid_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns the error message in the operation response' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: invalid_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:error]).to match(/No valid school members provided/) + end + end + + context 'when the user is not authorized' do + let(:student_params) do + { + class_member: { + user_id: student.id, + type: 'student' + } + } + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", params: student_params) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/class_member/deleting_a_class_member_spec.rb b/spec/features/class_member/deleting_a_class_member_spec.rb new file mode 100644 index 000000000..0dba7ae81 --- /dev/null +++ b/spec/features/class_member/deleting_a_class_member_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a class member', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:class_member) { create(:class_student, student_id: student.id, school_class:) } + let(:school_class) { build(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + let(:owner) { create(:owner, school:) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is the class teacher' do + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/class_member/listing_class_members_spec.rb b/spec/features/class_member/listing_class_members_spec.rb new file mode 100644 index 000000000..dbf3fb42d --- /dev/null +++ b/spec/features/class_member/listing_class_members_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing class members', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:teacher) { create(:teacher, school:) } + let(:students) { create_list(:student, 3, school:) } + let(:school_class) { build(:school_class, teacher_ids: [teacher.id, owner.id], school:) } + + before do + authenticated_in_hydra_as(owner) + + student_attributes = students.map do |student| + { id: student.id, name: student.name, username: student.username } + end + stub_profile_api_list_school_students(school:, student_attributes:) + + students.each do |student| + create(:class_student, student_id: student.id, school_class:) + end + + stub_user_info_api_for_users([teacher.id, owner.id], users: [teacher, owner]) + end + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the class members JSON array' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(5) + end + + it 'responds with the correct member ids, where applicable' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + school_class.students.each do |student| + expect(data.pluck(:id)).to include(student.id) + end + end + + it 'responds with the correct student ids' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + student_ids = data.pluck(:student).compact.pluck(:id) + + school_class.students.each do |student| + expect(student_ids).to include(student.student_id) + end + end + + it 'responds with the expected student parameters' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + student_data = data.pluck(:student).compact.find { |member| member[:id] == students[0].id } + + expect(student_data).to eq( + { + id: students[0].id, + username: students[0].username, + name: students[0].name, + type: 'student' + } + ) + end + + it 'responds with the correct teacher id' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + teacher_id = data.pluck(:teacher).compact.pick(:id) + + expect(teacher_id).to eq(teacher.id) + end + + it 'responds with the correct owner id' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + owner_id = data.pluck(:owner).compact.pick(:id) + + expect(owner_id).to eq(owner.id) + end + + it 'responds with the expected teacher parameters' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + teacher_data = data.pluck(:teacher).compact + + expect(teacher_data.first).to eq( + { + id: teacher.id, + name: teacher.name, + email: teacher.email, + type: 'teacher' + } + ) + end + + it 'responds with teachers at the top' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[0][:teacher]).to be_truthy + end + + it 'responds with students in alphabetical order by name ascending' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + student_names = data.pluck(:student).compact.pluck(:name) + sorted_student_names = student_names.sort + + expect(student_names).to eq(sorted_student_names) + end + + it "responds with nil attributes for students if the user profile doesn't exist" do + stub_user_info_api_for_unknown_users(user_id: students.first.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:student_name]).to be_nil + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/classes/#{school_class.id}/members" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/lesson/archiving_a_lesson_spec.rb b/spec/features/lesson/archiving_a_lesson_spec.rb new file mode 100644 index 000000000..dd95cd434 --- /dev/null +++ b/spec/features/lesson/archiving_a_lesson_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Archiving a lesson', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, user_id: owner.id) } + let(:owner_id) { SecureRandom.uuid } + let(:teacher) { create(:teacher, school:) } + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } + + it 'responds 204 No Content' do + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content if the lesson is already archived' do + lesson.archive! + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'archives the lesson' do + delete("/api/lessons/#{lesson.id}", headers:) + expect(lesson.reload.archived?).to be(true) + end + + it 'unarchives the lesson when the ?undo=true query parameter is set' do + lesson.archive! + + delete("/api/lessons/#{lesson.id}?undo=true", headers:) + expect(lesson.reload.archived?).to be(false) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/lessons/#{lesson.id}" + expect(response).to have_http_status(:unauthorized) + end + + it "responds 403 Forbidden when the user is not the lesson's owner" do + lesson.update!(user_id: SecureRandom.uuid) + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the lesson is associated with a school (library)' do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, visibility: 'teachers', user_id: teacher.id) } + + it 'responds 204 No Content when the user is a school-owner' do + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it "responds 403 Forbidden when the user a school-owner but visibility is 'private'" do + lesson.update!(visibility: 'private') + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is another school-teacher in the school' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb b/spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb new file mode 100644 index 000000000..1e1cb0b27 --- /dev/null +++ b/spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a copy of a lesson', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'public', user_id: teacher.id) } + let(:params) { {} } + let(:teacher) { create(:teacher, school:) } + let(:owner) { create(:owner, school:, name: 'School Owner') } + let(:school) { create(:school) } + + it 'responds 201 Created' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the lesson JSON' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON which is set from the current user' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Owner') + end + + # See spec/concepts/lesson/create_copy_spec.rb for more examples. + it 'only copies a subset of fields from the lesson' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + + data = JSON.parse(response.body, symbolize_names: true) + values = data.slice(:copied_from_id, :name, :visibility).values + + expect(values).to eq [lesson.id, 'Test Lesson', 'teachers'] + end + + it 'can override fields from the request params' do + new_params = { lesson: { name: 'New Name', visibility: 'public' } } + post("/api/lessons/#{lesson.id}/copy", headers:, params: new_params) + + data = JSON.parse(response.body, symbolize_names: true) + values = data.slice(:name, :visibility).values + + expect(values).to eq ['New Name', 'public'] + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/lessons/#{lesson.id}/copy", headers:, params: { lesson: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/lessons/#{lesson.id}/copy", params:) + expect(response).to have_http_status(:unauthorized) + end + + context "when the lesson's visibility is 'private'" do + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } + let(:owner) { create(:owner, school:) } + + it 'responds 201 Created when the user owns the lesson' do + lesson.update!(user_id: owner.id) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user does not own the lesson' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context "when the lesson's visibility is 'teachers'" do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers', user_id: teacher.id) } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + lesson: { + user_id: owner.id + } + } + end + + it 'responds 201 Created when the user owns the lesson' do + lesson.update!(user_id: owner.id) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-owner or school-teacher within the school' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb new file mode 100644 index 000000000..30b396188 --- /dev/null +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a lesson', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for(teacher) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:teacher) { create(:teacher, school:) } + let(:owner) { create(:owner, school:, name: 'School Owner') } + let(:school) { create(:school) } + + let(:params) do + { + lesson: { + name: 'Test Lesson', + project_attributes: { + name: 'Hello world project', + project_type: Project::Types::PYTHON, + components: [ + { name: 'main.py', extension: 'py', content: 'print("Hello, world!")' } + ] + } + } + } + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the lesson JSON' do + post('/api/lessons', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON which is set from the current user' do + post('/api/lessons', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Owner') + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post('/api/lessons', headers:, params: { lesson: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post('/api/lessons', params:) + expect(response).to have_http_status(:unauthorized) + end + + context 'when the lesson is associated with a school (library)' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + + let(:params) do + { + lesson: { + name: 'Test Lesson', + school_id: school.id, + project_attributes: { + name: 'Hello world project', + project_type: Project::Types::PYTHON, + components: [ + { name: 'main.py', extension: 'py', content: 'print("Hello, world!")' } + ] + } + } + } + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher for the school' do + authenticated_in_hydra_as(teacher) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'sets the lesson user to the current user for school-teacher users' do + authenticated_in_hydra_as(teacher) + new_params = { lesson: params[:lesson].merge(user_id: 'ignored') } + + post('/api/lessons', headers:, params: new_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher.id) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.teacher.find_by(user_id: teacher.id, school:).delete + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when the lesson is associated with a school class' do + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + + let(:params) do + { + lesson: { + name: 'Test Lesson', + school_id: school.id, + school_class_id: school_class.id, + project_attributes: { + name: 'Hello world project', + project_type: Project::Types::PYTHON, + components: [ + { name: 'main.py', extension: 'py', content: 'print("Hello, world!")' } + ] + } + } + } + end + + it 'responds 201 Created when the user is the school-teacher for the class' do + authenticated_in_hydra_as(teacher) + school_class.update!(teachers: [ClassTeacher.new({ teacher_id: teacher.id })]) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 422 Unprocessable if school_id is missing' do + new_params = { lesson: params[:lesson].without(:school_id) } + + post('/api/lessons', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable if school_class_id does not correspond to school_id' do + new_params = { lesson: params[:lesson].merge(school_id: SecureRandom.uuid) } + + post('/api/lessons', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + params[:lesson][:school_id] = school.id + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the current user is a school-teacher for a different class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 422 Unprocessable Entity when the user_id is a school-teacher for a different class' do + user_id = SecureRandom.uuid + new_params = { lesson: params[:lesson].merge(user_id:) } + + post('/api/lessons', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + end +end diff --git a/spec/features/lesson/listing_lessons_spec.rb b/spec/features/lesson/listing_lessons_spec.rb new file mode 100644 index 000000000..8b5f6cb16 --- /dev/null +++ b/spec/features/lesson/listing_lessons_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing lessons', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for(teacher) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'public', user_id: teacher.id) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:another_school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + + it 'responds 200 OK' do + get('/api/lessons', headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when no token is given' do + get '/api/lessons' + expect(response).to have_http_status(:ok) + end + + it 'responds with the lessons JSON' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:user_name]).to eq('School Teacher') + end + + it 'responds with the project JSON' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + expected_project = JSON.parse(lesson.project.to_json(only: %i[identifier project_type]), symbolize_names: true) + + expect(data.first[:project]).to eq(expected_project) + end + + it "responds with nil attributes for the user if their user profile doesn't exist" do + user_id = SecureRandom.uuid + stub_user_info_api_for_unknown_users(user_id:) + lesson.update!(user_id:) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:user_name]).to be_nil + end + + it 'does not include archived lessons' do + lesson.archive! + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'includes archived lessons if ?include_archived=true is set' do + lesson.archive! + + get('/api/lessons?include_archived=true', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'does not include lessons with no class if school_class_id provided' do + get("/api/lessons?school_class_id=#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'does not include lessons from another class if school_class_id provided' do + lesson.update!(school_class_id: another_school_class.id) + get("/api/lessons?school_class_id=#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'includes lessons from the class if school_class_id provided' do + lesson.update!(school_class_id: school_class.id) + + get("/api/lessons?school_class_id=#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'defaults to not including archived lessons from the class if school_class_id provided' do + lesson.archive! + lesson.update!(school_class_id: school_class.id) + get("/api/lessons?school_class_id=#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'includes archived lessons from class if include_archived=true and school_class_id provided' do + lesson.archive! + lesson.update!(school_class_id: school_class.id) + get("/api/lessons?include_archived=true&school_class_id=#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + context "when the lesson's visibility is 'private'" do + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } + let(:owner) { create(:owner, school:) } + + it 'includes the lesson when the user owns the lesson' do + lesson.update!(user_id: owner.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'does not include the lesson whent he user does not own the lesson' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + end + + context "when the lesson's visibility is 'teachers'" do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers', user_id: teacher.id) } + let(:owner) { create(:owner, school:) } + + it 'includes the lesson when the user owns the lesson' do + lesson.update!(user_id: owner.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'includes the lesson when the user is a school-owner or school-teacher within the school' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'does not include the lesson when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'does not include the lesson when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + end + + context "when the lesson's visibility is 'students'" do + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students', user_id: teacher.id) } + let(:teacher) { create(:teacher, school:) } + + it 'includes the lesson when the user owns the lesson' do + another_teacher = create(:teacher, school:) + authenticated_in_hydra_as(another_teacher) + lesson.update!(user_id: teacher.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it "includes the lesson when the user is a school-student within the lesson's class" do + student = create(:student, school:) + authenticated_in_hydra_as(student) + create(:class_student, school_class:, student_id: student.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it "does not include the lesson when the user is not a school-student within the lesson's class" do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'does not include the lesson when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + end +end diff --git a/spec/features/lesson/showing_a_lesson_spec.rb b/spec/features/lesson/showing_a_lesson_spec.rb new file mode 100644 index 000000000..d9f5269d5 --- /dev/null +++ b/spec/features/lesson/showing_a_lesson_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a lesson', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for(teacher) + end + + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'public', user_id: teacher.id) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } + + it 'responds 200 OK' do + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when no token is given' do + get "/api/lessons/#{lesson.id}" + expect(response).to have_http_status(:ok) + end + + it 'responds with the lesson JSON' do + get("/api/lessons/#{lesson.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON' do + get("/api/lessons/#{lesson.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Teacher') + end + + it "responds with nil attributes for the user if their user profile doesn't exist" do + user_id = SecureRandom.uuid + stub_user_info_api_for_unknown_users(user_id:) + lesson.update!(user_id:) + + get("/api/lessons/#{lesson.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to be_nil + end + + it 'responds 404 Not Found when no lesson exists' do + get('/api/lessons/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) + end + + context "when the lesson's visibility is 'private'" do + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } + let(:owner) { create(:owner, school:) } + + it 'responds 200 OK when the user owns the lesson' do + lesson.update!(user_id: owner.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 403 Forbidden when the user does not own the lesson' do + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context "when the lesson's visibility is 'teachers'" do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers', user_id: teacher.id) } + let(:owner) { create(:owner, school:) } + + it 'responds 200 OK when the user owns the lesson' do + lesson.update!(user_id: owner.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a school-owner or school-teacher within the school' do + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context "when the lesson's visibility is 'students'" do + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students', user_id: teacher.id) } + let(:teacher) { create(:teacher, school:) } + + it 'responds 200 OK when the user owns the lesson' do + another_teacher = create(:teacher, school:) + authenticated_in_hydra_as(another_teacher) + lesson.update!(user_id: teacher.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it "responds 200 OK when the user is a school-student within the lesson's class" do + student = create(:student, school:) + authenticated_in_hydra_as(student) + create(:class_student, school_class:, student_id: student.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it "responds 403 Forbidden when the user is a school-student but isn't within the lesson's class" do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/lesson/updating_a_lesson_spec.rb b/spec/features/lesson/updating_a_lesson_spec.rb new file mode 100644 index 000000000..948239e1c --- /dev/null +++ b/spec/features/lesson/updating_a_lesson_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a lesson', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:params) do + { + lesson: { + name: 'New Name' + } + } + end + let!(:lesson) { create(:lesson, name: 'Test Lesson', user_id: owner.id) } + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:verified_school) } + let(:owner) { create(:owner, school:, name: 'School Owner') } + + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for(teacher) + end + + it 'responds 200 OK' do + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the lesson JSON' do + put("/api/lessons/#{lesson.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds with the user JSON' do + put("/api/lessons/#{lesson.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Owner') + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/lessons/#{lesson.id}", headers:, params: { lesson: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/lessons/#{lesson.id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + it "responds 403 Forbidden when the user is not the lesson's owner" do + lesson.update!(user_id: SecureRandom.uuid) + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the lesson is associated with a school (library)' do + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers', user_id: teacher.id) } + + before do + lesson + end + + it 'responds 200 OK when the user is a school-owner' do + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when assigning the lesson to a school class' do + authenticated_in_hydra_as(teacher) + school_class = create(:school_class, school:, teacher_ids: [teacher.id]) + + new_params = { lesson: params[:lesson].merge(school_class_id: school_class.id) } + put("/api/lessons/#{lesson.id}", headers:, params: new_params) + + expect(response).to have_http_status(:ok) + end + + it "responds 403 Forbidden when the user a school-owner but visibility is 'private'" do + lesson.update!(visibility: 'private') + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is another school-teacher in the school' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when the lesson is associated with a school class' do + let(:school) { create(:school) } + let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students', user_id: teacher.id) } + + before do + authenticated_in_hydra_as(teacher) + end + + it 'responds 200 OK when the user is a school-owner' do + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds 422 Unprocessable Entity when trying to re-assign the lesson to a different class' do + school = create(:school, id: SecureRandom.uuid) + teacher = create(:teacher, school:) + school_class = create(:school_class, school:, teacher_ids: [teacher.id]) + + new_params = { lesson: params[:lesson].merge(school_class_id: school_class.id) } + put("/api/lessons/#{lesson.id}", headers:, params: new_params) + + expect(response).to have_http_status(:unprocessable_entity) + end + end +end diff --git a/spec/features/my_school/showing_my_school_spec.rb b/spec/features/my_school/showing_my_school_spec.rb new file mode 100644 index 000000000..eb3a2d4c2 --- /dev/null +++ b/spec/features/my_school/showing_my_school_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing my school', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let!(:school) { create(:school, name: 'school-name') } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:owner) { create(:owner, school:) } + + it 'responds 200 OK' do + get('/api/school', headers:) + expect(response).to have_http_status(:ok) + end + + it "includes the school details and user's roles in the JSON" do + school_json = school.to_json(only: %i[id name website reference address_line_1 address_line_2 municipality administrative_area postal_code country_code code verified_at created_at updated_at]) + expected_data = JSON.parse(school_json, symbolize_names: true).merge(roles: ['owner']) + + get('/api/school', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data).to eq(expected_data) + end + + it "responds 404 Not Found when the user doesn't have a role in any school" do + Role.find_by(school:, user_id: owner.id).delete + get('/api/school', headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 401 Unauthorized when no token is given' do + get '/api/school' + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/features/project/creating_a_project_spec.rb b/spec/features/project/creating_a_project_spec.rb new file mode 100644 index 000000000..c8b671deb --- /dev/null +++ b/spec/features/project/creating_a_project_spec.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a project', type: :request do + let(:generated_identifier) { 'word1-word2-word3' } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:params) do + { + project: { + name: 'Test Project', + components: [ + { name: 'main', extension: 'py', content: 'print("hi")' } + ] + } + } + end + + before do + authenticated_in_hydra_as(teacher) + mock_phrase_generation(generated_identifier) + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'generates an identifier for the project even if another identifier is specified' do + params_with_identifier = { project: { identifier: 'test-identifier', components: [] } } + post('/api/projects', headers:, params: params_with_identifier) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:identifier]).to eq(generated_identifier) + end + + it 'responds with the project JSON' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Project') + end + + it 'responds with the components JSON' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:components].first[:content]).to eq('print("hi")') + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post('/api/projects', headers:, params: { project: { components: [{ name: ' ' }] } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post('/api/projects', params:) + expect(response).to have_http_status(:unauthorized) + end + + context 'when the project is associated with a school (library)' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + + let(:params) do + { + project: { + name: 'Test Project', + components: [], + school_id: school.id, + user_id: teacher.id + } + } + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher for the school' do + authenticated_in_hydra_as(teacher) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user is a school-student for the school' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'sets the lesson user to the specified user for school-owner users' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher.id) + end + + it 'sets the project user to the current user for school-teacher users' do + authenticated_in_hydra_as(teacher) + new_params = { project: params[:project].merge(user_id: 'ignored') } + + post('/api/projects', headers:, params: new_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher.id) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.teacher.find_by(user_id: teacher.id, school:).delete + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when the project is associated with a lesson' do + let(:school) { create(:school) } + let(:lesson) { create(:lesson, school:, user_id: teacher.id) } + let(:lesson_created_by_owner) { create(:lesson, school:, user_id: owner.id) } + let(:teacher) { create(:teacher, school:) } + + let(:params) do + { + project: { + name: 'Test Project', + components: [], + school_id: school.id, + lesson_id: lesson.id, + user_id: teacher.id + } + } + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the current user is the owner of the lesson' do + authenticated_in_hydra_as(teacher) + lesson.update!(user_id: teacher.id) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 422 Unprocessable when when the user_id is not the owner of the lesson' do + user_id = SecureRandom.uuid + project = { + project: { + name: 'Test Project', + components: [], + school_id: school.id, + lesson_id: lesson_created_by_owner.id, + user_id: teacher.id + } + } + new_params = { project: project.merge(user_id:) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable when lesson_id is provided but school_id is missing' do + new_params = { project: params[:project].without(:school_id) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable when lesson_id does not correspond to school_id' do + new_params = { project: params[:project].merge(lesson_id: SecureRandom.uuid) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + new_params = { project: params[:project].without(:lesson_id).merge(school_id: SecureRandom.uuid) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the current user is not the owner of the lesson' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when an Experience CS admin creates a starter Scratch project' do + let(:experience_cs_admin) { create(:experience_cs_admin_user) } + let(:params) do + { + project: { + identifier: 'test-project', + name: 'Test Project', + locale: 'fr', + project_type: Project::Types::SCRATCH, + user_id: nil, + components: [] + } + } + end + + before do + authenticated_in_hydra_as(experience_cs_admin) + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'sets the project identifier to the specified (not the generated) value' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:identifier]).to eq('test-project') + end + + it 'sets the project name to the specified value' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Project') + end + + it 'sets the project locale to the specified value' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:locale]).to eq('fr') + end + + it 'sets the project type to the specified value' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:project_type]).to eq(Project::Types::SCRATCH) + end + + it 'sets the project user_id to the specified value (i.e. nil to represent a public project)' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to be_nil + end + end +end diff --git a/spec/features/project/updating_a_project_spec.rb b/spec/features/project/updating_a_project_spec.rb new file mode 100644 index 000000000..871574a23 --- /dev/null +++ b/spec/features/project/updating_a_project_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a project', type: :request do + before do + authenticated_in_hydra_as(owner) + + create(:component, project:, name: 'main', extension: 'py', content: 'print("hi")') + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:project_type) { Project::Types::PYTHON } + let(:user_id) { owner.id } + let!(:project) { create(:project, name: 'Test Project', user_id:, locale: 'en', project_type:) } + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } + + let(:params) do + { + project: { + name: 'New Name', + components: [ + { name: 'main', extension: 'py', content: 'print("hello")' } + ] + } + } + end + + it 'responds 200 OK' do + put("/api/projects/#{project.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the project JSON' do + put("/api/projects/#{project.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds with the components JSON' do + put("/api/projects/#{project.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:components].first[:content]).to eq('print("hello")') + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/projects/#{project.id}", headers:, params: { project: { components: [{ name: ' ' }] } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/projects/#{project.id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + context 'when an Experience CS admin creates a starter Scratch project' do + let(:experience_cs_admin) { create(:experience_cs_admin_user) } + let(:user_id) { nil } + let(:project_type) { Project::Types::SCRATCH } + let(:params) { { project: { name: 'Test Project' } } } + + before do + authenticated_in_hydra_as(experience_cs_admin) + end + + it 'responds 200 OK' do + put("/api/projects/#{project.identifier}", headers:, params:) + expect(response).to have_http_status(:success) + end + + it 'sets the project name to the specified value' do + put("/api/projects/#{project.identifier}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Project') + end + end +end diff --git a/spec/features/school/creating_a_school_spec.rb b/spec/features/school/creating_a_school_spec.rb new file mode 100644 index 000000000..fd51863ed --- /dev/null +++ b/spec/features/school/creating_a_school_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a school', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school: { + name: 'Test School', + website: 'http://www.example.com/', + address_line_1: 'Address Line 1', + municipality: 'Greater London', + country_code: 'GB', + creator_agree_authority: true, + creator_agree_terms_and_conditions: true, + creator_agree_to_ux_contact: true, + creator_agree_responsible_safeguarding: true, + user_origin: 'for_education' + } + } + end + + it 'responds 201 Created' do + post('/api/schools', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the school JSON' do + post('/api/schools', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School') + end + + it 'responds 400 Bad Request when params are missing' do + post('/api/schools', headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post('/api/schools', headers:, params: { school: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post '/api/schools' + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/features/school/deleting_a_school_spec.rb b/spec/features/school/deleting_a_school_spec.rb new file mode 100644 index 000000000..aa54ecf21 --- /dev/null +++ b/spec/features/school/deleting_a_school_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a school', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb new file mode 100644 index 000000000..6b45e7384 --- /dev/null +++ b/spec/features/school/listing_schools_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing schools', type: :request do + before do + school = create(:school, name: 'Test School') + owner = create(:owner, school:) + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + it 'responds 200 OK' do + get('/api/schools', headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the schools JSON' do + get('/api/schools', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test School') + end + + it 'only includes schools the user belongs to' do + create(:school, id: SecureRandom.uuid) + + get('/api/schools', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'responds 401 Unauthorized when no token is given' do + get '/api/schools' + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/features/school/showing_a_school_spec.rb b/spec/features/school/showing_a_school_spec.rb new file mode 100644 index 000000000..edf1c9fc7 --- /dev/null +++ b/spec/features/school/showing_a_school_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a school', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let!(:school) { create(:school, name: 'Test School') } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:owner) { create(:owner, school:) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school JSON' do + get("/api/schools/#{school.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School') + end + + it 'responds 404 Not Found when no school exists' do + get('/api/schools/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user belongs to a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school/updating_a_school_spec.rb b/spec/features/school/updating_a_school_spec.rb new file mode 100644 index 000000000..441388228 --- /dev/null +++ b/spec/features/school/updating_a_school_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a school', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let!(:school) { create(:school) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school: { + name: 'New Name' + } + } + end + + it 'responds 200 OK' do + put("/api/schools/#{school.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school JSON' do + put("/api/schools/#{school.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds 404 Not Found when no school exists' do + put('/api/schools/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 400 Bad Request when params are missing' do + put("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/schools/#{school.id}", headers:, params: { school: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put "/api/schools/#{school.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is not a school-owner' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + put("/api/schools/#{school.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + put("/api/schools/#{school.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb new file mode 100644 index 000000000..b113ca12e --- /dev/null +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a school class', type: :request do + before do + authenticated_in_hydra_as(teacher) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + + let(:params) do + { + school_class: { + name: 'Test School Class', + description: 'Test School Class Description' + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the school class JSON containing the correct name' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School Class') + end + + it 'responds with the school class JSON containing the correct description' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:description]).to eq('Test School Class Description') + end + + it 'responds with the teacher JSON' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:name]).to eq('School Teacher') + end + + it 'sets the class teacher to the specified user for school-owner users' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:id]).to eq(teacher.id) + end + + it 'sets the class teacher to the current user for school-teacher users' do + authenticated_in_hydra_as(teacher) + + new_params = { school_class: params[:school_class].merge(teacher_id: 'ignored') } + + post("/api/schools/#{school.id}/classes", headers:, params: new_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:id]).to eq(teacher.id) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/classes", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/classes", headers:, params: { school_class: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post "/api/schools/#{school.id}/classes" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.teacher.find_by(user_id: teacher.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_class/deleting_a_school_class_spec.rb b/spec/features/school_class/deleting_a_school_class_spec.rb new file mode 100644 index 000000000..6d336a8d8 --- /dev/null +++ b/spec/features/school_class/deleting_a_school_class_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a school class', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:owner) { create(:owner, school:) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is the class teacher' do + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/classes/#{school_class.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb new file mode 100644 index 000000000..52d1e6282 --- /dev/null +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school classes', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for_users([teacher, owner_teacher].map(&:id), users: [owner_teacher, teacher]) + + create(:class_student, school_class:, student_id: student.id) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, name: 'Test School Class', teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:owner) { create(:owner, school:) } + + let(:owner_teacher) { create(:teacher, school:, id: owner.id, name: owner.name, email: owner.email) } + let!(:owner_school_class) { create(:school_class, name: 'Owner School Class', teacher_ids: [owner_teacher.id], school:) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/classes", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school classes JSON' do + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test School Class') + end + + it 'only responds with the user\'s classes if the my_classes param is present' do + get("/api/schools/#{school.id}/classes?my_classes=true", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq(owner_school_class.name) + end + + it 'responds with the teachers JSON' do + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + expect(data.first[:teachers].first[:name]).to eq('School Teacher') + end + + it "skips teachers if the user profile doesn't exist" do + stub_user_info_api_for_unknown_users(user_id: teacher.id) + + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + expect(data.first[:teachers].first).to be_nil + end + + it "does not include school classes that the school-teacher doesn't teach" do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + create(:school_class, school:, teacher_ids: [teacher.id]) + + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it "does not include school classes that the school-student isn't a member of" do + authenticated_in_hydra_as(student) + stub_user_info_api_for(teacher) + create(:school_class, school:, teacher_ids: [teacher.id]) + + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/classes" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_class/showing_a_school_class_spec.rb b/spec/features/school_class/showing_a_school_class_spec.rb new file mode 100644 index 000000000..80f84f7cd --- /dev/null +++ b/spec/features/school_class/showing_a_school_class_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a school class', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for(teacher) + end + + let!(:school_class) { create(:school_class, name: 'Test School Class', teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:owner) { create(:owner, school:) } + + context 'when school and class ids are provided' do + it 'responds 200 OK' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is the class teacher' do + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a student in the class' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + create(:class_student, school_class:, student_id: student.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school class JSON' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School Class') + end + + it 'includes the school class code in the response' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:code]).to eq(school_class.code) + end + + it 'responds with the teacher JSON' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:name]).to eq('School Teacher') + end + + it "responds with nil attributes for the teacher if their user profile doesn't exist" do + stub_user_info_api_for_unknown_users(user_id: teacher.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to be_nil + end + + it 'responds 404 Not Found when no school exists' do + get("/api/schools/not-a-real-id/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 404 Not Found when no school class exists' do + get("/api/schools/#{school.id}/classes/not-a-real-id", headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/classes/#{school_class.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not a school-student for the class' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when school and class codes are provided' do + before do + school.verify! + end + + it 'responds 200 OK' do + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is the class teacher' do + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a student in the class' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + create(:class_student, school_class:, student_id: student.id) + + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school class JSON' do + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School Class') + end + + it 'includes the school class code in the response' do + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:code]).to eq(school_class.code) + end + + it 'responds with the teacher JSON' do + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:name]).to eq('School Teacher') + end + + it "responds with nil attributes for the teacher if their user profile doesn't exist" do + stub_user_info_api_for_unknown_users(user_id: teacher.id) + + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to be_nil + end + + it 'responds 404 Not Found when no school exists' do + get("/api/schools/not-a-real-code/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 404 Not Found when no school class exists' do + get("/api/schools/#{school.code}/classes/not-a-real-code", headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.code}/classes/#{school_class.code}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school.verify! + school_class.update!(school_id: school.id) + + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not a school-student for the class' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.code}/classes/#{school_class.code}", headers:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb new file mode 100644 index 000000000..21a31fce6 --- /dev/null +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a school class', type: :request do + before do + authenticated_in_hydra_as(teacher) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let!(:school_class) { create(:school_class, name: 'Test School Class', school:, teacher_ids: [teacher.id]) } + + let(:params) do + { + school_class: { + name: 'New Name' + } + } + end + + it 'responds 200 OK' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is the school-teacher for the class' do + authenticated_in_hydra_as(teacher) + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school class JSON' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds with the teacher JSON' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:name]).to eq('School Teacher') + end + + it 'responds 400 Bad Request when params are missing' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params: { school_class: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_member/listing_school_members_spec.rb b/spec/features/school_member/listing_school_members_spec.rb new file mode 100644 index 000000000..64c6abe28 --- /dev/null +++ b/spec/features/school_member/listing_school_members_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school members', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:teacher) { create(:teacher, school:) } + let(:students) { create_list(:student, 3, school:) } + + before do + authenticated_in_hydra_as(owner) + + student_attributes = students.map do |student| + { id: student.id, name: student.name, username: student.username } + end + stub_profile_api_list_school_students(school:, student_attributes:) + stub_user_info_api_for(teacher) + stub_profile_api_create_safeguarding_flag + end + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/members", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school members JSON array' do + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(5) + end + + it 'responds with the correct student ids' do + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + student_ids = data.pluck(:student).compact.pluck(:id) + + students.each do |student| + expect(student_ids).to include(student.id) + end + end + + it 'responds with the expected student parameters' do + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + student_data = data.pluck(:student).compact.find { |member| member[:id] == students[0].id } + + expect(student_data).to eq( + { + id: students[0].id, + username: students[0].username, + name: students[0].name, + type: 'student' + } + ) + end + + it 'responds with the correct teacher id' do + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + teacher_id = data.pluck(:teacher).compact.pick(:id) + + expect(teacher_id).to eq(teacher.id) + end + + it 'responds with the expected teacher parameters' do + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + teacher_data = data.pluck(:teacher).compact + + expect(teacher_data.first).to eq( + { + id: teacher.id, + name: teacher.name, + email: teacher.email, + type: 'teacher' + } + ) + end + + it 'responds with students in alphabetical order by name ascending' do + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + names = data.map { |member| member.values.first[:name] } + sorted_names = names.sort + + expect(names).to eq(sorted_names) + end + + it 'creates the school owner safeguarding flag' do + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag' do + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: owner.email) + end + + it "responds with nil attributes for students if the user profile doesn't exist" do + stub_user_info_api_for_unknown_users(user_id: students.first.id) + + get("/api/schools/#{school.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:student_name]).to be_nil + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/members" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'does not create the school owner safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'creates the school teacher safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: teacher.email) + end +end diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb new file mode 100644 index 000000000..60ec3f2c6 --- /dev/null +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Inviting a school owner', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_invite_school_owner + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:verified_school) } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school_owner: { + email_address: 'owner-to-invite@example.com' + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/owners", headers:, params: { school_owner: { email_address: 'invalid' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/owners", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_owner/listing_school_owners_spec.rb b/spec/features/school_owner/listing_school_owners_spec.rb new file mode 100644 index 000000000..558be9547 --- /dev/null +++ b/spec/features/school_owner/listing_school_owners_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school owners', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_list_school_owners(user_id: owner.id) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:, name: 'School Owner') } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school owners JSON' do + get("/api/schools/#{school.id}/owners", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('School Owner') + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/owners" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_owner/removing_a_school_owner_spec.rb b/spec/features/school_owner/removing_a_school_owner_spec.rb new file mode 100644 index 000000000..ab41a1c0b --- /dev/null +++ b/spec/features/school_owner/removing_a_school_owner_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Removing a school owner', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_remove_school_owner + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/owners/#{owner.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/owners/#{owner.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/owners/#{owner.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/owners/#{owner.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/schools/#{school.id}/owners/#{owner.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_student/creating_a_batch_of_school_students_spec.rb b/spec/features/school_student/creating_a_batch_of_school_students_spec.rb new file mode 100644 index 000000000..eabb536b1 --- /dev/null +++ b/spec/features/school_student/creating_a_batch_of_school_students_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a batch of school students', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_create_school_students + stub_profile_api_create_safeguarding_flag + + # UserJob will fail validation as it won't find our test job, so we need to double it + allow(CreateStudentsJob).to receive(:attempt_perform_later).and_return( + instance_double(CreateStudentsJob, job_id: SecureRandom.uuid) + ) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:verified_school) } + let(:student_id) { SecureRandom.uuid } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school_students: [ + { + username: 'student-to-create', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student' + }, + { + username: 'second-student-to-create', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student 2' + } + ] + } + end + + let(:bad_params) do + { + school_students: [ + { + username: 'student-to-create', + password: 'Password', + name: 'School Student' + }, + { + username: 'second-student-to-create', + password: 'Password', + name: 'School Student 2' + } + ] + } + end + + it 'creates the school owner safeguarding flag' do + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag' do + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: owner.email) + end + + it 'responds 202 Accepted' do + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(response).to have_http_status(:accepted) + end + + it 'responds 202 Accepted when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(response).to have_http_status(:accepted) + end + + it 'does not create the school owner safeguarding flag when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'creates the school teacher safeguarding flag when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: teacher.email) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/students/batch", headers:, params: { school_students: [] }) + expect(response).to have_http_status(:unprocessable_entity) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'responds 422 Unprocessable Entity with a suitable message when params are invalid' do + post("/api/schools/#{school.id}/students/batch", headers:, params: bad_params) + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('Error creating school students: Decryption failed: iv must be 16 bytes') + end + # rubocop:enable RSpec/MultipleExpectations + + # rubocop:disable RSpec/MultipleExpectations + it 'responds 422 Unprocessable Entity with a JSON array of validation errors' do + stub_profile_api_create_school_students_validation_error + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to eq('{"error":{"student-to-create":["Username must be unique in the batch data","Password is too simple (it should not be easily guessable, \\u003ca href=\"https://my.raspberrypi.org/password-help/"\\u003eneed password help?\\u003c/a\\u003e)","You must supply a name"],"another-student-to-create-2":["Password must be at least 8 characters","You must supply a name"]},"error_type":"validation_error"}') + end + # rubocop:enable RSpec/MultipleExpectations + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/students/batch", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/students/batch", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_student/creating_a_school_student_spec.rb b/spec/features/school_student/creating_a_school_student_spec.rb new file mode 100644 index 000000000..bb01c2bce --- /dev/null +++ b/spec/features/school_student/creating_a_school_student_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a school student', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_create_school_student + stub_profile_api_create_safeguarding_flag + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:verified_school) } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school_student: { + username: 'student123', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student' + } + } + end + + it 'creates the school owner safeguarding flag' do + post("/api/schools/#{school.id}/students", headers:, params:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag' do + post("/api/schools/#{school.id}/students", headers:, params:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: owner.email) + end + + it 'responds 204 No Content' do + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:no_content) + end + + it 'does not create the school owner safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'creates the school teacher safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: teacher.email) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/students", headers:, params: { school_student: { username: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/students", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_student/deleting_a_school_student_spec.rb b/spec/features/school_student/deleting_a_school_student_spec.rb new file mode 100644 index 000000000..f6cd818f2 --- /dev/null +++ b/spec/features/school_student/deleting_a_school_student_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a school student', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_delete_school_student + stub_profile_api_create_safeguarding_flag + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student_id) { SecureRandom.uuid } + let(:owner) { create(:owner, school:) } + + it 'creates the school owner safeguarding flag' do + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag' do + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: owner.email) + end + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/students/#{student_id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'does not create the school owner safeguarding flag when logged in as a teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag when logged in as a teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: teacher.email) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_student/listing_school_students_spec.rb b/spec/features/school_student/listing_school_students_spec.rb new file mode 100644 index 000000000..b5e5831fe --- /dev/null +++ b/spec/features/school_student/listing_school_students_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school students', type: :request do + before do + authenticated_in_hydra_as(owner) + student_attributes = [{ id: student.id, name: 'School Student' }] + stub_profile_api_list_school_students(school:, student_attributes:) + stub_profile_api_create_safeguarding_flag + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:owner) { create(:owner, school:) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:ok) + end + + it 'creates the school owner safeguarding flag' do + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag' do + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: owner.email) + end + + it 'responds 200 OK when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:ok) + end + + it 'does not create the school owner safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'creates the school teacher safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + get("/api/schools/#{school.id}/students", headers:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: teacher.email) + end + + it 'responds with the school students JSON' do + get("/api/schools/#{school.id}/students", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('School Student') + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/students" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.student.find_by(user_id: student.id, school:).delete + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_student/updating_a_school_student_spec.rb b/spec/features/school_student/updating_a_school_student_spec.rb new file mode 100644 index 000000000..180e64026 --- /dev/null +++ b/spec/features/school_student/updating_a_school_student_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a school student', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_update_school_student + stub_profile_api_create_safeguarding_flag + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student_id) { SecureRandom.uuid } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school_student: { + username: 'new-username', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'New Name' + } + } + end + + it 'creates the school owner safeguarding flag' do + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'does not create the school teacher safeguarding flag' do + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: owner.email) + end + + it 'responds 204 No Content' do + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:no_content) + end + + it 'does not create the school owner safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(ProfileApiClient).not_to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner], email: owner.email) + end + + it 'creates the school teacher safeguarding flag when the user is a school teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(ProfileApiClient).to have_received(:create_safeguarding_flag).with(token: UserProfileMock::TOKEN, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher], email: teacher.email) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/schools/#{school.id}/students/#{student_id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb new file mode 100644 index 000000000..3dcac0df2 --- /dev/null +++ b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Inviting a school teacher', type: :request do + before do + authenticated_in_hydra_as(owner) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:verified_school) } + let(:teacher_id) { SecureRandom.uuid } + let(:owner) { create(:owner, school:) } + + let(:params) do + { + school_teacher: { + email_address: 'teacher-to-invite@example.com' + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/teachers", headers:, params: { school_teacher: { email_address: 'invalid' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/teachers", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_teacher/listing_school_teachers_spec.rb b/spec/features/school_teacher/listing_school_teachers_spec.rb new file mode 100644 index 000000000..02fed9584 --- /dev/null +++ b/spec/features/school_teacher/listing_school_teachers_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school teachers', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:teacher_2) { create(:teacher, school:) } + + before do + authenticated_in_hydra_as(owner) + stub_user_info_api_for(teacher) + end + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/teachers" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.teacher.find_by(user_id: teacher.id, school:).delete + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 200 OK when the user is a school-teacher' do + stub_user_info_api_for_users([teacher.id, teacher_2.id], users: [teacher, teacher_2]) + authenticated_in_hydra_as(teacher_2) + + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school teachers JSON' do + stub_user_info_api_for_users([teacher.id, teacher_2.id], users: [teacher, teacher_2]) + authenticated_in_hydra_as(teacher_2) + + get("/api/schools/#{school.id}/teachers", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.pluck(:name)).to include(teacher_2.name) + end +end diff --git a/spec/features/school_teacher/removing_a_school_teacher_spec.rb b/spec/features/school_teacher/removing_a_school_teacher_spec.rb new file mode 100644 index 000000000..8dda43606 --- /dev/null +++ b/spec/features/school_teacher/removing_a_school_teacher_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Removing a school teacher', type: :request do + before do + authenticated_in_hydra_as(owner) + stub_profile_api_remove_school_teacher + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher_id) { SecureRandom.uuid } + let(:owner) { create(:owner, school:) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/teachers/#{teacher_id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + Role.owner.find_by(user_id: owner.id, school:).delete + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/teacher_invitations/accepting_an_invitation_spec.rb b/spec/features/teacher_invitations/accepting_an_invitation_spec.rb new file mode 100644 index 000000000..d612300df --- /dev/null +++ b/spec/features/teacher_invitations/accepting_an_invitation_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Accepting an invitations', type: :request do + include ActiveSupport::Testing::TimeHelpers + + let(:user) { create(:user) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + context 'when user is not logged in' do + it 'responds 401 Unauthorized' do + put('/api/teacher_invitations/fake-token/accept') + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user is logged in' do + before do + authenticated_in_hydra_as(user) + end + + context 'when invitation does not exist' do + let(:invitation) { build(:teacher_invitation) } + let!(:token) { invitation.generate_token_for(:teacher_invitation) } + + it 'responds 404 Not Found' do + put("/api/teacher_invitations/#{token}/accept", headers:) + expect(response).to have_http_status(:not_found) + end + end + + context 'when invitation exists' do + let(:school) { create(:verified_school) } + let(:invitation_email) { user.email } + let(:invitation) { create(:teacher_invitation, email_address: invitation_email, school:) } + let!(:token) { invitation.generate_token_for(:teacher_invitation) } + + context 'when invitation token is not valid because invitation email has changed' do + before do + invitation.update!(email_address: "not-#{invitation.email_address}") + end + + it 'responds 403 Forbidden' do + put("/api/teacher_invitations/#{token}/accept", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when invitation token is not valid because token has expired' do + it 'responds 403 Forbidden' do + travel 31.days do + put("/api/teacher_invitations/#{token}/accept", headers:) + end + expect(response).to have_http_status(:forbidden) + end + end + + context 'when invitation email does not match user email' do + let(:invitation_email) { "not-#{user.email}" } + + it 'responds 403 Forbidden' do + put("/api/teacher_invitations/#{token}/accept", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when user already has student role for the same school' do + before do + Role.student.create!(user_id: user.id, school:) + end + + it 'responds 422 Unprocessable entity' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'does not give the user the teacher role for the school to which they have been invited' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(user).not_to be_school_teacher(school) + end + + it 'includes validation errors in response' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + json = JSON.parse(response.body) + expect(json['error']).to eq({ 'base' => ['Cannot create teacher role as this user already has the student role for this school'] }) + end + + it 'does not set the accepted_at timestamp on the invitation' do + freeze_time(with_usec: false) do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(invitation.reload.accepted_at).to be_blank + end + end + end + + context 'when user already has a role for another school' do + let(:another_shool) { create(:school) } + + before do + Role.teacher.create!(user_id: user.id, school: another_shool) + end + + it 'responds 422 Unprocessable entity' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'does not give the user the teacher role for the school to which they have been invited' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(user).not_to be_school_teacher(school) + end + + it 'includes validation errors in response' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + json = JSON.parse(response.body) + expect(json['error']).to eq({ 'base' => ['Cannot create role as this user already has a role in a different school'] }) + end + + it 'does not set the accepted_at timestamp on the invitation' do + freeze_time(with_usec: false) do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(invitation.reload.accepted_at).to be_blank + end + end + end + + context 'when invitation token is valid' do + it 'responds 200 OK' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(response).to have_http_status(:ok) + end + + it 'gives the user the teacher role for the school to which they have been invited' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(user).to be_school_teacher(school) + end + + it 'sets the accepted_at timestamp on the invitation' do + freeze_time(with_usec: false) do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(invitation.reload.accepted_at).to eq(Time.current) + end + end + end + + context 'when invitation has already been accepted' do + let(:original_accepted_at) { 1.week.ago.noon } + + before do + invitation.update!(accepted_at: original_accepted_at) + end + + it 'responds 200 OK' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(response).to have_http_status(:ok) + end + + it 'does not update the accepted_at timestamp on the invitation' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(invitation.reload.accepted_at).to eq(original_accepted_at) + end + end + + context 'when user already has teacher role for the same school' do + before do + Role.teacher.create!(user_id: user.id, school:) + end + + it 'responds 200 OK' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(response).to have_http_status(:ok) + end + + it 'leaves the user with the teacher role for that school' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(user).to be_school_teacher(school) + end + + it 'sets the accepted_at timestamp on the invitation' do + freeze_time(with_usec: false) do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(invitation.reload.accepted_at).to eq(Time.current) + end + end + end + + context 'when user already has owner role for the same school' do + before do + Role.owner.create!(user_id: user.id, school:) + end + + it 'responds 200 OK' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(response).to have_http_status(:ok) + end + + it 'gives the user the teacher role for the school to which they have been invited' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(user).to be_school_teacher(school) + end + + it 'leaves the user with the owner role for that school' do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(user).to be_school_owner(school) + end + + it 'sets the accepted_at timestamp on the invitation' do + freeze_time(with_usec: false) do + put("/api/teacher_invitations/#{token}/accept", headers:) + + expect(invitation.reload.accepted_at).to eq(Time.current) + end + end + end + end + end +end diff --git a/spec/features/teacher_invitations/viewing_an_invitation_spec.rb b/spec/features/teacher_invitations/viewing_an_invitation_spec.rb new file mode 100644 index 000000000..5e11a0919 --- /dev/null +++ b/spec/features/teacher_invitations/viewing_an_invitation_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Viewing an invitations', type: :request do + include ActiveSupport::Testing::TimeHelpers + + let(:user) { create(:user) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + context 'when user is not logged in' do + it 'responds 401 Unauthorized' do + get('/api/teacher_invitations/fake-token') + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user is logged in' do + before do + authenticated_in_hydra_as(user) + end + + context 'when invitation does not exist' do + let(:invitation) { build(:teacher_invitation) } + let!(:token) { invitation.generate_token_for(:teacher_invitation) } + + it 'responds 404 Not Found' do + get("/api/teacher_invitations/#{token}", headers:) + expect(response).to have_http_status(:not_found) + end + end + + context 'when invitation exists' do + let(:invitation_email) { user.email } + let(:invitation) { create(:teacher_invitation, email_address: invitation_email) } + let!(:token) { invitation.generate_token_for(:teacher_invitation) } + + context 'when invitation token is not valid because invitation email has changed' do + before do + invitation.update!(email_address: "not-#{invitation.email_address}") + end + + it 'responds 403 Forbidden' do + get("/api/teacher_invitations/#{token}", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when invitation token is not valid because token has expired' do + it 'responds 403 Forbidden' do + travel 31.days do + get("/api/teacher_invitations/#{token}", headers:) + end + expect(response).to have_http_status(:forbidden) + end + end + + context 'when invitation email does not match user email' do + let(:invitation_email) { "not-#{user.email}" } + + it 'responds 403 Forbidden' do + get("/api/teacher_invitations/#{token}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'includes error message in response' do + get("/api/teacher_invitations/#{token}", headers:) + + json = JSON.parse(response.body) + expect(json['error']).to eq('Invitation email does not match user email') + end + end + + context 'when invitation token is valid' do + it 'responds 200 OK' do + get("/api/teacher_invitations/#{token}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'includes school name in response' do + get("/api/teacher_invitations/#{token}", headers:) + + json = JSON.parse(response.body) + expect(json['school_name']).to eq(invitation.school_name) + end + end + end + end +end diff --git a/spec/features/user_job/showing_user_jobs_spec.rb b/spec/features/user_job/showing_user_jobs_spec.rb new file mode 100644 index 000000000..54eac2112 --- /dev/null +++ b/spec/features/user_job/showing_user_jobs_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing user jobs', type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + let(:good_jobs) { create_list(:good_job, 2) } + let!(:user_jobs) { good_jobs.map { |job| create(:user_job, good_job: job, user_id: owner.id) } } + + before do + authenticated_in_hydra_as(owner) + end + + it 'responds 200 OK' do + get('/api/user_jobs', headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 401 Unauthorized when no token is given' do + get '/api/user_jobs' + expect(response).to have_http_status(:unauthorized) + end + + it 'responds with a list of jobs' do + get('/api/user_jobs', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:jobs].pluck(:id)).to eq(user_jobs.pluck(:good_job_id)) + end + + it 'responds with the expected job' do + job_id = user_jobs.first.good_job_id + + get("/api/user_jobs/#{job_id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:job][:id]).to eq(job_id) + end +end diff --git a/spec/fixtures/files/students-invalid.csv b/spec/fixtures/files/students-invalid.csv new file mode 100644 index 000000000..5d0b80523 --- /dev/null +++ b/spec/fixtures/files/students-invalid.csv @@ -0,0 +1,9 @@ +Student Name,Username,Password +Jane Doe,jane123,secret123 +John Doe,john123,secret456 +,, +Jack Doe,jack123,invalid +,, + ,jade123,secret789 +Jacob Doe, ,secret012 +Julia Doe,julia123, diff --git a/spec/fixtures/files/students.csv b/spec/fixtures/files/students.csv new file mode 100644 index 000000000..0bf9f9eaf --- /dev/null +++ b/spec/fixtures/files/students.csv @@ -0,0 +1,3 @@ +Student Name,Username,Password +Jane Doe,jane123,secret123 +John Doe,john123,secret456 diff --git a/spec/fixtures/files/students.xlsx b/spec/fixtures/files/students.xlsx new file mode 100644 index 000000000..36989f4a6 Binary files /dev/null and b/spec/fixtures/files/students.xlsx differ diff --git a/spec/fixtures/files/test_audio_1.mp3 b/spec/fixtures/files/test_audio_1.mp3 new file mode 100644 index 000000000..5b7a89f9d Binary files /dev/null and b/spec/fixtures/files/test_audio_1.mp3 differ diff --git a/spec/fixtures/files/test_video_1.mp4 b/spec/fixtures/files/test_video_1.mp4 new file mode 100644 index 000000000..163aff31e Binary files /dev/null and b/spec/fixtures/files/test_video_1.mp4 differ diff --git a/spec/graphql/mutations/create_component_mutation_spec.rb b/spec/graphql/mutations/create_component_mutation_spec.rb index 65e39ee18..15a0cebc9 100644 --- a/spec/graphql/mutations/create_component_mutation_spec.rb +++ b/spec/graphql/mutations/create_component_mutation_spec.rb @@ -43,8 +43,14 @@ end context 'when authenticated' do - let(:current_user_id) { SecureRandom.uuid } - let(:project) { create(:project, user_id: current_user_id) } + let(:current_user) { authenticated_user } + let(:project) { create(:project, user_id: authenticated_user.id) } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + before do + authenticated_in_hydra_as(owner) + end it 'returns the component ID' do expect(result.dig('data', 'createComponent', 'component', 'id')).not_to be_nil diff --git a/spec/graphql/mutations/create_project_mutation_spec.rb b/spec/graphql/mutations/create_project_mutation_spec.rb index d44021413..07ee1af9a 100644 --- a/spec/graphql/mutations/create_project_mutation_spec.rb +++ b/spec/graphql/mutations/create_project_mutation_spec.rb @@ -10,7 +10,7 @@ { project: { name: 'Untitled project', - projectType: 'python', + projectType: Project::Types::PYTHON, components: [{ content: 'Insert Python Here', default: true, @@ -42,9 +42,14 @@ end context 'when authenticated' do - let(:current_user_id) { SecureRandom.uuid } + let(:current_user) { authenticated_user } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } - before { mock_phrase_generation } + before do + authenticated_in_hydra_as(owner) + mock_phrase_generation + end it 'returns the project ID' do expect(result.dig('data', 'createProject', 'project', 'id')).to eq Project.first.to_gid_param diff --git a/spec/graphql/mutations/delete_project_mutation_spec.rb b/spec/graphql/mutations/delete_project_mutation_spec.rb index 85fd1116b..f648a9a57 100644 --- a/spec/graphql/mutations/delete_project_mutation_spec.rb +++ b/spec/graphql/mutations/delete_project_mutation_spec.rb @@ -5,6 +5,12 @@ RSpec.describe 'mutation DeleteProject() { ... }' do subject(:result) { execute_query(query: mutation, variables:) } + before do + authenticated_in_hydra_as(owner) + end + + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } let(:mutation) { 'mutation DeleteProject($project: DeleteProjectInput!) { deleteProject(input: $project) { id } }' } let(:project_id) { 'dummy-id' } let(:variables) { { project: { id: project_id } } } @@ -12,7 +18,7 @@ it { expect(mutation).to be_a_valid_graphql_query } context 'with an existing project' do - let!(:project) { create(:project, user_id: SecureRandom.uuid) } + let!(:project) { create(:project, user_id: authenticated_user.id) } let(:project_id) { project.to_gid_param } context 'when unauthenticated' do @@ -34,7 +40,12 @@ end context 'when authenticated' do - let(:current_user_id) { project.user_id } + let(:current_user) { authenticated_user } + let(:school) { create(:school) } + + before do + authenticated_in_hydra_as(authenticated_user) + end it 'deletes the project' do result @@ -50,7 +61,10 @@ end context 'with another users project' do - let(:current_user_id) { SecureRandom.uuid } + before do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + end it 'returns an error' do expect(result.dig('errors', 0, 'message')).to match(/not permitted/) diff --git a/spec/graphql/mutations/remix_project_mutation_spec.rb b/spec/graphql/mutations/remix_project_mutation_spec.rb index 428879338..3116cd9a4 100644 --- a/spec/graphql/mutations/remix_project_mutation_spec.rb +++ b/spec/graphql/mutations/remix_project_mutation_spec.rb @@ -15,18 +15,22 @@ } ' end - let(:project) { create(:project, :with_default_component, user_id: SecureRandom.uuid) } + let(:current_user) { authenticated_user } + let(:project) { create(:project, :with_default_component, user_id: authenticated_user.id) } let(:project_id) { project.to_gid_param } let(:variables) { { id: project_id } } + let(:remix_origin) { 'editor.com' } before do + owner = create(:owner, school: create(:school)) + authenticated_in_hydra_as(owner) project end it { expect(mutation).to be_a_valid_graphql_query } context 'when unauthenticated' do - let(:current_user_id) { nil } + let(:current_user) { nil } it 'does not create a project' do expect { result }.not_to change(Project, :count) @@ -39,7 +43,6 @@ context 'when original project not found' do let(:project_id) { SecureRandom.uuid } - let(:current_user_id) { SecureRandom.uuid } it 'returns "not found" error' do expect(result.dig('errors', 0, 'message')).to match(/not found/) @@ -51,7 +54,10 @@ end context 'when user cannot view original project' do - let(:current_user_id) { SecureRandom.uuid } + before do + teacher = create(:teacher, school: create(:school)) + authenticated_in_hydra_as(teacher) + end it 'returns "not permitted to read" error' do expect(result.dig('errors', 0, 'message')).to match(/not permitted to read/) @@ -63,7 +69,6 @@ end context 'when authenticated and project exists' do - let(:current_user_id) { project.user_id } let(:returned_gid) { result.dig('data', 'remixProject', 'project', 'id') } let(:remixed_project) { GlobalID.find(returned_gid) } @@ -75,6 +80,10 @@ expect(returned_gid).to eq Project.order(created_at: :asc).last.to_gid_param end + it 'sets the remix origin' do + expect(remixed_project.remix_origin).to eq('editor.com') + end + context 'when name and components not specified' do it 'uses original project name' do expect(remixed_project.name).to eq(project.name) diff --git a/spec/graphql/mutations/update_component_mutation_spec.rb b/spec/graphql/mutations/update_component_mutation_spec.rb index a9cfc96ec..3c04e405d 100644 --- a/spec/graphql/mutations/update_component_mutation_spec.rb +++ b/spec/graphql/mutations/update_component_mutation_spec.rb @@ -22,14 +22,16 @@ it { expect(mutation).to be_a_valid_graphql_query } context 'with an existing component' do - let(:component) { create(:component, name: 'bob', extension: 'html', content: 'new', default: true) } - let(:component_id) { component.to_gid_param } - before do - # Instantiate component - component + authenticated_in_hydra_as(owner) end + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:project) { create(:project, user_id: authenticated_user.id) } + let!(:component) { create(:component, project:, name: 'bob', extension: 'html', content: 'new', default: true) } + let(:component_id) { component.to_gid_param } + context 'when unauthenticated' do it 'does not update a component' do expect { result }.not_to change { component.reload.name } @@ -49,7 +51,7 @@ end context 'when authenticated' do - let(:current_user_id) { component.project.user_id } + let(:current_user) { authenticated_user } it 'updates the component name' do expect { result }.to change { component.reload.name }.from(component.name).to(variables.dig(:component, :name)) @@ -76,7 +78,10 @@ end context 'with another users component' do - let(:current_user_id) { SecureRandom.uuid } + before do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + end it 'returns an error' do expect(result.dig('errors', 0, 'message')).to match(/not permitted/) diff --git a/spec/graphql/mutations/update_project_mutation_spec.rb b/spec/graphql/mutations/update_project_mutation_spec.rb index 0d2d2e422..e6988645d 100644 --- a/spec/graphql/mutations/update_project_mutation_spec.rb +++ b/spec/graphql/mutations/update_project_mutation_spec.rb @@ -5,6 +5,12 @@ RSpec.describe 'mutation UpdateProject() { ... }' do subject(:result) { execute_query(query: mutation, variables:) } + before do + authenticated_in_hydra_as(owner) + end + + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } let(:mutation) { 'mutation UpdateProject($project: UpdateProjectInput!) { updateProject(input: $project) { project { id } } }' } let(:project_id) { 'dummy-id' } let(:variables) do @@ -12,7 +18,7 @@ project: { id: project_id, name: 'Untitled project again', - projectType: 'html' + projectType: Project::Types::HTML } } end @@ -20,12 +26,14 @@ it { expect(mutation).to be_a_valid_graphql_query } context 'with an existing project' do - let(:project) { create(:project, user_id: SecureRandom.uuid, project_type: :python) } + let(:project) { create(:project, user_id: authenticated_user.id, project_type: Project::Types::PYTHON) } let(:project_id) { project.to_gid_param } + let(:school) { create(:school) } before do # Instantiate project project + authenticated_in_hydra_as(authenticated_user) end context 'when unauthenticated' do @@ -47,14 +55,14 @@ end context 'when authenticated' do - let(:current_user_id) { project.user_id } + let(:current_user) { authenticated_user } it 'updates the project name' do expect { result }.to change { project.reload.name }.from(project.name).to(variables.dig(:project, :name)) end it 'updates the project type' do - expect { result }.to change { project.reload.project_type }.from(project.project_type).to('html') + expect { result }.to change { project.reload.project_type }.from(project.project_type).to(Project::Types::HTML) end context 'when the project cannot be found' do @@ -66,7 +74,10 @@ end context 'with another users project' do - let(:current_user_id) { SecureRandom.uuid } + before do + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) + end it 'returns an error' do expect(result.dig('errors', 0, 'message')).to match(/not permitted/) diff --git a/spec/graphql/queries/project_query_spec.rb b/spec/graphql/queries/project_query_spec.rb index 78ff3b81f..c43dc8477 100644 --- a/spec/graphql/queries/project_query_spec.rb +++ b/spec/graphql/queries/project_query_spec.rb @@ -88,8 +88,14 @@ end context 'when logged in' do - let(:current_user_id) { SecureRandom.uuid } - let(:project) { create(:project, user_id: current_user_id) } + let(:current_user) { authenticated_user } + let(:project) { create(:project, user_id: authenticated_user.id) } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + before do + authenticated_in_hydra_as(owner) + end it 'returns the project global id' do expect(result.dig('data', 'project', 'id')).to eq project.to_gid_param diff --git a/spec/graphql/queries/projects_query_spec.rb b/spec/graphql/queries/projects_query_spec.rb index eeeb9bacd..cb7a6ad1f 100644 --- a/spec/graphql/queries/projects_query_spec.rb +++ b/spec/graphql/queries/projects_query_spec.rb @@ -6,7 +6,7 @@ # NB: This is mostly tested via the `project_query_spec.rb` subject(:result) { execute_query(query:, variables:) } - let(:current_user_id) { nil } + let(:current_user) { nil } let(:variables) { {} } context 'when introspecting projects' do @@ -45,8 +45,14 @@ context 'when fetching project when logged in' do let(:query) { 'query { projects { edges { node { id } } } }' } - let(:current_user_id) { SecureRandom.uuid } - let(:project) { create(:project, user_id: current_user_id) } + let(:current_user) { authenticated_user } + let(:project) { create(:project, user_id: authenticated_user.id) } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + + before do + authenticated_in_hydra_as(owner) + end it { expect(query).to be_a_valid_graphql_query } @@ -78,9 +84,16 @@ context 'when fetching projects by user ID when logged in' do let(:query) { 'query ($userId: String) { projects(userId: $userId) { edges { node { id } } } }' } - let(:current_user_id) { SecureRandom.uuid } - let(:variables) { { userId: current_user_id } } - let(:project) { create(:project, user_id: current_user_id) } + let(:current_user) { authenticated_user } + let(:teacher) { create(:teacher, school:) } + let(:variables) { { userId: authenticated_user.id } } + let(:project) { create(:project, user_id: authenticated_user.id) } + let(:school) { create(:school) } + let(:lesson) { create(:lesson, user_id: teacher.id, school:) } + + before do + authenticated_in_hydra_as(teacher) + end it { expect(query).to be_a_valid_graphql_query } @@ -91,6 +104,24 @@ end end + context 'with an existing project owned by the user that belongs to a school' do + let(:project) { create(:project, user_id: teacher.id, school:) } + + it 'returns an empty array' do + project + expect(result.dig('data', 'projects', 'edges')).to be_empty + end + end + + context 'with an existing project owned by the user that belongs to a lesson' do + let(:project) { create(:project, user_id: teacher.id, lesson:) } + + it 'returns an empty array' do + project + expect(result.dig('data', 'projects', 'edges')).to be_empty + end + end + context 'with an existing unowned project' do let(:project) { create(:project, user_id: nil) } diff --git a/spec/jobs/create_students_job_spec.rb b/spec/jobs/create_students_job_spec.rb new file mode 100644 index 000000000..b93ac1bb0 --- /dev/null +++ b/spec/jobs/create_students_job_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CreateStudentsJob do + include ActiveJob::TestHelper + + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:verified_school) } + let(:user_id) { create(:user).id } + + let(:students) do + [{ + username: 'student-to-create', + password: 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=', + name: 'School Student' + }] + end + + before do + ActiveJob::Base.queue_adapter = :good_job + + stub_profile_api_create_school_students(user_ids: [user_id]) + end + + after do + GoodJob::Job.delete_all + + ActiveJob::Base.queue_adapter = :test + end + + it 'calls ProfileApiClient' do + described_class.perform_now(school_id: school.id, students:, token:) + + expect(ProfileApiClient).to have_received(:create_school_students) + .with(token:, students: [{ username: 'student-to-create', password: 'Student2024', name: 'School Student' }], school_id: school.id) + end + + it 'creates a new student role' do + described_class.perform_now(school_id: school.id, students:, token:) + + expect(Role.student.where(school:, user_id:)).to exist + end + + it 'does not enqueue a job if one is already running for that school' do + # Enqueue the job + GoodJob::Job.enqueue(described_class.new(school_id: school.id, students:, token:)) + + expect do + described_class.attempt_perform_later(school_id: school.id, students:, token:, user_id:) + end.to raise_error(ConcurrencyExceededForSchool) + end +end diff --git a/spec/jobs/upload_job_spec.rb b/spec/jobs/upload_job_spec.rb index 468df4e24..2537c188e 100644 --- a/spec/jobs/upload_job_spec.rb +++ b/spec/jobs/upload_job_spec.rb @@ -3,13 +3,14 @@ require 'rails_helper' RSpec.describe UploadJob do + include ActiveJob::TestHelper + around do |example| ClimateControl.modify GITHUB_AUTH_TOKEN: 'secret', GITHUB_WEBHOOK_REF: 'branches/whatever' do example.run end end - ActiveJob::Base.queue_adapter = :test let(:graphql_response) do GraphQL::Client::Response.new(raw_response, data: UploadJob::ProjectContentQuery.new(raw_response['data'], GraphQL::Client::Errors.new)) end @@ -20,7 +21,7 @@ { repository: 'my-amazing-repo', owner: 'me', expression: "#{ENV.fetch('http://github.com/GITHUB_WEBHOOK_REF')}:ja-JP/code" } end - let(:raw_response) do + let(:modifiable_response) do { data: { repository: { @@ -41,6 +42,24 @@ isBinary: true } }, + { + name: 'music.mp3', + extension: '.mp3', + object: { + __typename: 'Blob', + text: nil, + isBinary: true + } + }, + { + name: 'video.mp4', + extension: '.mp4', + object: { + __typename: 'Blob', + text: nil, + isBinary: true + } + }, { name: 'main.py', extension: '.py', @@ -50,6 +69,33 @@ isBinary: false } }, + { + name: 'index.html', + extension: '.html', + object: { + __typename: 'Blob', + text: '

Hello world!

', + isBinary: false + } + }, + { + name: 'styles.css', + extension: '.css', + object: { + __typename: 'Blob', + text: ".h1 {\n color: red;\n}\n", + isBinary: false + } + }, + { + name: 'script.js', + extension: '.js', + object: { + __typename: 'Blob', + text: "console.log('Hello, world!')", + isBinary: false + } + }, { name: 'project_config.yml', extension: '.yml', @@ -73,7 +119,7 @@ { name: "Don't Collide!", identifier: 'dont-collide-starter', - type: 'python', + type: Project::Types::PYTHON, locale: 'ja-JP', components: [ { @@ -81,6 +127,24 @@ extension: 'py', content: "#!/bin/python3\n\nfrom p5 import *\nfrom random import randint, seed\n\n# Include global variables here\n\n\ndef setup():\n# Put code to run once here\n\n\ndef draw():\n# Put code to run every frame here\n\n \n# Keep this to run your code\nrun()\n", default: true + }, + { + name: 'index', + extension: 'html', + content: '

Hello world!

', + default: false + }, + { + name: 'styles', + extension: 'css', + content: ".h1 {\n color: red;\n}\n", + default: false + }, + { + name: 'script', + extension: 'js', + content: "console.log('Hello, world!')", + default: false } ], images: [ @@ -88,37 +152,136 @@ filename: 'astronaut1.png', io: instance_of(StringIO) } + ], + videos: [ + { + filename: 'video.mp4', + io: instance_of(StringIO) + } + ], + audio: [ + { + filename: 'music.mp3', + io: instance_of(StringIO) + } ] } end - before do - allow(GithubApi::Client).to receive(:query).and_return(graphql_response) - stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/astronaut1.png').to_return(status: 200, body: '', headers: {}) - allow(ProjectImporter).to receive(:new).and_call_original - end + context 'with the build flag undefined' do + let(:raw_response) { modifiable_response } + + before do + allow(GithubApi::Client).to receive(:query).and_return(graphql_response) + allow(ProjectImporter).to receive(:new).and_call_original + + stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/astronaut1.png').to_return(status: 200, body: '', headers: {}) + stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/music.mp3').to_return(status: 200, body: '', headers: {}) + stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/video.mp4').to_return(status: 200, body: '', headers: {}) + end + + it 'enqueues the job' do + expect { described_class.perform_later(payload) }.to enqueue_job + end + + it 'requests data from Github' do + described_class.perform_now(payload) + expect(GithubApi::Client).to have_received(:query).with(UploadJob::ProjectContentQuery, variables:) + end + + it 'imports the project in the correct format' do + described_class.perform_now(payload) + expect(ProjectImporter).to have_received(:new) + .with(**project_importer_params) + end + + it 'requests the image from the correct URL' do + described_class.perform_now(payload) + expect(WebMock).to have_requested(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/astronaut1.png').once + end - it 'enqueues the job' do - expect { described_class.perform_later(payload) }.to enqueue_job + it 'saves the project to the database' do + expect { described_class.perform_now(payload) }.to change(Project, :count).by(1) + end end - it 'requests data from Github' do - described_class.perform_now(payload) - expect(GithubApi::Client).to have_received(:query).with(UploadJob::ProjectContentQuery, variables:) + context 'with the build flag set to false' do + let(:raw_response) { modifiable_response.deep_dup } + + before do + project_dir_entry = raw_response['data']['repository']['object']['entries'].find do |entry| + entry['name'] == 'dont-collide-starter' + end + + project_config_entry = project_dir_entry['object']['entries'].find do |entry| + entry['name'] == 'project_config.yml' + end + + project_config_entry['object']['text'] += "build: false\n" + + allow(GithubApi::Client).to receive(:query).and_return(graphql_response) + allow(ProjectImporter).to receive(:new).and_call_original + end + + it 'does not save the project to the database' do + expect { described_class.perform_now(payload) }.not_to change(Project, :count) + end end - it 'imports the project in the correct format' do - described_class.perform_now(payload) - expect(ProjectImporter).to have_received(:new) - .with(**project_importer_params) + context 'with the build flag set to true' do + let(:raw_response) { modifiable_response.deep_dup } + + before do + stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/astronaut1.png').to_return(status: 200, body: '', headers: {}) + stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/music.mp3').to_return(status: 200, body: '', headers: {}) + stub_request(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/video.mp4').to_return(status: 200, body: '', headers: {}) + + project_dir_entry = raw_response['data']['repository']['object']['entries'].find do |entry| + entry['name'] == 'dont-collide-starter' + end + + project_config_entry = project_dir_entry['object']['entries'].find do |entry| + entry['name'] == 'project_config.yml' + end + + project_config_entry['object']['text'] += "build: true\n" + + allow(GithubApi::Client).to receive(:query).and_return(graphql_response) + allow(ProjectImporter).to receive(:new).and_call_original + end + + it 'saves the project to the database' do + expect { described_class.perform_now(payload) }.to change(Project, :count).by(1) + end end - it 'requests the image from the correct URL' do - described_class.perform_now(payload) - expect(WebMock).to have_requested(:get, 'https://github.com/me/my-amazing-repo/raw/branches/whatever/ja-JP/code/dont-collide-starter/astronaut1.png').once + context 'when GitHub returns nothing for the locale' do + let(:raw_response) { { data: { repository: nil } } } + + before do + allow(GithubApi::Client).to receive(:query).and_return(graphql_response) + end + + it 'raises DataNotFoundError' do + expect do + described_class.perform_now(payload) + end.to raise_error(DataNotFoundError) + end end - it 'saves the project to the database' do - expect { described_class.perform_now(payload) }.to change(Project, :count).by(1) + context 'when locale is unsupported' do + let(:raw_response) { { data: { repository: nil } } } + let(:bad_payload) do + { repository: { name: 'my-amazing-repo', owner: { name: 'me' } }, commits: [{ added: ['unsupported-locale/code/dont-collide-starter/main.py'], modified: [], removed: [] }] } + end + + before do + allow(GithubApi::Client).to receive(:query).and_return(graphql_response) + end + + it 'does not request data from Github' do + described_class.perform_now(bad_payload) + expect(GithubApi::Client).not_to have_received(:query) + end end end diff --git a/spec/lib/corp_middleware_spec.rb b/spec/lib/corp_middleware_spec.rb new file mode 100644 index 000000000..bdd26c21a --- /dev/null +++ b/spec/lib/corp_middleware_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe CorpMiddleware do + let(:app) { instance_double(App::Application) } + let(:middleware) { described_class.new(app) } + let(:env) { { 'HTTP_HOST' => 'test.com', 'PATH_INFO' => '/rails/active_storage' } } + + before do + allow(app).to receive(:call).and_return([200, {}, ['OK']]) + allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('test.com') + end + + it 'sets the Cross-Origin-Resource-Policy header for a literal origin' do + _status, headers, _response = middleware.call(env) + + expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + + it 'sets the Cross-Origin-Resource-Policy header for regex origin' do + allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('/test\.com/') + + _status, headers, _response = middleware.call(env) + + expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + + it 'does not set the Cross-Origin-Resource-Policy header for disallowed origins' do + allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('other.com') + + _status, headers, _response = middleware.call(env) + + expect(headers).not_to have_key('Cross-Origin-Resource-Policy') + end +end diff --git a/spec/lib/for_education_code_generator_spec.rb b/spec/lib/for_education_code_generator_spec.rb new file mode 100644 index 000000000..4245cb5bc --- /dev/null +++ b/spec/lib/for_education_code_generator_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ForEducationCodeGenerator do + describe '.generate' do + it 'uses Random#rand to generate a random number up to the maximum' do + random = instance_double(Random) + allow(random).to receive(:rand).with(ForEducationCodeGenerator::MAX_CODE).and_return(123) + allow(Random).to receive(:new).and_return(random) + + expect(described_class.generate).to eq('00-01-23') + end + + it 'generates a string containing 3 pairs of digits' do + expect(described_class.generate).to match(/\d\d-\d\d-\d\d/) + end + + it 'generates a different code each time' do + expect(described_class.generate).not_to eq(described_class.generate) + end + end +end diff --git a/spec/lib/helpers/decryption_helpers_spec.rb b/spec/lib/helpers/decryption_helpers_spec.rb new file mode 100644 index 000000000..dac112d5d --- /dev/null +++ b/spec/lib/helpers/decryption_helpers_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'openssl' +require 'base64' + +RSpec.describe DecryptionHelpers do + let(:key) { 'a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0' } # 256-bit key in hex + let(:password) { 'Student2024' } + let(:encrypted_password) { 'SaoXlDBAyiAFoMH3VsddhdA7JWnM8P8by1wOjBUWH2g=' } # An encrypted password + + before do + allow(ENV).to receive(:fetch).with('EDITOR_ENCRYPTION_KEY').and_return(key) + end + + it 'decrypts the password successfully' do + expect(described_class.decrypt_password(encrypted_password)).to eq(password) + end + + it 'raises an error with an incorrect key' do + allow(ENV).to receive(:fetch).with('EDITOR_ENCRYPTION_KEY').and_return('b' * 64) + expect { described_class.decrypt_password(encrypted_password) }.to raise_error(RuntimeError, /Decryption failed/) + end + + it 'raises an error with invalid encrypted data' do + invalid_encrypted_password = Base64.encode64('invalid_data') + expect { described_class.decrypt_password(invalid_encrypted_password) }.to raise_error(RuntimeError, /Decryption failed/) + end +end diff --git a/spec/lib/hydra_admin_api_spec.rb b/spec/lib/hydra_admin_api_spec.rb deleted file mode 100644 index 50cadaacc..000000000 --- a/spec/lib/hydra_admin_api_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require 'hydra_admin_api' - -RSpec.describe HydraAdminApi do - let(:hydra_admin_url) { 'https://hydra.com/admin' } - let(:hydra_admin_api_key) { 'secret' } - let(:bypass_auth) { nil } - - around do |example| - ClimateControl.modify(BYPASS_AUTH: bypass_auth) do - example.run - end - end - - before do - stub_const('HydraAdminApi::ADMIN_URL', hydra_admin_url) - stub_const('HydraAdminApi::ADMIN_API_KEY', hydra_admin_api_key) - end - - describe '#fetch_oauth_user_id' do - subject(:response) { described_class.fetch_oauth_user_id(**args) } - - let(:args) { { token: 'abc123' } } - let(:uuid) { SecureRandom.uuid } - let(:stubbed_response) { { active: true, sub: uuid } } - # `active` is a required field in the response; `sub` is the "subject". - # - # See https://www.ory.sh/docs/reference/api#tag/oAuth2/operation/introspectOAuth2Token - - before do - stub_request(:post, "#{hydra_admin_url}/oauth2/introspect") - .with(body: args, headers: { apiKey: hydra_admin_api_key }) - .to_return(status: 200, - body: stubbed_response.to_json, - headers: { content_type: 'application/json' }) - end - - it { is_expected.to eq uuid } - - context 'when the token is not found' do - let(:stubbed_response) { { active: false } } - - it { is_expected.to be_nil } - end - - context 'when BYPASS_AUTH is set' do - let(:bypass_auth) { 'yes' } - - # Default bypass ID from - # https://github.com/RaspberryPiFoundation/rpi-auth/blob/main/lib/rpi_auth/engine.rb#L17 - it { is_expected.to eq 'b6301f34-b970-4d4f-8314-f877bad8b150' } - end - end -end diff --git a/spec/lib/origin_parser_spec.rb b/spec/lib/origin_parser_spec.rb new file mode 100644 index 000000000..7c76563a7 --- /dev/null +++ b/spec/lib/origin_parser_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OriginParser do + describe '.parse_origins' do + after { ENV['ALLOWED_ORIGINS'] = nil } + + it 'returns an empty array if ALLOWED_ORIGINS is not set' do + ENV['ALLOWED_ORIGINS'] = nil + expect(described_class.parse_origins).to eq([]) + end + + it 'parses literal strings correctly' do + ENV['ALLOWED_ORIGINS'] = 'http://example.com, https://example.org' + expect(described_class.parse_origins).to eq(['http://example.com/', 'https://example.org/']) + end + + it 'parses regexes correctly' do + ENV['ALLOWED_ORIGINS'] = '/https?:\/\/example\.com/' + expect(described_class.parse_origins).to eq([Regexp.new('https?:\/\/example\.com')]) + end + + it 'parses a mix of literals and regexes' do + ENV['ALLOWED_ORIGINS'] = 'http://example.com, /https?:\/\/localhost$/' + expect(described_class.parse_origins).to eq(['http://example.com/', Regexp.new('https?:\/\/localhost$')]) + end + + it 'strips whitespace from origins' do + ENV['ALLOWED_ORIGINS'] = ' http://example.com , /regex$/ ' + expect(described_class.parse_origins).to eq(['http://example.com/', Regexp.new('regex$')]) + end + + it 'returns an empty array if ALLOWED_ORIGINS is empty' do + ENV['ALLOWED_ORIGINS'] = '' + expect(described_class.parse_origins).to eq([]) + end + end +end diff --git a/spec/lib/phrase_identifier_spec.rb b/spec/lib/phrase_identifier_spec.rb index 6aa1b0ca0..6da694d1f 100644 --- a/spec/lib/phrase_identifier_spec.rb +++ b/spec/lib/phrase_identifier_spec.rb @@ -12,18 +12,17 @@ let(:phrase_regex) { /^[abc]-[abc]-[abc]$/ } before do - Word.delete_all - words.each { |word| Word.new(word:).save! } + allow(described_class).to receive(:words).and_return(words) end it { is_expected.to match phrase_regex } end context 'when there are no available combinations' do - let(:identifier) { create(:word).word } + let(:identifier) { Faker::Verb.base } before do - Word.delete_all + allow(described_class).to receive(:words).and_return([identifier]) create(:project, identifier:) end @@ -31,7 +30,9 @@ end context 'when no words are in the database' do - before { Word.delete_all } + before do + allow(described_class).to receive(:words).and_return([]) + end it { expect { generate }.to raise_exception(PhraseIdentifier::Error) } end diff --git a/spec/lib/profile_api_client_spec.rb b/spec/lib/profile_api_client_spec.rb new file mode 100644 index 000000000..cfc40f630 --- /dev/null +++ b/spec/lib/profile_api_client_spec.rb @@ -0,0 +1,720 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ProfileApiClient do + let(:api_url) { 'http://example.com/' } + let(:api_key) { 'api-key' } + let(:token) { SecureRandom.uuid } + + before do + allow(ENV).to receive(:fetch).with('IDENTITY_URL').and_return(api_url) + allow(ENV).to receive(:fetch).with('PROFILE_API_KEY').and_return(api_key) + end + + describe ProfileApiClient::Student422Error do + subject(:exception) { described_class.new(error) } + + let(:error_code) { 'ERR_USER_EXISTS' } + let(:error) { { 'message' => "Something's wrong with the password" } } + + it 'includes the message from the error' do + expect(exception.message).to eq("Something's wrong with the password") + end + end + + describe ProfileApiClient::UnexpectedResponse do + subject(:exception) { described_class.new(response) } + + let(:response) { instance_double(Faraday::Response, status:, headers:, body:) } + let(:status) { 'response-status' } + let(:headers) { 'response-headers' } + let(:body) { 'response-body' } + + it 'includes expected and actual status code in the message' do + expect(exception.message).to eq('Unexpected response from Profile API (status code response-status)') + end + + it 'makes response status available' do + expect(exception.response_status).to eq('response-status') + end + + it 'makes response headers available' do + expect(exception.response_headers).to eq('response-headers') + end + + it 'makes response body available' do + expect(exception.response_body).to eq('response-body') + end + end + + describe '.create_school' do + let(:school) { build(:school, id: SecureRandom.uuid, code: SecureRandom.uuid) } + let(:create_school_url) { "#{api_url}/api/v1/schools" } + + before do + stub_request(:post, create_school_url) + .to_return( + status: 201, + body: '{"id":"","schoolCode":"","updatedAt":"","createdAt":"","discardedAt":""}', + headers: { 'content-type' => 'application/json' } + ) + end + + it 'makes a request to the profile api host' do + create_school + expect(WebMock).to have_requested(:post, create_school_url) + end + + it 'includes token in the authorization request header' do + create_school + expect(WebMock).to have_requested(:post, create_school_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + create_school + expect(WebMock).to have_requested(:post, create_school_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets content-type of request to json' do + create_school + expect(WebMock).to have_requested(:post, create_school_url).with(headers: { 'content-type' => 'application/json' }) + end + + it 'sets accept header to json' do + create_school + expect(WebMock).to have_requested(:post, create_school_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'sends the school id and code in the request body as json' do + create_school + expected_body = { id: school.id, schoolCode: school.code }.to_json + expect(WebMock).to have_requested(:post, create_school_url).with(body: expected_body) + end + + it 'returns the created school if successful' do + data = { id: 'id', schoolCode: 'code', updatedAt: '2024-07-09T10:31:13.196Z', createdAt: '2024-07-09T10:31:13.196Z', discardedAt: nil } + expected = ProfileApiClient::School.new(**data) + stub_request(:post, create_school_url) + .to_return(status: 201, body: data.to_json, headers: { 'Content-Type' => 'application/json' }) + expect(create_school).to eq(expected) + end + + it 'raises exception if anything other than a 201 status code is returned' do + stub_request(:post, create_school_url) + .to_return(status: 200) + + expect { create_school }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:post, create_school_url) + .to_return(status: 401) + + expect { create_school }.to raise_error(Faraday::Error) + end + + describe 'when BYPASS_OAUTH is true' do + before do + allow(ENV).to receive(:[]).with('BYPASS_OAUTH').and_return(true) + end + + it 'does not make a request to Profile API' do + create_school + expect(WebMock).not_to have_requested(:post, create_school_url) + end + + it 'returns the id and code of the school supplied' do + expected = { 'id' => school.id, 'schoolCode' => school.code } + expect(create_school).to eq(expected) + end + end + + private + + def create_school + described_class.create_school(token:, id: school.id, code: school.code) + end + end + + describe '.safeguarding_flags' do + let(:list_safeguarding_flags_url) { "#{api_url}/api/v1/safeguarding-flags" } + + before do + stub_request(:get, list_safeguarding_flags_url).to_return(status: 200, body: '[]', headers: { 'content-type' => 'application/json' }) + end + + it 'makes a request to the profile api host' do + list_safeguarding_flags + expect(WebMock).to have_requested(:get, list_safeguarding_flags_url) + end + + it 'includes token in the authorization request header' do + list_safeguarding_flags + expect(WebMock).to have_requested(:get, list_safeguarding_flags_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + list_safeguarding_flags + expect(WebMock).to have_requested(:get, list_safeguarding_flags_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets accept header to json' do + list_safeguarding_flags + expect(WebMock).to have_requested(:get, list_safeguarding_flags_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'returns list of safeguarding flags if successful' do + flag = { + id: '7ac79585-e187-4d2f-bf0c-a1cbe72ecc9a', + userId: '583ba872-b16e-46e1-9f7d-df89d267550d', + flag: 'school:owner', + email: 'user@example.com', + createdAt: '2024-07-01T12:49:18.926Z', + updatedAt: '2024-07-01T12:49:18.926Z', + discardedAt: nil + } + expected = ProfileApiClient::SafeguardingFlag.new(**flag) + stub_request(:get, list_safeguarding_flags_url) + .to_return(status: 200, body: [flag].to_json, headers: { 'content-type' => 'application/json' }) + expect(list_safeguarding_flags).to eq([expected]) + end + + it 'raises exception if anything other than a 200 status code is returned' do + stub_request(:get, list_safeguarding_flags_url) + .to_return(status: 201) + + expect { list_safeguarding_flags }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:get, list_safeguarding_flags_url) + .to_return(status: 401) + + expect { list_safeguarding_flags }.to raise_error(Faraday::Error) + end + + private + + def list_safeguarding_flags + described_class.safeguarding_flags(token:) + end + end + + describe '.create_safeguarding_flag' do + let(:flag) { 'school:owner' } + let(:create_safeguarding_flag_url) { "#{api_url}/api/v1/safeguarding-flags" } + + before do + stub_request(:post, create_safeguarding_flag_url).to_return(status: 201, body: '{}', headers: { 'content-type' => 'application/json' }) + end + + it 'makes a request to the profile api host' do + create_safeguarding_flag + expect(WebMock).to have_requested(:post, create_safeguarding_flag_url) + end + + it 'includes token in the authorization request header' do + create_safeguarding_flag + expect(WebMock).to have_requested(:post, create_safeguarding_flag_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + create_safeguarding_flag + expect(WebMock).to have_requested(:post, create_safeguarding_flag_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets content-type of request to json' do + create_safeguarding_flag + expect(WebMock).to have_requested(:post, create_safeguarding_flag_url).with(headers: { 'content-type' => 'application/json' }) + end + + it 'sets accept header to json' do + create_safeguarding_flag + expect(WebMock).to have_requested(:post, create_safeguarding_flag_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'sends the safeguarding flag in the request body' do + create_safeguarding_flag + expect(WebMock).to have_requested(:post, create_safeguarding_flag_url).with(body: { flag:, email: 'user@example.com' }.to_json) + end + + it 'returns empty body if created successfully' do + stub_request(:post, create_safeguarding_flag_url) + .to_return(status: 201) + expect(create_safeguarding_flag).to be_nil + end + + it 'returns empty body if 303 response returned to indicate that the flag already exists' do + stub_request(:post, create_safeguarding_flag_url) + .to_return(status: 303) + expect(create_safeguarding_flag).to be_nil + end + + it 'raises exception if anything other than a 201 or 303 status code is returned' do + stub_request(:post, create_safeguarding_flag_url) + .to_return(status: 200) + + expect { create_safeguarding_flag }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:post, create_safeguarding_flag_url) + .to_return(status: 401) + + expect { create_safeguarding_flag }.to raise_error(Faraday::Error) + end + + def create_safeguarding_flag + described_class.create_safeguarding_flag(token:, flag:, email: 'user@example.com') + end + end + + describe '.delete_safeguarding_flag' do + let(:flag) { 'school:owner' } + let(:delete_safeguarding_flag_url) { "#{api_url}/api/v1/safeguarding-flags/#{flag}" } + + before do + stub_request(:delete, delete_safeguarding_flag_url).to_return(status: 204, body: '') + end + + it 'makes a request to the profile api host' do + delete_safeguarding_flag + expect(WebMock).to have_requested(:delete, delete_safeguarding_flag_url) + end + + it 'includes token in the authorization request header' do + delete_safeguarding_flag + expect(WebMock).to have_requested(:delete, delete_safeguarding_flag_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + delete_safeguarding_flag + expect(WebMock).to have_requested(:delete, delete_safeguarding_flag_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets accept header to json' do + delete_safeguarding_flag + expect(WebMock).to have_requested(:delete, delete_safeguarding_flag_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'returns empty body if successful' do + stub_request(:delete, delete_safeguarding_flag_url) + .to_return(status: 204, body: '') + expect(delete_safeguarding_flag).to be_nil + end + + it 'raises exception if anything other than a 204 status code is returned' do + stub_request(:delete, delete_safeguarding_flag_url) + .to_return(status: 200) + + expect { delete_safeguarding_flag }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:delete, delete_safeguarding_flag_url) + .to_return(status: 401) + + expect { delete_safeguarding_flag }.to raise_error(Faraday::Error) + end + + def delete_safeguarding_flag + described_class.delete_safeguarding_flag(token:, flag:) + end + end + + describe '.create_school_student' do + let(:username) { 'username' } + let(:password) { 'password' } + let(:name) { 'name' } + let(:school) { build(:school, id: SecureRandom.uuid) } + let(:create_students_url) { "#{api_url}/api/v1/schools/#{school.id}/students" } + + before do + stub_request(:post, create_students_url).to_return(status: 201, body: '{}', headers: { 'content-type' => 'application/json' }) + end + + it 'makes a request to the profile api host' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url) + end + + it 'includes token in the authorization request header' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets content-type of request to json' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url).with(headers: { 'content-type' => 'application/json' }) + end + + it 'sets accept header to json' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'sends the student details in the request body' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url).with(body: [{ name:, username:, password: }].to_json) + end + + it 'returns the id of the created student(s) if successful' do + response = { created: ['student-id'] } + stub_request(:post, create_students_url) + .to_return(status: 201, body: response.to_json, headers: { 'content-type' => 'application/json' }) + expect(create_school_student).to eq(response) + end + + it 'raises 422 exception with the relevant message if 400 status code is returned' do + response = { errors: [message: 'The password is well dodgy'] } + stub_request(:post, create_students_url) + .to_return(status: 400, body: response.to_json, headers: { 'content-type' => 'application/json' }) + + expect { create_school_student }.to raise_error(ProfileApiClient::Student422Error) + .with_message('The password is well dodgy') + end + + it 'raises exception if anything other that 201 status code is returned' do + stub_request(:post, create_students_url) + .to_return(status: 200) + + expect { create_school_student }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:post, create_students_url) + .to_return(status: 401) + + expect { create_school_student }.to raise_error(Faraday::Error) + end + + context 'when there are extraneous leading and trailing spaces in the student params' do + let(:username) { ' username ' } + let(:password) { ' password ' } + let(:name) { ' name ' } + + it 'strips the extraneous spaces' do + create_school_student + expect(WebMock).to have_requested(:post, create_students_url).with(body: [{ name: 'name', username: 'username', password: 'password' }].to_json) + end + end + + def create_school_student + described_class.create_school_student(token:, username:, password:, name:, school_id: school.id) + end + end + + describe '.list_school_student' do + let(:school) { build(:school, id: SecureRandom.uuid) } + let(:list_students_url) { "#{api_url}/api/v1/schools/#{school.id}/students/list" } + let(:student_ids) { [SecureRandom.uuid] } + + before do + stub_request(:post, list_students_url).to_return(status: 200, body: '[]', headers: { 'content-type' => 'application/json' }) + end + + it 'makes a request to the profile api host' do + list_school_students + expect(WebMock).to have_requested(:post, list_students_url) + end + + it 'includes token in the authorization request header' do + list_school_students + expect(WebMock).to have_requested(:post, list_students_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + list_school_students + expect(WebMock).to have_requested(:post, list_students_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets content-type of request to json' do + list_school_students + expect(WebMock).to have_requested(:post, list_students_url).with(headers: { 'content-type' => 'application/json' }) + end + + it 'sets accept header to json' do + list_school_students + expect(WebMock).to have_requested(:post, list_students_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'sets body to the student IDs' do + list_school_students + expect(WebMock).to have_requested(:post, list_students_url).with(body: student_ids) + end + + it 'returns the student(s) if successful' do + student = { + id: '549e4674-6ffd-4ac6-9a97-b4d7e5c0e5c5', + schoolId: '132383f1-702a-46a0-9eb2-a40dd4f212e3', + name: 'student-name', + username: 'student-username', + createdAt: '2024-07-03T13:00:40.041Z', + updatedAt: '2024-07-03T13:00:40.041Z', + discardedAt: nil + } + expected = ProfileApiClient::Student.new(**student) + stub_request(:post, list_students_url) + .to_return(status: 200, body: [student].to_json, headers: { 'content-type' => 'application/json' }) + expect(list_school_students).to eq([expected]) + end + + it 'raises exception if anything other that 200 status code is returned' do + stub_request(:post, list_students_url) + .to_return(status: 201) + + expect { list_school_students }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:post, list_students_url) + .to_return(status: 401) + + expect { list_school_students }.to raise_error(Faraday::Error) + end + + def list_school_students + described_class.list_school_students(token:, school_id: school.id, student_ids:) + end + end + + # rubocop:disable RSpec/MultipleMemoizedHelpers + describe '.update_school_student' do + let(:username) { 'username' } + let(:password) { 'password' } + let(:name) { 'name' } + let(:school) { build(:school, id: SecureRandom.uuid) } + let(:student) { create(:student, school:) } + let(:update_student_url) { "#{api_url}/api/v1/schools/#{school.id}/students/#{student.id}" } + + before do + stub_request(:patch, update_student_url) + .to_return( + status: 200, + body: '{"id":"","schoolId":"","name":"","username":"","createdAt":"","updatedAt":"","discardedAt":""}', + headers: { 'content-type' => 'application/json' } + ) + end + + it 'makes a request to the profile api host' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url) + end + + it 'includes token in the authorization request header' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets content-type of request to json' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(headers: { 'content-type' => 'application/json' }) + end + + it 'sets accept header to json' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'sends the student details in the request body' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(body: { name:, username:, password: }.to_json) + end + + it 'returns the updated student if successful' do + response = { id: 'id', schoolId: 'school-id', name: 'new-name', username: 'new-username', createdAt: '', updatedAt: '', discardedAt: '' } + expected = ProfileApiClient::Student.new(**response) + stub_request(:patch, update_student_url) + .to_return(status: 200, body: response.to_json, headers: { 'content-type' => 'application/json' }) + expect(update_school_student).to eq(expected) + end + + it 'raises 422 exception with the relevant message if 400 status code is returned' do + response = { errors: [message: 'The username is well dodgy'] } + stub_request(:patch, update_student_url) + .to_return(status: 400, body: response.to_json, headers: { 'content-type' => 'application/json' }) + + expect { update_school_student }.to raise_error(ProfileApiClient::Student422Error) + .with_message('The username is well dodgy') + end + + it 'raises exception if anything other that 200 status code is returned' do + stub_request(:patch, update_student_url) + .to_return(status: 201) + + expect { update_school_student }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:patch, update_student_url) + .to_return(status: 401) + + expect { update_school_student }.to raise_error(Faraday::Error) + end + + context 'when there are extraneous leading and trailing spaces in the student params' do + let(:username) { ' username ' } + let(:password) { ' password ' } + let(:name) { ' name ' } + + it 'strips the extraneous spaces' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(body: { name: 'name', username: 'username', password: 'password' }.to_json) + end + end + + context 'when optional values are nil' do + let(:username) { nil } + let(:password) { nil } + let(:name) { nil } + + it 'does not send empty values' do + update_school_student + expect(WebMock).to have_requested(:patch, update_student_url).with(body: {}.to_json) + end + end + + def update_school_student + described_class.update_school_student(token:, username:, password:, name:, school_id: school.id, student_id: student.id) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + + describe '.school_student' do + let(:school) { build(:school, id: SecureRandom.uuid) } + let(:student_url) { "#{api_url}/api/v1/schools/#{school.id}/students/#{student_id}" } + let(:student_id) { SecureRandom.uuid } + + before do + stub_request(:get, student_url) + .to_return( + status: 200, + body: '{"id":"","schoolId":"","name":"","username":"","createdAt":"","updatedAt":"","discardedAt":""}', + headers: { 'content-type' => 'application/json' } + ) + end + + it 'makes a request to the profile api host' do + school_student + expect(WebMock).to have_requested(:get, student_url) + end + + it 'includes token in the authorization request header' do + school_student + expect(WebMock).to have_requested(:get, student_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + school_student + expect(WebMock).to have_requested(:get, student_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets accept header to json' do + school_student + expect(WebMock).to have_requested(:get, student_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'returns the student(s) if successful' do + student = { + id: '549e4674-6ffd-4ac6-9a97-b4d7e5c0e5c5', + schoolId: '132383f1-702a-46a0-9eb2-a40dd4f212e3', + name: 'student-name', + username: 'student-username', + createdAt: '2024-07-03T13:00:40.041Z', + updatedAt: '2024-07-03T13:00:40.041Z', + discardedAt: nil + } + expected = ProfileApiClient::Student.new(**student) + stub_request(:get, student_url) + .to_return(status: 200, body: student.to_json, headers: { 'content-type' => 'application/json' }) + expect(school_student).to eq(expected) + end + + it 'raises exception if anything other than a 200 status code is returned' do + stub_request(:get, student_url) + .to_return(status: 201) + + expect { school_student }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:get, student_url) + .to_return(status: 401) + + expect { school_student }.to raise_error(Faraday::Error) + end + + private + + def school_student + described_class.school_student(token:, school_id: school.id, student_id:) + end + end + + describe '.delete_school_student' do + let(:school) { build(:school, id: SecureRandom.uuid) } + let(:delete_student_url) { "#{api_url}/api/v1/schools/#{school.id}/students/#{student_id}" } + let(:student_id) { SecureRandom.uuid } + + before do + stub_request(:delete, delete_student_url).to_return(status: 204, body: '', headers: { 'content-type' => 'application/json' }) + end + + it 'makes a request to the profile api host' do + delete_school_student + expect(WebMock).to have_requested(:delete, delete_student_url) + end + + it 'includes token in the authorization request header' do + delete_school_student + expect(WebMock).to have_requested(:delete, delete_student_url).with(headers: { authorization: "Bearer #{token}" }) + end + + it 'includes the profile api key in the x-api-key request header' do + delete_school_student + expect(WebMock).to have_requested(:delete, delete_student_url).with(headers: { 'x-api-key' => api_key }) + end + + it 'sets accept header to json' do + delete_school_student + expect(WebMock).to have_requested(:delete, delete_student_url).with(headers: { 'accept' => 'application/json' }) + end + + it 'returns nil if successful' do + stub_request(:delete, delete_student_url) + .to_return(status: 204, body: '', headers: { 'content-type' => 'application/json' }) + expect(delete_school_student).to be_nil + end + + it 'raises exception if anything other than a 200 status code is returned' do + stub_request(:delete, delete_student_url) + .to_return(status: 201) + + expect { delete_school_student }.to raise_error(ProfileApiClient::UnexpectedResponse) + end + + it 'raises faraday exception for 4xx and 5xx responses' do + stub_request(:delete, delete_student_url) + .to_return(status: 401) + + expect { delete_school_student }.to raise_error(Faraday::Error) + end + + private + + def delete_school_student + described_class.delete_school_student(token:, school_id: school.id, student_id:) + end + end +end diff --git a/spec/lib/project_importer_spec.rb b/spec/lib/project_importer_spec.rb index 0e6b07041..7d847dc54 100644 --- a/spec/lib/project_importer_spec.rb +++ b/spec/lib/project_importer_spec.rb @@ -1,14 +1,13 @@ # frozen_string_literal: true require 'rails_helper' -require 'project_importer' RSpec.describe ProjectImporter do let(:importer) do described_class.new( name: 'My amazing project', identifier: 'my-amazing-project', - type: 'python', + type: Project::Types::PYTHON, locale: 'ja-JP', components: [ { name: 'main', extension: 'py', content: 'print(\'hello\')', default: true }, @@ -16,6 +15,12 @@ ], images: [ { filename: 'my-amazing-image.png', io: File.open('spec/fixtures/files/test_image_1.png') } + ], + videos: [ + { filename: 'my-amazing-video.mp4', io: File.open('spec/fixtures/files/test_video_1.mp4') } + ], + audio: [ + { filename: 'my-amazing-audio.mp3', io: File.open('spec/fixtures/files/test_audio_1.mp3') } ] ) end @@ -50,6 +55,16 @@ importer.import! expect(project.images.count).to eq(1) end + + it 'creates the project videos' do + importer.import! + expect(project.videos.count).to eq(1) + end + + it 'creates the project audio' do + importer.import! + expect(project.audio.count).to eq(1) + end end context 'when the project already exists in the database' do @@ -59,6 +74,8 @@ :with_default_component, :with_components, :with_attached_image, + :with_attached_video, + :with_attached_audio, component_count: 2, identifier: 'my-amazing-project', locale: 'ja-JP' @@ -88,5 +105,13 @@ it 'updates images' do expect { importer.import! }.to change { project.reload.images[0].filename.to_s }.to('my-amazing-image.png') end + + it 'updates videos' do + expect { importer.import! }.to change { project.reload.videos[0].filename.to_s }.to('my-amazing-video.mp4') + end + + it 'updates audio' do + expect { importer.import! }.to change { project.reload.audio[0].filename.to_s }.to('my-amazing-audio.mp3') + end end end diff --git a/spec/lib/tasks/for_education_spec.rb b/spec/lib/tasks/for_education_spec.rb new file mode 100644 index 000000000..e9d51bd0a --- /dev/null +++ b/spec/lib/tasks/for_education_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'rake' + +RSpec.describe 'for_education', type: :task do + let(:creator_id) { '583ba872-b16e-46e1-9f7d-df89d267550d' } # jane.doe@example.com + let(:teacher_id) { 'bbb9b8fd-f357-4238-983d-6f87b99bdbb2' } # john.doe@example.com + let(:student_1) { 'e52de409-9210-4e94-b08c-dd11439e07d9' } # student + let(:student_2) { '0d488bec-b10d-46d3-b6f3-4cddf5d90c71' } # student + let(:school_id) { 'e52de409-9210-4e94-b08c-dd11439e07d9' } + + describe ':destroy_seed_data' do + let(:task) { Rake::Task['for_education:destroy_seed_data'] } + let(:school) { create(:school, creator_id:, id: school_id) } + + before do + create(:role, user_id: creator_id, school:) + create(:student_role, user_id: student_1, school:) + create(:teacher_role, user_id: creator_id, school:) + school_class = create(:school_class, school_id: school.id, teacher_ids: [creator_id]) + create(:class_student, student_id: student_1, school_class_id: school_class.id) + create(:lesson, school_id: school.id, user_id: creator_id) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'destroys all seed data' do + task.invoke + expect(Role.where(user_id: [creator_id, teacher_id, student_1, student_2])).not_to exist + expect(School.where(creator_id:)).not_to exist + expect(ClassStudent.where(student_id: student_1)).not_to exist + expect(SchoolClass.where(school_id: school.id)).not_to exist + expect(ClassTeacher.where(teacher_id: creator_id)).not_to exist + expect(Lesson.where(school_id: school.id)).not_to exist + expect(Project.where(school_id: school.id)).not_to exist + end + # rubocop:enable RSpec/MultipleExpectations + end + + describe ':seed_an_unverified_school' do + let(:task) { Rake::Task['for_education:seed_an_unverified_school'] } + + it 'creates an unverified school' do + task.invoke + expect(School.find_by(creator_id:).verified_at).to be_nil + end + end + + describe ':seed_a_verified_school' do + let(:task) { Rake::Task['for_education:seed_a_verified_school'] } + + it 'creates a verified school' do + task.invoke + expect(School.find_by(creator_id:).verified_at).to be_truthy + end + end + + describe ':seed_a_school_with_lessons_and_students' do + let(:task) { Rake::Task['for_education:seed_a_school_with_lessons_and_students'] } + + before do + task.invoke + end + + it 'creates a verified school' do + expect(School.find_by(creator_id:).verified_at).to be_truthy + end + + # rubocop:disable RSpec/MultipleExpectations + it 'creates lessons with projects' do + school = School.find_by(creator_id:) + expect(SchoolClass.where(school_id: school.id)).to exist + lesson = Lesson.where(school_id: school.id) + expect(lesson.length).to eq(2) + expect(Project.where(lesson_id: lesson.pluck(:id)).length).to eq(2) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'assigns a teacher' do + school = School.find_by(creator_id:) + expect(Role.teacher.where(user_id: teacher_id, school_id: school.id)).to exist + end + + it 'creates a class teacher association for the creator' do + expect(ClassTeacher.where(teacher_id: creator_id).length).to eq(1) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'assigns students' do + school_id = School.find_by(creator_id:).id + school_class_id = SchoolClass.find_by(school_id:).id + expect(Role.student.where(user_id: student_1, school_id:)).to exist + expect(ClassStudent.where(student_id: student_1, school_class_id:)).to exist + expect(Role.student.where(user_id: student_2, school_id:)).to exist + expect(ClassStudent.where(student_id: student_2, school_class_id:)).to exist + end + # rubocop:enable RSpec/MultipleExpectations + end +end diff --git a/spec/lib/test_seeds_spec.rb b/spec/lib/test_seeds_spec.rb new file mode 100644 index 000000000..f06e21371 --- /dev/null +++ b/spec/lib/test_seeds_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'rake' + +RSpec.describe 'test_seeds', type: :task do + let(:creator_id) { '583ba872-b16e-46e1-9f7d-df89d267550d' } # jane.doe@example.com + let(:teacher_id) { 'bbb9b8fd-f357-4238-983d-6f87b99bdbb2' } # john.doe@example.com + let(:student_1) { 'e52de409-9210-4e94-b08c-dd11439e07d9' } # student + let(:student_2) { '0d488bec-b10d-46d3-b6f3-4cddf5d90c71' } # student + let(:school_id) { 'e52de409-9210-4e94-b08c-dd11439e07d9' } + + describe ':destroy' do + let(:task) { Rake::Task['test_seeds:destroy'] } + let(:school) { create(:school, creator_id:, id: school_id) } + + before do + create(:role, user_id: creator_id, school:) + create(:student_role, user_id: student_1, school:) + create(:teacher_role, user_id: creator_id, school:) + school_class = create(:school_class, school_id: school.id, teacher_ids: [creator_id]) + create(:class_student, student_id: student_1, school_class_id: school_class.id) + create(:lesson, school_id: school.id, user_id: creator_id) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'destroys all seed data' do + task.invoke + expect(Role.where(user_id: [creator_id, teacher_id, student_1, student_2])).not_to exist + expect(School.where(creator_id:)).not_to exist + expect(ClassStudent.where(student_id: student_1)).not_to exist + expect(SchoolClass.where(school_id: school.id)).not_to exist + expect(Lesson.where(school_id: school.id)).not_to exist + expect(Project.where(school_id: school.id)).not_to exist + end + # rubocop:enable RSpec/MultipleExpectations + end + + describe ':seed_a_school_with_lessons_and_students' do + let(:task) { Rake::Task['test_seeds:create'] } + + before do + task.invoke + end + + it 'creates a verified school' do + expect(School.find_by(creator_id:).verified_at).to be_truthy + end + + # rubocop:disable RSpec/MultipleExpectations + it 'creates lessons with projects' do + school = School.find_by(creator_id:) + expect(SchoolClass.where(school_id: school.id)).to exist + lesson = Lesson.where(school_id: school.id) + expect(lesson.length).to eq(4) + expect(Project.where(lesson_id: lesson.pluck(:id)).length).to eq(4) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'assigns a teacher' do + school = School.find_by(creator_id:) + expect(Role.teacher.where(user_id: teacher_id, school_id: school.id)).to exist + end + + # rubocop:disable RSpec/MultipleExpectations + it 'creates class with lessons for the owner' do + school_id = School.find_by(creator_id:).id + school_class = SchoolClass.joins(:teachers).find_by(school_id:, teachers: { teacher_id: creator_id }) + + expect(school_class).not_to be_nil + expect(Lesson.where(school_id:, school_class_id: school_class.id).length).to eq(2) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'creates a class teacher association for the owner' do + expect(ClassTeacher.where(teacher_id: creator_id).length).to eq(1) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'creates class with lessons for the teacher' do + school_id = School.find_by(creator_id:).id + school_class = SchoolClass.joins(:teachers).find_by(school_id:, teachers: { teacher_id: }) + expect(school_class).not_to be_nil + expect(Lesson.where(school_id:, school_class_id: school_class.id).length).to eq(2) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'creates a class teacher association for the teacher' do + expect(ClassTeacher.where(teacher_id:).length).to eq(1) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'assigns students' do + school_id = School.find_by(creator_id:).id + school_class_id = SchoolClass.find_by(school_id:).id + expect(Role.student.where(user_id: student_1, school_id:)).to exist + expect(ClassStudent.where(student_id: student_1, school_class_id:)).to exist + expect(Role.student.where(user_id: student_2, school_id:)).to exist + expect(ClassStudent.where(student_id: student_2, school_class_id:)).to exist + end + # rubocop:enable RSpec/MultipleExpectations + end +end diff --git a/spec/mailers/invitation_mailer_spec.rb b/spec/mailers/invitation_mailer_spec.rb new file mode 100644 index 000000000..7a377732b --- /dev/null +++ b/spec/mailers/invitation_mailer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe InvitationMailer do + describe 'invite_teacher' do + subject(:email) { described_class.with(invitation:).invite_teacher } + + let(:invitation) { create(:teacher_invitation) } + + before do + allow(ENV).to receive(:fetch).with('EDITOR_PUBLIC_URL').and_return('http://example.com/') + end + + it 'includes the school name in the body' do + expect(email.body.to_s).to include(invitation.school.name) + end + + it 'includes a link to redeem the invitation in the body' do + allow(invitation).to receive(:generate_token_for).and_return('token-id') + + expect(email.body.to_s).to include('http://example.com/en/invitations/token-id') + end + + it 'includes the school name in the subject' do + expect(email.subject).to include(invitation.school.name) + end + end +end diff --git a/spec/mailers/previews/invitation_preview.rb b/spec/mailers/previews/invitation_preview.rb new file mode 100644 index 000000000..ca4b74e70 --- /dev/null +++ b/spec/mailers/previews/invitation_preview.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Preview all emails at http://localhost:3000/rails/mailers/invitation +class InvitationPreview < ActionMailer::Preview + def invite_teacher + school = School.new(name: 'Elmwood Secondary School') + invitation = TeacherInvitation.new(email_address: 'teacher@example.com', school:) + InvitationMailer.with(invitation:).invite_teacher + end +end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 5d47c4d5f..e3d959581 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -6,11 +6,12 @@ RSpec.describe Ability do subject { described_class.new(user) } - let(:project) { build(:project) } + let(:user_id) { SecureRandom.uuid } + let(:project) { build(:project, user_id:) } let(:starter_project) { build(:project, user_id: nil) } describe 'Project' do - context 'when no user' do + context 'with no user' do let(:user) { nil } context 'with a starter project' do @@ -30,8 +31,62 @@ end end - context 'when user present' do - let(:user) { project.user_id } + context 'with a standard user' do + let(:user) { build(:user, id: user_id) } + let(:another_project) { build(:project) } + + context 'with a starter project' do + it { is_expected.not_to be_able_to(:index, starter_project) } + it { is_expected.to be_able_to(:show, starter_project) } + it { is_expected.not_to be_able_to(:create, starter_project) } + it { is_expected.not_to be_able_to(:update, starter_project) } + it { is_expected.not_to be_able_to(:destroy, starter_project) } + end + + context 'with own project' do + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:create, project) } + it { is_expected.to be_able_to(:update, project) } + it { is_expected.to be_able_to(:destroy, project) } + end + + context 'with another user\'s project' do + it { is_expected.not_to be_able_to(:read, another_project) } + it { is_expected.not_to be_able_to(:create, another_project) } + it { is_expected.not_to be_able_to(:update, another_project) } + it { is_expected.not_to be_able_to(:destroy, another_project) } + end + end + + context 'with a teacher' do + let(:user) { build(:teacher, id: user_id) } + let(:another_project) { build(:project) } + + context 'with a starter project' do + it { is_expected.not_to be_able_to(:index, starter_project) } + it { is_expected.to be_able_to(:show, starter_project) } + it { is_expected.not_to be_able_to(:create, starter_project) } + it { is_expected.not_to be_able_to(:update, starter_project) } + it { is_expected.not_to be_able_to(:destroy, starter_project) } + end + + context 'with own project' do + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:create, project) } + it { is_expected.to be_able_to(:update, project) } + it { is_expected.to be_able_to(:destroy, project) } + end + + context 'with another user\'s project' do + it { is_expected.not_to be_able_to(:read, another_project) } + it { is_expected.not_to be_able_to(:create, another_project) } + it { is_expected.not_to be_able_to(:update, another_project) } + it { is_expected.not_to be_able_to(:destroy, another_project) } + end + end + + context 'with an owner' do + let(:user) { build(:owner, id: user_id) } let(:another_project) { build(:project) } context 'with a starter project' do @@ -56,7 +111,257 @@ it { is_expected.not_to be_able_to(:destroy, another_project) } end end + + context 'with a student' do + let(:user) { build(:student, id: user_id) } + let(:another_project) { build(:project) } + + context 'with a starter project' do + it { is_expected.not_to be_able_to(:index, starter_project) } + it { is_expected.to be_able_to(:show, starter_project) } + it { is_expected.not_to be_able_to(:create, starter_project) } + it { is_expected.not_to be_able_to(:update, starter_project) } + it { is_expected.not_to be_able_to(:destroy, starter_project) } + end + + context 'with own project' do + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:create, project) } + it { is_expected.to be_able_to(:update, project) } + it { is_expected.to be_able_to(:destroy, project) } + end + + context 'with another user\'s project' do + it { is_expected.not_to be_able_to(:read, another_project) } + it { is_expected.not_to be_able_to(:create, another_project) } + it { is_expected.not_to be_able_to(:update, another_project) } + it { is_expected.not_to be_able_to(:destroy, another_project) } + end + end + + context 'with an experience-cs admin' do + let(:user) { build(:experience_cs_admin_user, id: user_id) } + let(:another_project) { build(:project) } + + context 'with a starter project' do + it { is_expected.to be_able_to(:read, starter_project) } + it { is_expected.to be_able_to(:create, starter_project) } + it { is_expected.to be_able_to(:update, starter_project) } + it { is_expected.to be_able_to(:destroy, starter_project) } + end + + context 'with own project' do + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:create, project) } + it { is_expected.to be_able_to(:update, project) } + it { is_expected.to be_able_to(:destroy, project) } + end + + context 'with another user\'s project' do + it { is_expected.not_to be_able_to(:read, another_project) } + it { is_expected.not_to be_able_to(:create, another_project) } + it { is_expected.not_to be_able_to(:update, another_project) } + it { is_expected.not_to be_able_to(:destroy, another_project) } + end + end + + # rubocop:disable RSpec/MultipleMemoizedHelpers + context "with a teacher's project where the lesson is visible to students" do + let(:user) { create(:user) } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:school_class) { build(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { build(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } + let!(:project) { build(:project, school:, lesson:, user_id: teacher.id) } + + context 'when user is a school owner' do + before do + create(:owner_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + + context 'when user is a school teacher' do + before do + create(:teacher_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + + context 'when user is a school student and belongs to the teachers class' do + before do + create(:student_role, user_id: user.id, school:) + create(:class_student, school_class:, student_id: user.id) + end + + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + + context 'when user is a school student and does not belong to the teachers class' do + before do + create(:student_role, user_id: user.id, school:) + end + + it { is_expected.not_to be_able_to(:read, project) } + it { is_expected.not_to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + end + + context 'with a teachers project where the lesson is not visible to students' do + let(:user) { create(:user) } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:school_class) { build(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { build(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'teachers') } + let!(:project) { build(:project, school:, lesson:, user_id: teacher.id) } + + context 'when user is a school owner' do + before do + create(:owner_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + + context 'when user is a school teacher' do + before do + create(:teacher_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, project) } + it { is_expected.to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + + context 'when user is a school student and belongs to the teachers class' do + before do + create(:student_role, user_id: user.id, school:) + create(:class_student, school_class:, student_id: user.id) + end + + it { is_expected.not_to be_able_to(:read, project) } + it { is_expected.not_to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + + context 'when user is a school student and does not belong to the teachers class' do + before do + create(:student_role, user_id: user.id, school:) + end + + it { is_expected.not_to be_able_to(:read, project) } + it { is_expected.not_to be_able_to(:show_context, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:set_finished, project.school_project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + end + + # TODO: Handle other visibilities + + context "with a remix of a teacher's project" do + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + let(:another_teacher) { create(:teacher, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id, another_teacher.id]) } + let(:class_member) { create(:class_student, school_class:, student_id: student.id) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } + let(:original_project) { create(:project, school:, lesson:, user_id: teacher.id) } + let!(:remixed_project) { create(:project, school:, user_id: student.id, remixed_from_id: original_project.id) } + + context 'when user is the student' do + let(:user) { student } + + it { is_expected.to be_able_to(:read, remixed_project) } + it { is_expected.to be_able_to(:create, remixed_project) } + it { is_expected.to be_able_to(:update, remixed_project) } + it { is_expected.not_to be_able_to(:destroy, remixed_project) } + it { is_expected.to be_able_to(:set_finished, remixed_project.school_project) } + end + + context 'when user is a student and the lesson is not visible to students' do + let(:user) { student } + + before do + lesson.update(visibility: 'teachers') + end + + it { is_expected.not_to be_able_to(:read, remixed_project) } + it { is_expected.not_to be_able_to(:create, remixed_project) } + it { is_expected.not_to be_able_to(:update, remixed_project) } + it { is_expected.not_to be_able_to(:destroy, remixed_project) } + end + + context 'when user is teacher that does not own the orginal project' do + let(:user) { create(:teacher, school:) } + + it { is_expected.not_to be_able_to(:read, remixed_project) } + it { is_expected.not_to be_able_to(:create, remixed_project) } + it { is_expected.not_to be_able_to(:update, remixed_project) } + it { is_expected.not_to be_able_to(:destroy, remixed_project) } + it { is_expected.not_to be_able_to(:set_finished, remixed_project.school_project) } + end + + context 'when user is teacher that owns the orginal project' do + let(:user) { teacher } + + it { is_expected.to be_able_to(:read, remixed_project) } + it { is_expected.not_to be_able_to(:create, remixed_project) } + it { is_expected.not_to be_able_to(:update, remixed_project) } + it { is_expected.not_to be_able_to(:destroy, remixed_project) } + it { is_expected.not_to be_able_to(:set_finished, remixed_project.school_project) } + end + + context 'when user is another teacher of the class' do + let(:user) { another_teacher } + + it { is_expected.to be_able_to(:read, original_project) } + it { is_expected.not_to be_able_to(:create, original_project) } + it { is_expected.to be_able_to(:update, original_project) } + + it { is_expected.to be_able_to(:read, remixed_project) } + it { is_expected.not_to be_able_to(:create, remixed_project) } + it { is_expected.not_to be_able_to(:update, remixed_project) } + it { is_expected.not_to be_able_to(:destroy, remixed_project) } + it { is_expected.not_to be_able_to(:set_finished, remixed_project.school_project) } + end + end end + # rubocop:enable RSpec/MultipleMemoizedHelpers describe 'Component' do let(:starter_project_component) { build(:component, project: starter_project) } @@ -83,7 +388,7 @@ end context 'when user present' do - let(:user) { project.user_id } + let(:user) { build(:user, id: user_id) } context 'with a component from a starter project' do it { is_expected.not_to be_able_to(:index, starter_project_component) } @@ -111,4 +416,95 @@ end end end + + describe 'School' do + let(:school) { create(:school) } + let(:user) { build(:user) } + + context 'when user is not a school-owner but is the creator of the school' do + before do + user.id = user_id + school.update(creator_id: user_id, verified_at: nil) + end + + it { is_expected.to be_able_to(:read, school) } + end + + context 'when user is a school owner' do + before do + create(:owner_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, school) } + it { is_expected.to be_able_to(:update, school) } + it { is_expected.to be_able_to(:destroy, school) } + end + + context 'when user is a school teacher' do + before do + create(:teacher_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, school) } + it { is_expected.not_to be_able_to(:update, school) } + it { is_expected.not_to be_able_to(:destroy, school) } + end + + context 'when user is a school student' do + before do + create(:student_role, user_id: user.id, school:) + end + + it { is_expected.to be_able_to(:read, school) } + it { is_expected.not_to be_able_to(:update, school) } + it { is_expected.not_to be_able_to(:destroy, school) } + + context 'with a starter project' do + it { is_expected.not_to be_able_to(:index, starter_project) } + it { is_expected.not_to be_able_to(:show, starter_project) } + it { is_expected.not_to be_able_to(:create, starter_project) } + it { is_expected.not_to be_able_to(:update, starter_project) } + it { is_expected.not_to be_able_to(:destroy, starter_project) } + end + + context 'with an owned project' do + it { is_expected.not_to be_able_to(:index, project) } + it { is_expected.not_to be_able_to(:show, project) } + it { is_expected.not_to be_able_to(:create, project) } + it { is_expected.not_to be_able_to(:update, project) } + it { is_expected.not_to be_able_to(:destroy, project) } + end + end + end + + describe 'SchoolMembers' do + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + + context 'when user is a school owner' do + let(:user) { owner } + + it { is_expected.to be_able_to(:read, :school_member) } + end + + context 'when user is a school teacher' do + let(:user) { teacher } + + it { is_expected.to be_able_to(:read, :school_member) } + end + + context 'when user is a school student' do + let(:user) { student } + + it { is_expected.not_to be_able_to(:read, :school_member) } + end + + context 'when user is not authenticated' do + let(:user) { nil } + + it { is_expected.not_to be_able_to(:read, :school_member) } + end + end end diff --git a/spec/models/class_student_spec.rb b/spec/models/class_student_spec.rb new file mode 100644 index 000000000..535896792 --- /dev/null +++ b/spec/models/class_student_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassStudent, versioning: true do + before do + stub_user_info_api_for(student) + end + + let(:student) { create(:student, school:, name: 'School Student') } + let(:school) { create(:school) } + let(:school_class) { build(:school_class, teacher_ids: [teacher.id], school:) } + let(:teacher) { create(:teacher, school:) } + + describe 'associations' do + it 'belongs to a school_class' do + class_student = create(:class_student, student_id: student.id, school_class:) + expect(class_student.school_class).to be_a(SchoolClass) + end + + it 'belongs to a school (via school_class)' do + class_student = create(:class_student, student_id: student.id, school_class:) + expect(class_student.school).to be_a(School) + end + end + + describe 'validations' do + subject(:class_student) { build(:class_student, student_id: student.id, school_class:) } + + it 'has a valid default factory' do + expect(class_student).to be_valid + end + + it 'can save the default factory' do + expect { class_student.save! }.not_to raise_error + end + + it 'requires a school_class' do + class_student.school_class = nil + expect(class_student).to be_invalid + end + + it 'requires a student_id' do + class_student.student_id = ' ' + expect(class_student).to be_invalid + end + + it 'requires a UUID student_id' do + class_student.student_id = 'invalid' + expect(class_student).to be_invalid + end + + it 'requires a student that has the school-student role for the school' do + class_student.student = teacher + expect(class_student).to be_invalid + end + + it 'requires a unique student_id within the school_class' do + class_student.save! + duplicate = build(:class_student, student_id: class_student.student_id, school_class: class_student.school_class) + expect(duplicate).to be_invalid + end + end + + describe 'auditing' do + subject(:class_student) { create(:class_student, student_id: student.id, school_class:) } + + it 'enables auditing' do + expect(class_student.versions.length).to(eq(1)) + end + end +end diff --git a/spec/models/class_teacher_spec.rb b/spec/models/class_teacher_spec.rb new file mode 100644 index 000000000..03878d536 --- /dev/null +++ b/spec/models/class_teacher_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassTeacher, versioning: true do + before do + stub_user_info_api_for(teacher) + end + + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:another_teacher) { create(:teacher, school:, name: 'Another Teacher') } + let(:school) { create(:school) } + let(:school_class) { build(:school_class, teacher_ids: [teacher.id], school:) } + let(:student) { create(:student, school:) } + + describe 'associations' do + subject(:class_teacher) { build(:class_teacher, teacher_id: another_teacher.id, school_class:) } + + it { is_expected.to belong_to(:school_class) } + + it 'belongs to a school via school_class' do + expect(class_teacher.school).to eq(school) + end + end + + describe 'validations' do + subject(:class_teacher) { build(:class_teacher, teacher_id: another_teacher.id, school_class:) } + + it 'has a valid default factory' do + expect(class_teacher).to be_valid + end + + it 'can save the default factory' do + expect { class_teacher.save! }.not_to raise_error + end + + it 'requires a school_class' do + class_teacher.school_class = nil + expect(class_teacher).to be_invalid + end + + it 'requires a teacher_id' do + class_teacher.teacher_id = ' ' + expect(class_teacher).to be_invalid + end + + it 'requires a UUID teacher_id' do + class_teacher.teacher_id = 'invalid' + expect(class_teacher).to be_invalid + end + + it 'requires teacher to have the school-teacher role for the school' do + class_teacher.teacher = student + expect(class_teacher).to be_invalid + end + + it 'requires a unique teacher_id within the school_class' do + class_teacher.save! + duplicate = build(:class_teacher, teacher_id: class_teacher.teacher_id, school_class: class_teacher.school_class) + expect(duplicate).to be_invalid + end + end + + describe 'auditing' do + subject(:class_teacher) { create(:class_teacher, teacher_id: another_teacher.id, school_class:) } + + it 'enables auditing' do + expect(class_teacher.versions.length).to(eq(1)) + end + end +end diff --git a/spec/models/component_spec.rb b/spec/models/component_spec.rb index 506ab8f73..bbeb4b805 100644 --- a/spec/models/component_spec.rb +++ b/spec/models/component_spec.rb @@ -2,12 +2,13 @@ require 'rails_helper' -RSpec.describe Component do +RSpec.describe Component, versioning: true do subject { build(:component) } it { is_expected.to belong_to(:project) } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:extension) } + it { is_expected.to validate_length_of(:content).is_at_most(8_500_000) } context 'when default component' do let(:component) { create(:default_python_component) } @@ -37,5 +38,23 @@ .to include(I18n.t('errors.project.editing.change_default_extension')) end end + + describe 'auditing' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + + it 'enables auditing for a component that belongs to a project with a school_id' do + project_with_school = create(:project, user_id: student.id, school_id: school.id) + component = create(:component, project: project_with_school) + expect(component.versions.length).to(eq(1)) + end + + it 'does not enable auditing for a component that belongs to a project without a school_id' do + project_without_school = create(:project, school_id: nil) + component = create(:component, project: project_without_school) + expect(component.versions.length).to(eq(0)) + end + end end end diff --git a/spec/models/filesystem_project_spec.rb b/spec/models/filesystem_project_spec.rb new file mode 100644 index 000000000..daf8859b1 --- /dev/null +++ b/spec/models/filesystem_project_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe FilesystemProject do + it 'imports all starter projects' do + expect { described_class.import_all! }.not_to raise_error + end +end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb new file mode 100644 index 000000000..4eba9d729 --- /dev/null +++ b/spec/models/lesson_spec.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson do + before do + stub_user_info_api_for(teacher) + end + + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:school) { create(:school) } + + describe 'associations' do + it 'optionally belongs to a school (library)' do + lesson = create(:lesson, school:, user_id: teacher.id) + expect(lesson.school).to be_a(School) + end + + it 'optionally belongs to a school class' do + school_class = create(:school_class, teacher_ids: [teacher.id], school:) + + lesson = create(:lesson, school_class:, school: school_class.school, user_id: teacher.id) + expect(lesson.school_class).to be_a(SchoolClass) + end + + it 'optionally belongs to a parent' do + lesson = create(:lesson, parent: build(:lesson)) + expect(lesson.parent).to be_a(described_class) + end + + it 'has many copies' do + lesson = create(:lesson, copies: [build(:lesson), build(:lesson)]) + expect(lesson.copies.size).to eq(2) + end + + it 'has one project' do + user_id = SecureRandom.uuid + lesson = create(:lesson, user_id:, project: build(:project, user_id:)) + expect(lesson.project).to be_a(Project) + end + end + + describe 'callbacks' do + it 'cannot be destroyed and should be archived instead' do + lesson = create(:lesson) + expect { lesson.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) + end + end + + describe 'validations' do + subject(:lesson) { build(:lesson, user_id: teacher.id) } + + it 'has a valid default factory' do + expect(lesson).to be_valid + end + + it 'can save the default factory' do + expect { lesson.save! }.not_to raise_error + end + + it 'requires a user_id' do + lesson.user_id = ' ' + expect(lesson).to be_invalid + end + + it 'requires a UUID user_id' do + lesson.user_id = 'invalid' + expect(lesson).to be_invalid + end + + context 'when the lesson has a school' do + before do + lesson.update!(school:) + end + + let(:school) { create(:school) } + + it 'requires that the user that has the school-owner or school-teacher role for the school' do + student = create(:student, school:) + lesson.user_id = student.id + expect(lesson).to be_invalid + end + end + + context 'when the lesson has a school_class' do + before do + lesson.update!(school_class: create(:school_class, teacher_ids: [teacher.id], school:)) + end + + let(:school) { create(:school) } + + it 'requires that the user that is a school-teacher for the school_class' do + owner = create(:owner, school:) + lesson.user_id = owner.id + expect(lesson).to be_invalid + end + end + + it 'requires a name' do + lesson.name = ' ' + expect(lesson).to be_invalid + end + + it 'requires a visibility' do + lesson.visibility = ' ' + expect(lesson).to be_invalid + end + + it "requires a visibility that is either 'private', 'teachers', 'students' or 'public'" do + lesson.visibility = 'invalid' + expect(lesson).to be_invalid + end + end + + describe '.archived' do + let!(:archived_lesson) { create(:lesson, archived_at: Time.now.utc) } + let!(:unarchived_lesson) { create(:lesson) } + + it 'includes archived lessons' do + expect(described_class.archived).to include(archived_lesson) + end + + it 'excludes unarchived lessons' do + expect(described_class.archived).not_to include(unarchived_lesson) + end + end + + describe '.unarchived' do + let!(:archived_lesson) { create(:lesson, archived_at: Time.now.utc) } + let!(:unarchived_lesson) { create(:lesson) } + + it 'includes unarchived lessons' do + expect(described_class.unarchived).to include(unarchived_lesson) + end + + it 'excludes archived lessons' do + expect(described_class.unarchived).not_to include(archived_lesson) + end + end + + describe '#school' do + let(:school) { create(:school) } + + it 'is set from the school_class' do + school_class = create(:school_class, teacher_ids: [teacher.id], school:) + lesson = create(:lesson, school_class:, user_id: teacher.id) + expect(lesson.school).to eq(lesson.school_class.school) + end + + it 'is not nullified when there is no school_class' do + lesson = create(:lesson, school:, user_id: teacher.id) + expect(lesson.school).not_to eq(lesson.school_class&.school) + end + end + + describe '.users' do + it 'returns User instances for the current scope' do + create(:lesson, user_id: teacher.id) + + user = described_class.all.users.first + expect(user.name).to eq('School Teacher') + end + + it 'ignores members where no profile account exists' do + user_id = SecureRandom.uuid + stub_user_info_api_for_unknown_users(user_id:) + create(:lesson, user_id:) + + user = described_class.all.users.first + expect(user).to be_nil + end + + it 'ignores members not included in the current scope' do + create(:lesson) + + user = described_class.none.users.first + expect(user).to be_nil + end + end + + describe '.with_users' do + it 'returns an array of class members paired with their User instance' do + lesson = create(:lesson, user_id: teacher.id) + + pair = described_class.all.with_users.first + user = described_class.all.users.first + + expect(pair).to eq([lesson, user]) + end + + it 'returns nil values for members where no profile account exists' do + user_id = SecureRandom.uuid + stub_user_info_api_for_unknown_users(user_id:) + lesson = create(:lesson, user_id:) + + pair = described_class.all.with_users.first + expect(pair).to eq([lesson, nil]) + end + + it 'ignores members not included in the current scope' do + create(:lesson) + + pair = described_class.none.with_users.first + expect(pair).to be_nil + end + end + + describe '#with_user' do + it 'returns the class member paired with their User instance' do + lesson = create(:lesson, user_id: teacher.id) + + pair = lesson.with_user + user = described_class.all.users.first + + expect(pair).to eq([lesson, user]) + end + + it 'returns a nil value if the member has no profile account' do + user_id = SecureRandom.uuid + stub_user_info_api_for_unknown_users(user_id:) + lesson = create(:lesson, user_id:) + + pair = lesson.with_user + expect(pair).to eq([lesson, nil]) + end + end + + describe '#archive!' do + let(:lesson) { build(:lesson) } + + it 'archives the lesson' do + lesson.archive! + expect(lesson.archived?).to be(true) + end + + it 'sets archived_at' do + lesson.archive! + expect(lesson.archived_at).to be_present + end + + it 'does not set archived_at if it was already set' do + lesson.update!(archived_at: 1.day.ago) + + lesson.archive! + expect(lesson.archived_at).to be < 23.hours.ago + end + + it 'saves the record' do + lesson.archive! + expect(lesson).to be_persisted + end + + it 'is infallible to other validation errors' do + lesson.save! + lesson.name = ' ' + lesson.save!(validate: false) + + lesson.archive! + expect(lesson.archived?).to be(true) + end + end + + describe '#unarchive!' do + let(:lesson) { build(:lesson, archived_at: Time.now.utc) } + + it 'unarchives the lesson' do + lesson.unarchive! + expect(lesson.archived?).to be(false) + end + + it 'clears archived_at' do + lesson.unarchive! + expect(lesson.archived_at).to be_nil + end + + it 'saves the record' do + lesson.unarchive! + expect(lesson).to be_persisted + end + + it 'is infallible to other validation errors' do + lesson.archive! + lesson.name = ' ' + lesson.save!(validate: false) + + lesson.unarchive! + expect(lesson.archived?).to be(false) + end + end +end diff --git a/spec/models/project_error_spec.rb b/spec/models/project_error_spec.rb new file mode 100644 index 000000000..ba777b4ce --- /dev/null +++ b/spec/models/project_error_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ProjectError do + it { is_expected.to respond_to(:user_id) } + it { is_expected.to respond_to(:error_type) } + + describe 'associations' do + it { is_expected.to belong_to(:project).optional } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:error) } + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 0572db25a..124e52924 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2,22 +2,46 @@ require 'rails_helper' -RSpec.describe Project do +RSpec.describe Project, versioning: true do + let(:school) { create(:school) } + describe 'associations' do - it { is_expected.to have_many(:components) } - it { is_expected.to have_many(:remixes).dependent(:nullify) } + it { is_expected.to belong_to(:school).optional(true) } + it { is_expected.to belong_to(:lesson).optional(true) } it { is_expected.to belong_to(:parent).optional(true) } + it { is_expected.to have_many(:remixes).dependent(:nullify) } + it { is_expected.to have_many(:components) } + it { is_expected.to have_many(:project_errors).dependent(:nullify) } it { is_expected.to have_many_attached(:images) } + it { is_expected.to have_many_attached(:videos) } + it { is_expected.to have_many_attached(:audio) } + it { is_expected.to have_one(:school_project).dependent(:destroy) } it 'purges attached images' do expect(described_class.reflect_on_attachment(:images).options[:dependent]).to eq(:purge_later) end + + it 'purges attached videos' do + expect(described_class.reflect_on_attachment(:videos).options[:dependent]).to eq(:purge_later) + end + + it 'purges attached audio' do + expect(described_class.reflect_on_attachment(:audio).options[:dependent]).to eq(:purge_later) + end end describe 'validations' do let(:project) { create(:project) } let(:identifier) { project.identifier } + it 'has a valid default factory' do + expect(build(:project)).to be_valid + end + + it 'can save the default factory' do + expect { build(:project).save! }.not_to raise_error + end + it 'is invalid if no user or locale' do invalid_project = build(:project, locale: nil, user_id: nil) expect(invalid_project).to be_invalid @@ -28,6 +52,32 @@ expect(valid_project).to be_valid end + it 'is invalid if school_id but no school project' do + invalid_project = build(:project, school_id: SecureRandom.uuid) + expect(invalid_project).to be_invalid + end + + it 'is invalid if school_id and school project with different school_id' do + invalid_project = build(:project, school_id: SecureRandom.uuid, school_project: build(:school_project, school_id: SecureRandom.uuid)) + expect(invalid_project).to be_invalid + end + + it 'is valid if school_id and school project with matching school_id' do + school_id = SecureRandom.uuid + valid_project = build(:project, school_id:, school_project: build(:school_project, school_id:)) + expect(valid_project).to be_valid + end + + it 'is invalid if a school project with lesson and class but user is not class member' do + school = create(:school) + teacher = create(:teacher, school:) + school_class = create(:school_class, school:, teacher_ids: [teacher.id]) + lesson = create(:lesson, school:, school_class:, user_id: teacher.id) + invalid_project = build(:project, school:, lesson:, user_id: SecureRandom.uuid) + + expect(invalid_project).to be_invalid + end + context 'with same identifier and same user as existing project' do let(:user_id) { project.user_id } @@ -55,6 +105,53 @@ expect(new_project).to be_invalid end end + + context 'when the project has a school' do + before do + project.update!(school: create(:school)) + end + + it 'requires that the user that has a role within the school' do + project.user_id = SecureRandom.uuid + expect(project).to be_invalid + end + end + + context 'when the project has a lesson' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + + before do + lesson = create(:lesson, school:, school_class:, user_id: teacher.id) + + project.update!(lesson:, school:, user_id: lesson.user_id, identifier: 'something') + end + + it 'fails if the user is the owner of the lesson' do + project.user_id = SecureRandom.uuid + expect(project).to be_invalid + end + + it 'succeeds if the user is the owner of the lesson' do + expect(project).to be_valid + end + + it 'fails if the user is not a member of the lesson' do + create(:class_student, school_class:, student_id: teacher.id) + + project.user_id = student.id + expect(project).to be_invalid + end + + it 'suceeds if the user is a member of the lesson' do + create(:class_student, school_class:, student_id: student.id) + + project.user_id = student.id + expect(project).to be_invalid + end + end end describe 'check_unique_not_null' do @@ -65,4 +162,161 @@ expect { unsaved_project.valid? }.to change { unsaved_project.identifier.nil? }.from(true).to(false) end end + + describe 'create_school_project_if_needed' do + let(:teacher) { create(:teacher, school:) } + let(:teacher_project) { create(:project, school_id: school.id, user_id: teacher.id) } + let(:project) { create(:project) } + + it 'creates a school project if the project belongs to a school' do + expect(teacher_project.school_project).to be_present + end + + it 'gives the school project the same school_id as the project' do + expect(teacher_project.school_project.school_id).to eq(school.id) + end + + it 'does not create a school project if the project does not belong to a school' do + expect(project.school_project).to be_nil + end + end + + describe '.users' do + let(:student) { create(:student, school:, name: 'School Student') } + let(:teacher) { create(:teacher, school:) } + + let(:student_attributes) do + [{ id: student.id, name: student.name, username: student.username }] + end + + before do + stub_profile_api_list_school_students(school:, student_attributes:) + end + + it 'returns User instances for the current scope' do + create(:project, user_id: student.id, school_id: school.id) + user = described_class.all.users(teacher).first + expect(user.name).to eq('School Student') + end + + it 'ignores members where no profile account exists' do + user_id = SecureRandom.uuid + create(:project, user_id:) + + user = described_class.all.users(teacher).first + expect(user).to be_nil + end + + it 'ignores members not included in the current scope' do + create(:project) + + user = described_class.none.users(teacher).first + expect(user).to be_nil + end + end + + describe '.with_users' do + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + + let(:student_attributes) do + [{ id: student.id, name: student.name, username: student.username }] + end + + before do + stub_profile_api_list_school_students(school:, student_attributes:) + end + + it 'returns an array of class members paired with their User instance' do + project = create(:project, user_id: student.id) + + pair = described_class.all.with_users(teacher).first + user = described_class.all.users(teacher).first + + expect(pair).to eq([project, user]) + end + + it 'returns nil values for members where no profile account exists' do + user_id = SecureRandom.uuid + project = create(:project, user_id:) + + pair = described_class.all.with_users(teacher).first + expect(pair).to eq([project, nil]) + end + + it 'ignores members not included in the current scope' do + create(:project) + + pair = described_class.none.with_users(teacher).first + expect(pair).to be_nil + end + end + + describe '#with_user' do + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + + let(:student_attributes) do + [{ id: student.id, name: student.name, username: student.username }] + end + + before do + stub_profile_api_list_school_students(school:, student_attributes:) + end + + it 'returns the class member paired with their User instance' do + project = create(:project, user_id: student.id) + + pair = project.with_user(teacher) + user = described_class.all.users(teacher).first + + expect(pair).to eq([project, user]) + end + + it 'returns a nil value if the member has no profile account' do + user_id = SecureRandom.uuid + project = create(:project, user_id:) + + pair = project.with_user(teacher) + expect(pair).to eq([project, nil]) + end + end + + describe '#last_edited_at' do + let(:project) { create(:project, updated_at: 1.day.ago) } + let(:component) { create(:component, project:, updated_at: 2.days.ago) } + + it 'returns the project updated_at if most recent' do + expect(project.last_edited_at).to eq(project.updated_at) + end + + it 'returns the latest component updated_at if most recent' do + latest_component = create(:component, project:, updated_at: 1.hour.ago) + expect(project.last_edited_at).to eq(latest_component.updated_at) + end + end + + describe '#media' do + let(:project) { create(:project, :with_attached_image, :with_attached_video, :with_attached_audio) } + + it 'returns all media files' do + expect(project.media).to eq(project.images + project.videos + project.audio) + end + end + + describe 'auditing' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + + it 'enables auditing for projects with a school_id' do + project_with_school = create(:project, user_id: student.id, school_id: school.id) + expect(project_with_school.versions.length).to(eq(1)) + end + + it 'does not enable auditing for projects without a school_id' do + project_without_school = create(:project, school_id: nil) + expect(project_without_school.versions.length).to(eq(0)) + end + end end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb new file mode 100644 index 000000000..4f15b879d --- /dev/null +++ b/spec/models/role_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Role do + describe 'validations', versioning: true do + subject(:role) { build(:role) } + + it 'has a valid default factory' do + expect(role).to be_valid + end + + it 'can save the default factory' do + expect { role.save! }.not_to raise_error + end + + it 'requires a school' do + role.school = nil + expect(role).to be_invalid + end + + it 'requires a user_id' do + role.user_id = nil + expect(role).to be_invalid + end + + it 'requires a role' do + role.role = nil + expect(role).to be_invalid + end + + it 'requires a valid role' do + expect { role.role = 'made-up-role' }.to raise_exception(ArgumentError, /is not a valid role/) + end + + it 'requires role to be unique for the combination of user and school' do + role.save + duplicate_role = build(:role, school: role.school, user_id: role.user_id, role: role.role) + expect(duplicate_role).to be_invalid + end + + it 'enables auditing' do + role.save + expect(role.versions.length).to(eq(1)) + end + + context 'when the student role exists for a user and school' do + let(:user) { build(:user) } + let(:school) { build(:school) } + + before do + create(:student_role, user_id: user.id, school:) + end + + it 'prevents an owner role being created for the user and school' do + role = build(:owner_role, user_id: user.id, school:) + expect(role).to be_invalid + end + + it 'adds a message to explain why the owner role cannot be created' do + role = build(:owner_role, user_id: user.id, school:) + role.valid? + expect(role.errors[:base]).to include('Cannot create owner role as this user already has the student role for this school') + end + + it 'prevents a teacher role being created for the user and school' do + role = build(:teacher_role, user_id: user.id, school:) + expect(role).to be_invalid + end + + it 'adds a message to explain why the teacher role cannot be created' do + role = build(:teacher_role, user_id: user.id, school:) + role.valid? + expect(role.errors[:base]).to include('Cannot create teacher role as this user already has the student role for this school') + end + end + + context 'when the teacher role exists for a user and school' do + let(:user) { build(:user) } + let(:school) { build(:school) } + + before do + create(:teacher_role, user_id: user.id, school:) + end + + it 'allows an owner role to be created for the user and school' do + expect(create(:owner_role, user_id: user.id, school:)).to be_persisted + end + + it 'prevents a student role being created for the user and school' do + role = build(:student_role, user_id: user.id, school:) + expect(role).to be_invalid + end + + it 'adds a message to explain why the student role cannot be created' do + role = build(:student_role, user_id: user.id, school:) + role.valid? + expect(role.errors[:base]).to include('Cannot create student role as this user already has the teacher role for this school') + end + end + + context 'when the owner role exists for a user and school' do + let(:user) { build(:user) } + let(:school) { build(:school) } + + before do + create(:owner_role, user_id: user.id, school:) + end + + it 'allows a teacher role to be created for the user and school' do + expect(create(:teacher_role, user_id: user.id, school:)).to be_persisted + end + + it 'prevents a student role being created for the user and school' do + role = build(:student_role, user_id: user.id, school:) + expect(role).to be_invalid + end + + it 'adds a message to explain why the student role cannot be created' do + role = build(:student_role, user_id: user.id, school:) + role.valid? + expect(role.errors[:base]).to include('Cannot create student role as this user already has the owner role for this school') + end + end + + context 'when the owner and teacher roles exist for a user and school' do + let(:user) { build(:user) } + let(:school) { build(:school) } + + before do + create(:owner_role, user_id: user.id, school:) + create(:teacher_role, user_id: user.id, school:) + end + + it 'prevents a student role being created for the user and school' do + role = build(:student_role, user_id: user.id, school:) + expect(role).to be_invalid + end + + it 'adds a message to explain why the student role cannot be created' do + role = build(:student_role, user_id: user.id, school:) + role.valid? + expect(role.errors[:base]).to include('Cannot create student role as this user already has the owner and teacher roles for this school') + end + end + + context 'when a user has a role within a school' do + let(:user) { build(:user) } + let(:school_1) { build(:school) } + let(:school_2) { build(:school) } + + before do + create(:role, user_id: user.id, school: school_1) + end + + it 'prevents the user from having a role within a different school' do + role = build(:role, user_id: user.id, school: school_2) + expect(role).to be_invalid + end + + it 'adds a message to explain that a user can only have roles within a single school' do + role = build(:role, user_id: user.id, school: school_2) + role.valid? + expect(role.errors[:base]).to include('Cannot create role as this user already has a role in a different school') + end + end + end +end diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb new file mode 100644 index 000000000..3a4428a49 --- /dev/null +++ b/spec/models/school_class_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass, versioning: true do + before do + stub_user_info_api_for_users([teacher.id, second_teacher.id], users: [teacher, second_teacher]) + end + + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let(:second_teacher) { create(:teacher, school:, name: 'Second Teacher') } + let(:school) { create(:school) } + + describe 'associations' do + it { is_expected.to belong_to(:school) } + it { is_expected.to have_many(:students).dependent(:destroy) } + it { is_expected.to have_many(:teachers).dependent(:destroy) } + it { is_expected.to have_many(:lessons).dependent(:nullify) } + end + + describe 'nested attributes' do + it 'accepts nested attributes for teachers' do + school_class_attributes = attributes_for(:school_class).merge( + teachers_attributes: [ + { teacher_id: teacher.id }, + { teacher_id: second_teacher.id } + ] + ) + + school_class = described_class.new(school_class_attributes) + expect(school_class.teachers.map(&:teacher_id)).to eq([teacher.id, second_teacher.id]) + end + end + + describe 'validations' do + subject(:school_class) { build(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) } + + it 'has a valid default factory' do + expect(school_class).to be_valid + end + + it 'can save the default factory' do + expect { school_class.save! }.not_to raise_error + end + + it 'requires a school' do + school_class.school = nil + expect(school_class).to be_invalid + end + + it 'requires teacher_ids' do + school_class_without_teacher = build(:school_class, teacher_ids: [], school:) + expect(school_class_without_teacher).to be_invalid + end + + it 'requires UUID teacher_ids' do + school_class_with_invalid_teacher = build(:school_class, teacher_ids: ['invalid'], school:) + expect(school_class_with_invalid_teacher).to be_invalid + end + + it 'requires a name' do + school_class.name = ' ' + expect(school_class).to be_invalid + end + + it 'assigns class code before validating' do + school_class.code = nil + school_class.valid? + expect(school_class.code).to match(/\d\d-\d\d-\d\d/) + end + + it 'requires a unique class code within the same school' do + school_class.save! + school_class_with_duplicate_code = build(:school_class, school: school_class.school, code: school_class.code) + school_class_with_duplicate_code.valid? + expect(school_class_with_duplicate_code.errors[:code]).to include('has already been taken') + end + + it 'permits a duplicate class code in a different school' do + school_class.save! + school_class_with_duplicate_code = build(:school_class, school: build(:school), code: school_class.code) + expect(school_class_with_duplicate_code).to be_valid + end + + it 'requires a valid class code format' do + school_class.code = 'invalid' + expect(school_class).to be_invalid + end + + it 'accepts a valid class code format' do + school_class.code = '12-34-56' + expect(school_class).to be_valid + end + + it 'does not allow the class code to be changed' do + school_class.code = '12-34-56' + school_class.save! + school_class.code = '65-43-21' + expect(school_class).to be_invalid + end + end + + describe '.teachers' do + it 'returns User instances for the current scope' do + create(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) + + teacher = described_class.all.teachers.first + expect(teacher.name).to eq('School Teacher') + end + + it 'ignores teachers where no profile account exists' do + stub_user_info_api_for_unknown_users(user_id: teacher.id) + create(:school_class, school:, teacher_ids: [teacher.id]) + + teacher = described_class.all.teachers.first + expect(teacher).to be_nil + end + + it 'ignores teachers not included in the current scope' do + create(:school_class, teacher_ids: [teacher.id], school:) + + teacher = described_class.none.teachers.first + expect(teacher).to be_nil + end + end + + describe '.with_teachers' do + it 'returns an array of class teachers paired with their User instance' do + school_class = create(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) + + pair = described_class.all.with_teachers.first + teacher = described_class.all.teachers.first + + expect(pair).to eq([school_class, [teacher, second_teacher]]) + end + + it 'returns nil values for teachers where no profile account exists' do + stub_user_info_api_for_unknown_users(user_id: teacher.id) + school_class = create(:school_class, school:, teacher_ids: [teacher.id]) + + pair = described_class.all.with_teachers.first + expect(pair).to eq([school_class, [nil]]) + end + + it 'ignores teachers not included in the current scope' do + create(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) + + pair = described_class.none.with_teachers.first + expect(pair).to be_nil + end + end + + describe '#with_teachers' do + it 'returns the class teachers paired with their User instances' do + school_class = create(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) + school_class_with_teachers = school_class.with_teachers + + expect(school_class_with_teachers).to eq([school_class, [teacher, second_teacher]]) + end + + it 'skips user if the teacher has no profile account' do + stub_user_info_api_for_unknown_users(user_id: teacher.id) + stub_user_info_api_for(second_teacher) + + school_class = create(:school_class, school:, teacher_ids: [teacher.id, second_teacher.id]) + school_class_with_teachers = school_class.with_teachers + + expect(school_class_with_teachers).to eq([school_class, [second_teacher]]) + end + end + + describe '#teacher_ids' do + it 'returns an array of teacher ids' do + school_class = create(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) + expect(school_class.teacher_ids).to eq([teacher.id, second_teacher.id]) + end + end + + describe '#assign_class_code' do + it 'assigns a class code if not already present' do + school_class = build(:school_class, code: nil, school:) + school_class.assign_class_code + expect(school_class.code).to match(/\d\d-\d\d-\d\d/) + end + + it 'does not assign a class code if already present' do + school_class = build(:school_class, code: '12-34-56', school:) + school_class.assign_class_code + expect(school_class.code).to eq('12-34-56') + end + + it 'retries 5 times if the school code is not unique within the school' do + school_class = create(:school_class, code: '12-34-56', school:) + allow(ForEducationCodeGenerator).to receive(:generate).and_return(*([school_class.code] * 4), '00-00-00') + another_school_class = create(:school_class, school: school_class.school, code: nil) + another_school_class.assign_class_code + expect(another_school_class.code).to eq('00-00-00') + end + + it 'raises adds error if unique code cannot be generated in 5 retries' do + school_class = create(:school_class, code: '12-34-56', school:) + allow(ForEducationCodeGenerator).to receive(:generate).and_return(*([school_class.code] * 5)) + another_school_class = build(:school_class, school: school_class.school, code: nil) + another_school_class.assign_class_code + expect(another_school_class.errors[:code]).to include('could not be generated') + end + + it 'does not add error if class code shared by class from another school' do + school_class = create(:school_class, code: '12-34-56', school:) + allow(ForEducationCodeGenerator).to receive(:generate).and_return(school_class.code) + another_school_class = build(:school_class, school: build(:school), code: nil) + another_school_class.assign_class_code + expect(another_school_class.errors[:code]).to be_empty + end + end + + describe 'auditing' do + subject(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + + it 'enables auditing' do + expect(school_class.versions.length).to(eq(1)) + end + end +end diff --git a/spec/models/school_project_spec.rb b/spec/models/school_project_spec.rb new file mode 100644 index 000000000..54cc3a717 --- /dev/null +++ b/spec/models/school_project_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolProject do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb new file mode 100644 index 000000000..76ac67470 --- /dev/null +++ b/spec/models/school_spec.rb @@ -0,0 +1,432 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School do + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school, creator_id: SecureRandom.uuid) } + + describe 'associations' do + it 'has many classes' do + create(:school_class, school:, teacher_ids: [teacher.id]) + create(:school_class, school:, teacher_ids: [teacher.id]) + expect(school.classes.size).to eq(2) + end + + it 'has many lessons' do + create(:lesson, school:, user_id: teacher.id) + create(:lesson, school:, user_id: teacher.id) + expect(school.lessons.size).to eq(2) + end + + it 'has many projects' do + create(:project, user_id: student.id, school:) + create(:project, user_id: student.id, school:) + expect(school.projects.size).to eq(2) + end + + it 'has many roles' do + Role.delete_all + create(:student_role, school:) + create(:owner_role, school:) + expect(school.roles.size).to eq(2) + end + + context 'when a school is destroyed' do + let!(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let!(:lesson_1) { create(:lesson, user_id: teacher.id, school_class:) } + let!(:lesson_2) { create(:lesson, user_id: teacher.id, school:) } + let!(:project) { create(:project, user_id: student.id, school:) } + let!(:role) { create(:role, school:) } + + before do + create(:class_student, school_class:, student_id: student.id) + end + + it 'also destroys school classes to avoid making them invalid' do + expect { school.destroy! }.to change(SchoolClass, :count).by(-1) + end + + it 'also destroys class students to avoid making them invalid' do + expect { school.destroy! }.to change(ClassStudent, :count).by(-1) + end + + it 'also destroys class teachers to avoid making them invalid' do + expect { school.destroy! }.to change(ClassTeacher, :count).by(-1) + end + + it 'does not destroy lessons' do + expect { school.destroy! }.not_to change(Lesson, :count) + end + + it 'nullifies school_id and school_class_id fields on lessons' do + school.destroy! + + lessons = [lesson_1, lesson_2].map(&:reload) + values = lessons.flat_map { |l| [l.school_id, l.school_class_id] } + + expect(values).to eq [nil, nil, nil, nil] + end + + it 'does not destroy projects' do + expect { school.destroy! }.not_to change(Project, :count) + end + + it 'nullifies the school_id field on projects' do + school.destroy! + expect(project.reload.school_id).to be_nil + end + + it 'does not destroy roles' do + expect { school.destroy! }.not_to change(Role, :count) + end + + it 'nullifies the school_id field on roles' do + school.destroy! + expect(role.reload.school_id).to be_nil + end + end + end + + describe 'validations' do + subject(:school) { create(:school) } + + it 'has a valid default factory' do + expect(school).to be_valid + end + + it 'can save the default factory' do + expect { school.save! }.not_to raise_error + end + + it 'requires a name' do + school.name = ' ' + expect(school).to be_invalid + end + + it 'requires a website' do + school.website = ' ' + expect(school).to be_invalid + end + + it 'requires a creator_id' do + school.creator_id = nil + expect(school).to be_invalid + end + + it 'requires a unique creator_id' do + school.save! + another_school = build(:school, creator_id: school.creator_id) + another_school.valid? + expect(another_school.errors[:creator_id]).to include('has already been taken') + end + + it 'rejects a badly formed url for website' do + school.website = 'http://.example.com/' + expect(school).to be_invalid + end + + it 'accepts a url with a multi-part TLD' do + school.website = 'https://example.co.uk/' + expect(school).to be_valid + end + + it 'does not require a reference' do + create(:school, id: SecureRandom.uuid, reference: nil) + + school.reference = nil + expect(school).to be_valid + end + + it 'requires references to be unique if provided' do + school.reference = 'URN-123' + school.save! + + duplicate_school = build(:school, reference: 'urn-123') + expect(duplicate_school).to be_invalid + end + + it 'requires an address_line_1' do + school.address_line_1 = ' ' + expect(school).to be_invalid + end + + it 'requires a municipality' do + school.municipality = ' ' + expect(school).to be_invalid + end + + it 'requires a country_code' do + school.country_code = ' ' + expect(school).to be_invalid + end + + it "requires an 'ISO 3166-1 alpha-2' country_code" do + school.country_code = 'GBR' + expect(school).to be_invalid + end + + it 'does not require a creator_role' do + school.creator_role = nil + expect(school).to be_valid + end + + it 'does not require a creator_department' do + school.creator_department = nil + expect(school).to be_valid + end + + it 'requires creator_agree_authority to be true' do + school.creator_agree_authority = false + expect(school).to be_invalid + end + + it 'requires creator_agree_terms_and_conditions to be true' do + school.creator_agree_terms_and_conditions = false + expect(school).to be_invalid + end + + it 'requires creator_agree_responsible_safeguarding to be true' do + school.creator_agree_responsible_safeguarding = false + expect(school).to be_invalid + end + + it 'does not require creator_agree_to_ux_contact to be true' do + school.creator_agree_to_ux_contact = false + expect(school).to be_valid + end + + it 'cannot have #rejected_at set when #verified_at is present' do + school.verify! + school.reject + expect(school.errors[:rejected_at]).to include('must be blank') + end + + it 'cannot have #verified_at set when #rejected_at is present' do + school.reject + school.update(verified_at: Time.zone.now) + expect(school.errors[:verified_at]).to include('must be blank') + end + + it "cannot change #verified_at once it's been set" do + school.verify! + school.update(verified_at: nil) + expect(school.errors[:verified_at]).to include('cannot be changed after verification') + end + + it 'requires #code to be unique' do + school.update!(code: '00-00-00', verified_at: Time.current) + another_school = build(:school, code: '00-00-00') + another_school.valid? + expect(another_school.errors[:code]).to include('has already been taken') + end + + it 'requires #code to be set when the school is verified' do + school.update(verified_at: Time.current) + expect(school.errors[:code]).to include("can't be blank") + end + + it 'requires code to be blank until the school is verified' do + school.update(code: 'school-code') + expect(school.errors[:code]).to include('must be blank') + end + + it 'requires code to be formatted as 3 pairs of digits separated by hyphens' do + school.update(code: 'invalid', verified_at: Time.current) + expect(school.errors[:code]).to include('is invalid') + end + + it "cannot change #code once it's been set" do + school.verify! + school.update(code: '00-00-00') + expect(school.errors[:code]).to include('cannot be changed after verification') + end + + it 'requires a user_origin' do + school.user_origin = nil + expect(school).to be_invalid + end + + it 'sets the user_origin to for_education by default' do + expect(school.user_origin).to eq('for_education') + end + end + + describe '#creator' do + let(:creator) { create(:owner, school:) } + + before do + school.update!(creator_id: creator.id) + stub_user_info_api_for(creator) + end + + it 'returns a User instance' do + expect(school.creator).to be_instance_of(User) + end + + it 'returns the creator from the UserInfo API matching the creator_id' do + expect(school.creator.id).to eq(creator.id) + end + end + + describe '.find_for_user!' do + before do + stub_user_info_api_for(teacher) + end + + it 'returns the school that the user has a role in' do + user = User.where(id: teacher.id).first + expect(described_class.find_for_user!(user)).to eq(school) + end + + it "returns the school that the user created if they don't have a role in any school" do + creator = create(:user) + school.update!(creator_id: creator.id) + expect(described_class.find_for_user!(creator)).to eq(school) + end + + it "raises ActiveRecord::RecordNotFound if the user doesn't have a role in a school" do + user = build(:user) + expect { described_class.find_for_user!(user) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe '#verified?' do + it 'returns true when verified_at is present' do + school.verified_at = Time.zone.now + expect(school).to be_verified + end + + it 'returns false when verified_at is blank' do + school.verified_at = nil + expect(school).not_to be_verified + end + end + + describe '#rejected?' do + it 'returns true when rejected_at is present' do + school.rejected_at = Time.zone.now + expect(school).to be_rejected + end + + it 'returns false when rejected_at is blank' do + school.rejected_at = nil + expect(school).not_to be_rejected + end + end + + describe '#verify!' do + it 'sets verified_at to the current time' do + school.verify! + expect(school.verified_at).to be_within(1.second).of(Time.zone.now) + end + + it 'uses the school code generator to generates and set the code' do + allow(ForEducationCodeGenerator).to receive(:generate).and_return('00-00-00') + school.verify! + expect(school.code).to eq('00-00-00') + end + + it 'retries 5 times if the school code is not unique' do + school.verify! + allow(ForEducationCodeGenerator).to receive(:generate).and_return(school.code, school.code, school.code, school.code, '00-00-00') + another_school = create(:school) + another_school.verify! + expect(another_school.code).to eq('00-00-00') + end + + it 'raises exception if unique code cannot be generated in 5 retries' do + school.verify! + allow(ForEducationCodeGenerator).to receive(:generate).and_return(school.code, school.code, school.code, school.code, school.code) + another_school = create(:school) + expect { another_school.verify! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'returns true on successful verification' do + expect(school.verify!).to be(true) + end + + it 'raises ActiveRecord::RecordInvalid if verification fails' do + school.rejected_at = Time.zone.now + expect { school.verify! }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + describe '#format_uk_postal_code' do + it 'retains correctly formatted UK postal_code' do + school.country_code = 'GB' + school.postal_code = 'SW1A 1AA' + school.save + expect(school.postal_code).to eq('SW1A 1AA') + end + + it 'corrects incorrectly formatted UK postal_code' do + school.country_code = 'GB' + school.postal_code = 'SW1 A1AA' + expect { school.save }.to change(school, :postal_code).to('SW1A 1AA') + end + + it 'formats UK postal_code with 4 char outcode' do + school.country_code = 'GB' + school.postal_code = 'SW1A1AA' + expect { school.save }.to change(school, :postal_code).to('SW1A 1AA') + end + + it 'formats UK postal_code with 3 char outcode' do + school.country_code = 'GB' + school.postal_code = 'SW11AA' + expect { school.save }.to change(school, :postal_code).to('SW1 1AA') + end + + it 'formats UK postal_code with 2 char outcode' do + school.country_code = 'GB' + school.postal_code = 'SW1AA' + expect { school.save }.to change(school, :postal_code).to('SW 1AA') + end + + it 'does not format UK postal_code for short / invalid codes' do + school.country_code = 'GB' + school.postal_code = 'SW1A' + expect { school.save }.not_to change(school, :postal_code) + end + + it 'does not format postal_code for non-UK countries' do + school.country_code = 'FR' + school.postal_code = '123456' + expect { school.save }.not_to change(school, :postal_code) + end + end + + describe '#reject' do + it 'sets rejected_at to the current time' do + school.reject + expect(school.rejected_at).to be_within(1.second).of(Time.zone.now) + end + + it 'returns true on successful rejection' do + expect(school.reject).to be(true) + end + + it 'returns false on unsuccessful rejection' do + school.verified_at = Time.zone.now + expect(school.reject).to be(false) + end + end + + describe '#reopen' do + it 'sets rejected_at to nil' do + school.reopen + expect(school.rejected_at).to be_nil + end + + it 'returns true on successful reopening' do + expect(school.reopen).to be(true) + end + + it 'returns false on unsuccessful reopening' do + school.verified_at = Time.zone.now + expect(school.reopen).to be(false) + end + end +end diff --git a/spec/models/teacher_invitation_spec.rb b/spec/models/teacher_invitation_spec.rb new file mode 100644 index 000000000..d77a33762 --- /dev/null +++ b/spec/models/teacher_invitation_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeacherInvitation do + include ActionMailer::TestHelper + include ActiveSupport::Testing::TimeHelpers + + it 'has a valid factory' do + invitation = build(:teacher_invitation) + + expect(invitation).to be_valid + end + + it 'is invalid with an incorrectly formatted email address' do + invitation = build(:teacher_invitation, email_address: 'not-an-email-address') + + expect(invitation).not_to be_valid + end + + it 'is invalid with an unverified school' do + school = build(:school, verified_at: nil) + invitation = build(:teacher_invitation, school:) + + expect(invitation).not_to be_valid + end + + it 'sends an invitation email after create' do + school = create(:verified_school) + + invitation = described_class.create!(email_address: 'teacher@example.com', school:) + + assert_enqueued_email_with InvitationMailer, :invite_teacher, params: { invitation: } + end + + it 'generates a token for teacher invitation' do + invitation = create(:teacher_invitation) + token = invitation.generate_token_for(:teacher_invitation) + + expect(described_class.find_by_token_for(:teacher_invitation, token)).to eq(invitation) + end + + it 'generates a token valid for 30 days' do + invitation = create(:teacher_invitation) + token = invitation.generate_token_for(:teacher_invitation) + + travel 31.days do + expect(described_class.find_by_token_for(:teacher_invitation, token)).to be_nil + end + end + + it 'invalidates the token if the email address changes' do + invitation = create(:teacher_invitation) + token = invitation.generate_token_for(:teacher_invitation) + + invitation.update(email_address: 'new-email@example.com') + + expect(described_class.find_by_token_for(:teacher_invitation, token)).to be_nil + end + + it 'delegates #school_name to School#name' do + school = build(:school, name: 'school-name') + invitation = build(:teacher_invitation, school:) + + expect(invitation.school_name).to eq('school-name') + end + + it 'non-deterministically encrypts the email_address' do + school = create(:verified_school) + described_class.create!(email_address: 'teacher@example.com', school:) + + expect(described_class.find_by(email_address: 'teacher@example.com')).to be_nil + end +end diff --git a/spec/models/user_job_spec.rb b/spec/models/user_job_spec.rb new file mode 100644 index 000000000..eb4ecd60f --- /dev/null +++ b/spec/models/user_job_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserJob do + it { is_expected.to belong_to(:good_job) } + it { is_expected.to validate_presence_of(:user_id) } +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..ce42c44b3 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,420 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User do + subject(:user) { build(:user) } + + let(:school) { create(:school) } + let(:organisation_id) { school.id } + + it { is_expected.to respond_to(:id) } + it { is_expected.to respond_to(:name) } + it { is_expected.to respond_to(:email) } + + describe '.from_userinfo' do + subject(:users) { described_class.from_userinfo(ids:) } + + let(:owner) { create(:owner, school:, name: 'School Owner', email: 'school-owner@example.com') } + let(:ids) { [owner.id] } + let(:user) { users.first } + + before do + stub_user_info_api_for(owner) + end + + it 'returns an Array' do + expect(users).to be_an Array + end + + it 'returns an array of instances of the described class' do + expect(user).to be_a described_class + end + + it 'returns a user with the correct ID' do + expect(user.id).to eq ids.first + end + + it 'returns a user with the correct name' do + expect(user.name).to eq 'School Owner' + end + + it 'returns a user with the correct email' do + expect(user.email).to eq 'school-owner@example.com' + end + end + + describe '.from_token' do + subject(:user) { described_class.from_token(token: UserProfileMock::TOKEN) } + + context 'when logged into a full account' do + let(:owner) { create(:owner, school:, name: 'School Owner', email: 'school-owner@example.com') } + + before do + authenticated_in_hydra_as(owner) + end + + it 'returns an instance of the described class' do + expect(user).to be_a described_class + end + + it 'returns a user with the correct ID' do + expect(user.id).to eq owner.id + end + + it 'returns a user with the correct name' do + expect(user.name).to eq owner.name + end + + it 'returns a user with the correct email' do + expect(user.email).to eq 'school-owner@example.com' + end + + it 'returns a user without a username' do + expect(user.username).to be_nil + end + + context 'when BYPASS_OAUTH is true' do + around do |example| + ClimateControl.modify(BYPASS_OAUTH: 'true') do + example.run + end + end + + it 'does not call the API' do + user + expect(WebMock).not_to have_requested(:get, /.*/) + end + + it 'returns a stubbed user' do + expect(user.name).to eq('School Owner') + end + end + end + + context 'when logged into a student account' do + let(:student) { create(:student, school:, name: 'School Student') } + + before do + authenticated_in_hydra_as(student, :student) + end + + it 'returns an instance of the described class' do + expect(user).to be_a described_class + end + + it 'returns a user with the correct ID' do + expect(user.id).to eq student.id + end + + it 'returns a user with the correct name' do + expect(user.name).to eq student.name + end + + it 'returns a user with the correct username' do + expect(user.username).to eq student.username + end + + it 'returns a user without an email' do + expect(user.email).to be_nil + end + end + + context 'when the access token is invalid' do + before do + allow(Sentry).to receive(:capture_exception) + stub_request(:get, "#{HydraPublicApiClient::API_URL}/userinfo").to_return(status: 401) + end + + it 'returns nil' do + expect(user).to be_nil + end + + it 'reports the Faraday::UnauthorizedError exception to Sentry' do + user + expect(Sentry).to have_received(:capture_exception).with(instance_of(Faraday::UnauthorizedError)) + end + end + end + + describe '.from_omniauth' do + subject(:auth_subject) { described_class.from_omniauth(auth) } + + let(:id) { 'f80ba5b2-2eee-457d-9f75-872b5c09be84' } + let(:info_without_organisations) do + { + 'id' => id, + 'email' => 'john.doe@example.com', + 'name' => 'John Doe', + 'roles' => 'school-student' + } + end + let(:info) { info_without_organisations } + let(:user) { described_class.new(info) } + let(:credentials) { { token: 'token' } } + + let(:auth) do + OmniAuth::AuthHash.new( + { + provider: 'rpi', + uid: id, + extra: { + raw_info: info + }, + credentials: + } + ) + end + + it 'returns a User object' do + expect(auth_subject).to be_a described_class + end + + it 'returns a user with the correct ID' do + expect(auth_subject.id).to eq id + end + + it 'returns a user with the correct name' do + expect(auth_subject.name).to eq 'John Doe' + end + + it 'returns a user with the access token supplied in credentials' do + expect(auth_subject.token).to eq 'token' + end + + it 'returns a user with the correct email' do + expect(user.email).to eq 'john.doe@example.com' + end + + context 'with unusual keys in info' do + let(:info) { { foo: :bar, flibble: :woo } } + + it { is_expected.to be_a described_class } + end + + context 'with no info' do + let(:info) { nil } + + it { is_expected.to be_a described_class } + end + + context 'with no auth set' do + let(:auth) { nil } + + it { is_expected.to be_nil } + end + + context 'with no credentials set' do + let(:credentials) { nil } + + it 'returns a user with no token' do + expect(auth_subject.token).to be_nil + end + end + end + + describe '#school_owner?' do + subject(:user) { create(:user) } + + let(:school) { create(:school) } + + it 'returns true when the user has the owner role for this school' do + create(:owner_role, school:, user_id: user.id) + expect(user).to be_school_owner(school) + end + + it 'returns false when the user does not have the owner role for this school' do + create(:teacher_role, school:, user_id: user.id) + expect(user).not_to be_school_owner(school) + end + end + + describe '#school_teacher?' do + subject(:user) { create(:user) } + + let(:school) { create(:school) } + + it 'returns true when the user has the teacher role for this school' do + create(:teacher_role, school:, user_id: user.id) + expect(user).to be_school_teacher(school) + end + + it 'returns false when the user does not have the teacher role for this school' do + create(:owner_role, school:, user_id: user.id) + expect(user).not_to be_school_teacher(school) + end + end + + describe '#school_student?' do + subject(:user) { create(:user) } + + let(:school) { create(:school) } + + it 'returns true when the user has the student role for this school' do + create(:student_role, school:, user_id: user.id) + expect(user).to be_school_student(school) + end + + it 'returns false when the user does not have the student role for this school' do + create(:owner_role, school:, user_id: user.id) + expect(user).not_to be_school_student(school) + end + end + + describe '#student?' do + subject(:user) { create(:user) } + + let(:school) { create(:school) } + + it 'returns true when the user has a student role' do + create(:student_role, school:, user_id: user.id) + expect(user).to be_student + end + + it 'returns false when the user does not have a student role' do + create(:owner_role, school:, user_id: user.id) + expect(user).not_to be_student + end + end + + describe '#parsed_roles' do + it 'returns array of role names when roles is set to comma-separated string' do + user = build(:user, roles: 'role-1,role-2') + expect(user.parsed_roles).to eq(%w[role-1 role-2]) + end + + it 'strips leading & trailing spaces from role names' do + user = build(:user, roles: ' role-1 , role-2 ') + expect(user.parsed_roles).to eq(%w[role-1 role-2]) + end + + it 'returns empty array when roles is set to empty string' do + user = build(:user, roles: '') + expect(user.parsed_roles).to eq([]) + end + + it 'returns empty array when roles is set to nil' do + user = build(:user, roles: nil) + expect(user.parsed_roles).to eq([]) + end + end + + describe '#admin?' do + it 'returns true if the user has the editor-admin role in Hydra' do + user = build(:user, roles: 'editor-admin') + expect(user).to be_admin + end + + it 'returns false if the user does not have the editor-admin role in Hydra' do + user = build(:user, roles: 'another-editor-admin') + expect(user).not_to be_admin + end + end + + describe '#experience_cs_admin?' do + it 'returns true if the user has the experience-cs-admin role in Hydra' do + user = build(:experience_cs_admin_user) + expect(user).to be_experience_cs_admin + end + + it 'returns false if the user does not have the experience-cs-admin role in Hydra' do + user = build(:user, roles: 'another-admin') + expect(user).not_to be_experience_cs_admin + end + end + + describe '#school_roles' do + subject(:user) { build(:user) } + + let(:school) { create(:school) } + + context 'when the user has no roles' do + it 'returns an empty array if the user has no role in this school' do + expect(user.school_roles(school)).to be_empty + end + end + + context 'when the user has an organisation and roles' do + before do + create(:role, school:, user_id: user.id, role: 'owner') + create(:role, school:, user_id: user.id, role: 'teacher') + end + + it 'returns an array of the roles the user has at the school' do + expect(user.school_roles(school)).to match_array(%w[owner teacher]) + end + end + end + + describe '.where' do + subject(:user) { described_class.where(id: owner.id).first } + + let(:owner) { create(:owner, school:, name: 'School Owner', email: 'school-owner@example.com') } + + before do + stub_user_info_api_for(owner) + end + + it 'returns an instance of the described class' do + expect(user).to be_a described_class + end + + it 'returns a user with the correct ID' do + expect(user.id).to eq owner.id + end + + it 'returns a user with the correct name' do + expect(user.name).to eq 'School Owner' + end + + it 'returns a user with the correct email' do + expect(user.email).to eq 'school-owner@example.com' + end + + context 'when BYPASS_OAUTH is true' do + around do |example| + ClimateControl.modify(BYPASS_OAUTH: 'true') do + example.run + end + end + + let(:owner) { create(:owner, school:, id: '00000000-0000-0000-0000-000000000000') } + + it 'does not call the API' do + user + expect(WebMock).not_to have_requested(:get, /.*/) + end + + it 'returns a stubbed user' do + expect(user.name).to eq('School Owner') + end + end + end + + describe '#schools' do + it 'includes schools where the user has the owner role' do + create(:owner_role, school:, user_id: user.id) + expect(user.schools).to eq([school]) + end + + it 'includes schools where the user has the teacher role' do + create(:teacher_role, school:, user_id: user.id) + expect(user.schools).to eq([school]) + end + + it 'includes schools where the user has the student role' do + create(:student_role, school:, user_id: user.id) + expect(user.schools).to eq([school]) + end + + it 'does not include schools where the user has no role' do + expect(user.schools).to be_empty + end + + it 'only includes a school once even if the user has multiple roles' do + create(:owner_role, school:, user_id: user.id) + create(:teacher_role, school:, user_id: user.id) + expect(user.schools).to eq([school]) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index cf3ba2d54..37006a75a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -8,6 +8,7 @@ require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../config/environment', __dir__) +ENV['BYPASS_OAUTH'] = nil # Ensure we don't bypass auth in tests # Prevent database truncation if the environment is production abort('The Rails environment is running in production mode!') if Rails.env.production? @@ -18,6 +19,8 @@ Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } +require 'paper_trail/frameworks/rspec' + # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are # run as spec files by default. This means that files in spec/support that end @@ -42,9 +45,6 @@ exit 1 end RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') - # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false # instead of true. @@ -83,12 +83,52 @@ config.include GraphqlQueryHelpers, type: :graphql_query config.include PhraseIdentifierMock - config.include HydraAdminApiMock, type: :request + config.include ProfileApiMock + config.include UserProfileMock + + config.include SignInStubs, type: :request + config.include SignInStubs, type: :system if Bullet.enable? config.before { Bullet.start_request } config.after { Bullet.end_request } end + + config.before(:each, type: :system) do + driven_by :rack_test + end + + config.around(type: :task) do |example| + DatabaseCleaner.clean_with(:truncation) + DatabaseCleaner.strategy = :transaction + + DatabaseCleaner.cleaning do + Rails.application.load_tasks + example.run + Rake::Task.clear + end + end + + config.before(:suite) do + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).first + puts "Running tests in environment: #{Rails.env}" + puts "Running tests against the database: #{db_config.database}" + end + + config.before(:each, js: true, type: :system) do + # We need to allow net connect at this stage to allow WebDrivers to update + # or Capybara to talk to selenium etc. + WebMock.allow_net_connect! + + # Ensure we update the driver here, while we can connect to the network + Webdrivers::Geckodriver.update + driven_by :selenium_headless, using: :firefox + + # Need to set the hostname, otherwise it defaults to www.example.com. + default_url_options[:host] = Capybara.server_host + + WebMock.disable_net_connect!(allow_localhost: true, allow: Capybara.server_host) + end end Shoulda::Matchers.configure do |config| diff --git a/spec/requests/graphql_spec.rb b/spec/requests/graphql_spec.rb index 5084b2bf6..050e1455f 100644 --- a/spec/requests/graphql_spec.rb +++ b/spec/requests/graphql_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'POST /graphql' do subject(:request) { post(graphql_path, as: :json, params:, headers:) } - let(:headers) { nil } + let(:headers) { { Origin: 'editor.com' } } let(:params) { nil } before do @@ -27,9 +27,9 @@ end shared_examples 'an unidentified request' do - it 'sets the current_user_id as nil in the context' do + it 'sets the current_user as nil in the context' do request - expect(EditorApiSchema).to have_received(:execute).with(anything, hash_including(context: hash_including(current_user_id: nil))) + expect(EditorApiSchema).to have_received(:execute).with(anything, hash_including(context: hash_including(current_user: nil))) end end @@ -58,32 +58,38 @@ end context 'when an Authorization header is supplied' do - let(:headers) { { Authorization: token } } + let(:headers) { { Authorization: token, Origin: 'editor.com' } } let(:token) { '' } it_behaves_like 'an unidentified request' context 'with a token' do - let(:token) { 'valid-token' } + let(:token) { UserProfileMock::TOKEN } context 'when the token is invalid' do before do - stub_fetch_oauth_user_id(nil) + unauthenticated_in_hydra end it_behaves_like 'an unidentified request' end context 'when the token is valid' do - let(:current_user_id) { SecureRandom.uuid } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } before do - stub_fetch_oauth_user_id(current_user_id) + authenticated_in_hydra_as(owner) end - it 'sets the current_user_id in the context' do + it 'sets the current_user in the context' do request - expect(EditorApiSchema).to have_received(:execute).with(anything, hash_including(context: hash_including(current_user_id:))) + expect(EditorApiSchema).to have_received(:execute).with(anything, hash_including(context: hash_including(current_user: authenticated_user))) + end + + it 'sets the request origin from the headers' do + request + expect(EditorApiSchema).to have_received(:execute).with(anything, hash_including(context: hash_including(remix_origin: 'editor.com'))) end end end diff --git a/spec/requests/project_errors_spec.rb b/spec/requests/project_errors_spec.rb new file mode 100644 index 000000000..02a7a8048 --- /dev/null +++ b/spec/requests/project_errors_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Create project error requests' do + let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } + let(:project) { create(:project, user_id:) } + let(:error) { 'some random test error message' } + let(:status) { :created } + let(:error_type) { nil } + + let(:params) do + { + error:, + error_type:, + user_id:, + project_id: project.identifier + } + end + + let(:expected_body) do + { + error:, + error_type:, + user_id:, + project_id: project.id + } + end + + before do + post('/api/project_errors', params:) + end + + shared_examples 'upload error' do + it 'creates an error' do + json_response = JSON.parse(response.body, symbolize_names: true)[:data] + expect(json_response).to include(expected_body) + end + + it 'returns the correct status code' do + expect(response).to have_http_status(status) + end + end + + shared_examples 'invalid request' do + it 'returns an empty body data object' do + json_response = JSON.parse(response.body, symbolize_names: true)[:data] + expect(json_response).to eq([]) + end + + it 'returns bad request status code' do + expect(response).to have_http_status(:bad_request) + end + end + + describe 'with a user and project' do + context 'with a valid error param' do + it_behaves_like 'upload error' + end + + context 'without an error param' do + let(:params) do + { + user_id:, + project_id: project.identifier + } + end + + it_behaves_like 'invalid request' + end + end + + describe 'without a user and project' do + let(:error_type) { nil } + let(:params) do + { + error:, + error_type: + } + end + + let(:expected_body) do + { + error:, + error_type: + } + end + + context 'with a valid error param' do + it_behaves_like 'upload error' + end + + context 'with a valid error and error_type' do + let(:error_type) { 'TestError' } + + it_behaves_like 'upload error' + end + + context 'without an error param' do + let(:params) { {} } + + it_behaves_like 'invalid request' + end + end + + describe 'with an unknown project' do + let(:params) do + { + error:, + error_type:, + project: 'some-made-up-slug' + } + end + + let(:expected_body) do + { + error:, + error_type: + } + end + + context 'with a valid error param' do + it_behaves_like 'upload error' + end + + context 'with a valid error and error_type' do + let(:error_type) { 'TestError' } + + it_behaves_like 'upload error' + end + end +end diff --git a/spec/requests/projects/create_spec.rb b/spec/requests/projects/create_spec.rb index fa1f23a72..45e420fbf 100644 --- a/spec/requests/projects/create_spec.rb +++ b/spec/requests/projects/create_spec.rb @@ -3,15 +3,16 @@ require 'rails_helper' RSpec.describe 'Create project requests' do - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } - let(:project) { create(:project, user_id:) } + let(:project) { create(:project, user_id: authenticated_user.id) } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } context 'when auth is correct' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } context 'when creating project is successful' do before do - stub_fetch_oauth_user_id(user_id) + authenticated_in_hydra_as(owner) response = OperationResponse.new response[:project] = project @@ -21,13 +22,13 @@ it 'returns success' do post('/api/projects', headers:) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:created) end end context 'when creating project fails' do before do - stub_fetch_oauth_user_id(user_id) + authenticated_in_hydra_as(owner) response = OperationResponse.new response[:error] = 'Error creating project' @@ -37,7 +38,7 @@ it 'returns error' do post('/api/projects', headers:) - expect(response).to have_http_status(:internal_server_error) + expect(response).to have_http_status(:unprocessable_entity) end end end diff --git a/spec/requests/projects/destroy_spec.rb b/spec/requests/projects/destroy_spec.rb index ffaed1425..feef49dc3 100644 --- a/spec/requests/projects/destroy_spec.rb +++ b/spec/requests/projects/destroy_spec.rb @@ -3,14 +3,14 @@ require 'rails_helper' RSpec.describe 'Project delete requests' do - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } - context 'when user is logged in' do - let!(:project) { create(:project, user_id:, locale: nil) } - let(:headers) { { Authorization: 'dummy-token' } } + let!(:project) { create(:project, user_id: owner.id, locale: nil) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } before do - stub_fetch_oauth_user_id(user_id) + authenticated_in_hydra_as(owner) end context 'when deleting a project the user owns' do @@ -36,6 +36,29 @@ expect(response).to have_http_status(:forbidden) end end + + context 'when an Experience CS admin destroys a starter Scratch project' do + let(:project) do + create( + :project, { + project_type: Project::Types::SCRATCH, + user_id: nil, + locale: 'en' + } + ) + end + let(:experience_cs_admin) { create(:experience_cs_admin_user) } + + before do + authenticated_in_hydra_as(experience_cs_admin) + end + + it 'deletes the project' do + expect do + delete("/api/projects/#{project.identifier}", headers:) + end.to change(Project, :count).by(-1) + end + end end context 'when no token is given' do diff --git a/spec/requests/projects/images_spec.rb b/spec/requests/projects/images_spec.rb index d656e0675..481caf28d 100644 --- a/spec/requests/projects/images_spec.rb +++ b/spec/requests/projects/images_spec.rb @@ -3,8 +3,7 @@ require 'rails_helper' RSpec.describe 'Images requests' do - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } - let(:project) { create(:project, user_id:) } + let(:project) { create(:project, user_id: owner.id) } let(:image_filename) { 'test_image_1.png' } let(:params) { { images: [fixture_file_upload(image_filename, 'image/png')] } } let(:expected_json) do @@ -17,13 +16,15 @@ ] }.to_json end + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } describe 'create' do context 'when auth is correct' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_fetch_oauth_user_id(project.user_id) + authenticated_in_hydra_as(owner) end it 'attaches file to project' do @@ -50,10 +51,12 @@ end context 'when authed user is not creator' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } before do - stub_fetch_oauth_user_id(SecureRandom.uuid) + teacher = create(:teacher, school:) + authenticated_in_hydra_as(teacher) end it 'returns forbidden response' do diff --git a/spec/requests/projects/index_spec.rb b/spec/requests/projects/index_spec.rb index 5a8bd673f..bde800cee 100644 --- a/spec/requests/projects/index_spec.rb +++ b/spec/requests/projects/index_spec.rb @@ -5,19 +5,20 @@ RSpec.describe 'Project index requests' do include PaginationLinksMock - let(:headers) { { Authorization: 'dummy-token' } } - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:project_keys) { %w[identifier project_type name user_id updated_at] } + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } before do - create_list(:project, 2, user_id:) + create_list(:project, 2, user_id: owner.id) end context 'when user is logged in' do before do # create non user projects create_list(:project, 2) - stub_fetch_oauth_user_id(user_id) + authenticated_in_hydra_as(owner) end it 'returns success response' do @@ -34,7 +35,7 @@ it 'returns users projects' do get('/api/projects', headers:) returned = response.parsed_body - expect(returned.all? { |proj| proj['user_id'] == user_id }).to be(true) + expect(returned.all? { |proj| proj['user_id'] == authenticated_user.id }).to be(true) end it 'returns all keys in response' do @@ -46,8 +47,8 @@ context 'when the projects index has pagination' do before do - create_list(:project, 10, user_id:) - stub_fetch_oauth_user_id(user_id) + authenticated_in_hydra_as(owner) + create_list(:project, 10, user_id: authenticated_user.id) end it 'returns the default number of projects on the first page' do diff --git a/spec/requests/projects/remix_spec.rb b/spec/requests/projects/remix_spec.rb index 34c3ee629..6e9c9d676 100644 --- a/spec/requests/projects/remix_spec.rb +++ b/spec/requests/projects/remix_spec.rb @@ -4,7 +4,6 @@ RSpec.describe 'Remix requests' do let!(:original_project) { create(:project) } - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } let(:project_params) do { name: original_project.name, @@ -12,58 +11,117 @@ components: [] } end + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } before do mock_phrase_generation end context 'when auth is correct' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) do + { + Authorization: UserProfileMock::TOKEN, + Origin: 'editor.com' + } + end before do - stub_fetch_oauth_user_id(user_id) + authenticated_in_hydra_as(owner) end - it 'returns success response' do - post("/api/projects/#{original_project.identifier}/remix", params: { project: project_params }, headers:) + describe '#index' do + before do + create_list(:project, 2, remixed_from_id: original_project.id, user_id: authenticated_user.id) + end - expect(response).to have_http_status(:ok) - end + it 'returns success response' do + get("/api/projects/#{original_project.identifier}/remixes", headers:) + expect(response).to have_http_status(:ok) + end + + it 'returns the list of projects' do + get("/api/projects/#{original_project.identifier}/remixes", headers:) + expect(response.parsed_body.length).to eq(2) + end - it 'returns 404 response if invalid project' do - project_params[:identifier] = 'no-such-project' - post('/api/projects/no-such-project/remix', params: { project: project_params }, headers:) + it 'returns 404 response if invalid project' do + get('/api/projects/no-such-project/remixes', headers:) - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:not_found) + end end - context 'when project can not be saved' do + describe '#show' do before do - stub_fetch_oauth_user_id(user_id) - error_response = OperationResponse.new - error_response[:error] = 'Something went wrong' - allow(Project::CreateRemix).to receive(:call).and_return(error_response) + create(:project, remixed_from_id: original_project.id, user_id: authenticated_user.id) end - it 'returns 400' do - post("/api/projects/#{original_project.identifier}/remix", params: { project: project_params }, headers:) + it 'returns success response' do + get("/api/projects/#{original_project.identifier}/remix", headers:) - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:ok) end - it 'returns error message' do + it 'returns 404 response if invalid project' do + get('/api/projects/no-such-project/remix', headers:) + + expect(response).to have_http_status(:not_found) + end + end + + describe '#create' do + it 'returns success response' do post("/api/projects/#{original_project.identifier}/remix", params: { project: project_params }, headers:) - expect(response.body).to eq({ error: 'Something went wrong' }.to_json) + expect(response).to have_http_status(:ok) + end + + it 'returns 404 response if invalid project' do + project_params[:identifier] = 'no-such-project' + post('/api/projects/no-such-project/remix', params: { project: project_params }, headers:) + + expect(response).to have_http_status(:not_found) + end + + context 'when project cannot be saved' do + before do + authenticated_in_hydra_as(owner) + error_response = OperationResponse.new + error_response[:error] = 'Something went wrong' + allow(Project::CreateRemix).to receive(:call).and_return(error_response) + end + + it 'returns 400' do + post("/api/projects/#{original_project.identifier}/remix", params: { project: project_params }, headers:) + + expect(response).to have_http_status(:bad_request) + end + + it 'returns error message' do + post("/api/projects/#{original_project.identifier}/remix", params: { project: project_params }, headers:) + + expect(response.body).to eq({ error: 'Something went wrong' }.to_json) + end end end end context 'when auth is invalid' do - it 'returns unauthorized' do - post "/api/projects/#{original_project.identifier}/remix" + describe '#show' do + it 'returns unauthorized' do + get "/api/projects/#{original_project.identifier}/remix" + + expect(response).to have_http_status(:unauthorized) + end + end + + describe '#create' do + it 'returns unauthorized' do + post "/api/projects/#{original_project.identifier}/remix" - expect(response).to have_http_status(:unauthorized) + expect(response).to have_http_status(:unauthorized) + end end end end diff --git a/spec/requests/projects/show_context_spec.rb b/spec/requests/projects/show_context_spec.rb new file mode 100644 index 000000000..dfedf1171 --- /dev/null +++ b/spec/requests/projects/show_context_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Project context requests' do + let(:headers) { {} } + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + + context 'when user is a teacher' do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + before do + authenticated_in_hydra_as(teacher) + stub_profile_api_list_school_students(school:, student_attributes: [{ name: 'Joe Bloggs' }]) + end + + context 'when loading own project context' do + let!(:project) { create(:project, :with_instructions, school:, lesson:, user_id: teacher.id, locale: nil) } + let(:project_context_json) do + { + identifier: project.identifier, + project_type: project.project_type, + school_id: project.school_id, + lesson_id: project.lesson_id, + class_id: project.lesson.school_class_id + }.to_json + end + + it 'returns success response' do + get("/api/projects/#{project.identifier}/context", headers:) + + expect(response).to have_http_status(:ok) + end + + it 'returns json' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response.content_type).to eq('application/json; charset=utf-8') + end + + it 'returns the project json' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response.body).to eq(project_context_json) + end + end + + context 'when loading another teacher\'s project context in a class where user is a teacher' do + before do + stub_user_info_api_for_users([teacher.id, another_teacher.id], users: [teacher, another_teacher]) + end + + let(:another_teacher) { create(:teacher, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id, another_teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: another_teacher.id, visibility: 'teachers') } + let(:another_teacher_project) { create(:project, :with_instructions, school:, lesson:, user_id: another_teacher.id, locale: nil) } + let(:another_teacher_project_context_json) do + { + identifier: another_teacher_project.identifier, + project_type: another_teacher_project.project_type, + school_id: another_teacher_project.school_id, + lesson_id: another_teacher_project.lesson_id, + class_id: another_teacher_project.lesson.school_class_id + }.to_json + end + + it 'returns success response' do + get("/api/projects/#{another_teacher_project.identifier}/context", headers:) + expect(response).to have_http_status(:ok) + end + + it 'returns the project json' do + get("/api/projects/#{another_teacher_project.identifier}/context", headers:) + expect(response.body).to eq(another_teacher_project_context_json) + end + end + + context 'when loading another user\'s project context' do + let!(:another_project) { create(:project, user_id: SecureRandom.uuid, locale: nil) } + let(:another_project_json) do + { + identifier: another_project.identifier + }.to_json + end + + it 'returns forbidden response' do + get("/api/projects/#{another_project.identifier}", headers:) + + expect(response).to have_http_status(:forbidden) + end + + it 'does not return the project json' do + get("/api/projects/#{another_project.identifier}", headers:) + expect(response.body).not_to include(another_project_json) + end + end + end + + context 'when user is a student' do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:student) { create(:student, school:) } + let!(:project) { create(:project, :with_instructions, school:, lesson:, user_id: teacher.id, locale: nil) } + let(:project_context_json) do + { + identifier: project.identifier, + project_type: project.project_type, + school_id: project.school_id, + lesson_id: project.lesson_id, + class_id: project.lesson.school_class_id + }.to_json + end + + before do + authenticated_in_hydra_as(student) + end + + context 'when student is in the class' do + before do + create(:class_student, school_class:, student_id: student.id) + end + + context 'when loading context of a lesson project that is visible to students' do + it 'returns success response' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response).to have_http_status(:ok) + end + + it 'returns json' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response.content_type).to eq('application/json; charset=utf-8') + end + + it 'returns the project context json' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response.body).to eq(project_context_json) + end + end + + context 'when loading context of a lesson project that is not visible to students' do + before do + project.lesson.update(visibility: 'teachers') + end + + it 'returns forbidden response' do + get("/api/projects/#{project.identifier}/context", headers:) + + expect(response).to have_http_status(:forbidden) + end + + it 'does not return the project context json' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response.body).not_to include(project_context_json) + end + end + end + + context 'when student is not in the class' do + it 'returns forbidden response' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'does not return the project context json' do + get("/api/projects/#{project.identifier}/context", headers:) + expect(response.body).not_to include(project_context_json) + end + end + end +end diff --git a/spec/requests/projects/show_spec.rb b/spec/requests/projects/show_spec.rb index 1336a28ad..d14a09c26 100644 --- a/spec/requests/projects/show_spec.rb +++ b/spec/requests/projects/show_spec.rb @@ -3,28 +3,35 @@ require 'rails_helper' RSpec.describe 'Project show requests' do - let!(:project) { create(:project, locale: nil) } - let(:project_json) do - { - identifier: project.identifier, - project_type: 'python', - locale: project.locale, - name: project.name, - user_id: project.user_id, - components: [], - image_list: [] - }.to_json - end let(:headers) { {} } + let(:teacher) { create(:teacher, school:) } + let(:school) { create(:school) } context 'when user is logged in' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_fetch_oauth_user_id(project.user_id) + authenticated_in_hydra_as(teacher) + stub_profile_api_list_school_students(school:, student_attributes: [{ name: 'Joe Bloggs' }]) end context 'when loading own project' do + let!(:project) { create(:project, :with_instructions, school:, user_id: teacher.id, locale: nil) } + let(:project_json) do + { + identifier: project.identifier, + project_type: Project::Types::PYTHON, + locale: project.locale, + name: project.name, + user_id: project.user_id, + instructions: project.instructions, + components: [], + image_list: [], + videos: [], + audio: [] + }.to_json + end + it 'returns success response' do get("/api/projects/#{project.identifier}", headers:) @@ -40,19 +47,97 @@ get("/api/projects/#{project.identifier}", headers:) expect(response.body).to eq(project_json) end + + it 'does not include the finished boolean in the project json' do + get("/api/projects/#{project.identifier}", headers:) + expect(response.parsed_body).not_to have_key('finished') + end + end + + context 'when loading a student\'s project' do + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } + let(:teacher_project) { create(:project, :with_instructions, school_id: school.id, lesson_id: lesson.id, user_id: teacher.id, locale: nil) } + let(:student_project) { create(:project, school_id: school.id, lesson_id: nil, user_id: create(:student, school:).id, remixed_from_id: teacher_project.id, locale: nil, instructions: teacher_project.instructions) } + let(:student_project_json) do + { + identifier: student_project.identifier, + project_type: Project::Types::PYTHON, + locale: student_project.locale, + name: student_project.name, + user_id: student_project.user_id, + instructions: student_project.instructions, + parent: { + name: teacher_project.name, + identifier: teacher_project.identifier + }, + components: [], + image_list: [], + videos: [], + audio: [], + user_name: 'Joe Bloggs' + }.to_json + end + + it 'returns success response' do + get("/api/projects/#{student_project.identifier}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'includes the expected parameters in the project json' do + get("/api/projects/#{student_project.identifier}", headers:) + expect(response.body).to eq(student_project_json) + end + end + + context 'when loading another teacher\'s project in a class where user is a teacher' do + before do + stub_user_info_api_for_users([teacher.id, another_teacher.id], users: [teacher, another_teacher]) + end + + let(:another_teacher) { create(:teacher, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id, another_teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: another_teacher.id, visibility: 'teachers') } + let(:another_teacher_project) { create(:project, :with_instructions, school:, lesson:, user_id: another_teacher.id, locale: nil) } + let(:another_teacher_project_json) do + { + identifier: another_teacher_project.identifier, + project_type: Project::Types::PYTHON, + locale: another_teacher_project.locale, + name: another_teacher_project.name, + user_id: teacher.id, + instructions: another_teacher_project.instructions, + components: [], + image_list: [], + videos: [], + audio: [] + }.to_json + end + + it 'returns success response' do + get("/api/projects/#{another_teacher_project.identifier}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'returns the project json' do + get("/api/projects/#{another_teacher_project.identifier}", headers:) + expect(response.body).to eq(another_teacher_project_json) + end end context 'when loading another user\'s project' do - let!(:another_project) { create(:project, locale: nil) } + let!(:another_project) { create(:project, user_id: SecureRandom.uuid, locale: nil) } let(:another_project_json) do { identifier: another_project.identifier, - project_type: 'python', + project_type: Project::Types::PYTHON, name: another_project.name, locale: another_project.locale, user_id: another_project.user_id, components: [], - image_list: [] + image_list: [], + videos: [], + audio: [] }.to_json end @@ -71,16 +156,20 @@ context 'when user is not logged in' do context 'when loading a starter project' do - let!(:starter_project) { create(:project, user_id: nil, locale: 'ja-JP') } + let(:project_type) { Project::Types::PYTHON } + let!(:starter_project) { create(:project, user_id: nil, locale: 'ja-JP', project_type:) } let(:starter_project_json) do { identifier: starter_project.identifier, - project_type: 'python', + project_type:, locale: starter_project.locale, name: starter_project.name, user_id: starter_project.user_id, + instructions: nil, components: [], - image_list: [] + image_list: [], + videos: [], + audio: [] }.to_json end @@ -113,6 +202,21 @@ end context 'when loading an owned project' do + let!(:project) { create(:project, user_id: teacher.id, locale: nil) } + let(:project_json) do + { + identifier: project.identifier, + project_type: Project::Types::PYTHON, + locale: project.locale, + name: project.name, + user_id: project.user_id, + components: [], + image_list: [], + videos: [], + audio: [] + }.to_json + end + it 'returns forbidden response' do get("/api/projects/#{project.identifier}", headers:) diff --git a/spec/requests/projects/update_spec.rb b/spec/requests/projects/update_spec.rb index d51af4f68..c20a48ae2 100644 --- a/spec/requests/projects/update_spec.rb +++ b/spec/requests/projects/update_spec.rb @@ -3,12 +3,10 @@ require 'rails_helper' RSpec.describe 'Project update requests' do - let(:headers) { { Authorization: 'dummy-token' } } - let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } - let(:project) { create(:project, user_id:, locale: nil) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } context 'when authed user is project creator' do - let(:project) { create(:project, :with_default_component, locale: nil) } + let!(:project) { create(:project, :with_default_component, user_id: owner.id, locale: nil) } let!(:component) { create(:component, project:) } let(:default_component_params) do project.components.first.attributes.symbolize_keys.slice( @@ -18,17 +16,21 @@ :extension ) end + let(:owner) { create(:owner, school:) } + let(:school) { create(:school) } let(:params) do { project: - { components: [ - default_component_params, - { id: component.id, name: 'updated', extension: 'py', content: 'updated component content' } - ] } } + { + components: [ + default_component_params, + { id: component.id, name: 'updated', extension: 'py', content: 'updated component content' } + ] + } } end before do - stub_fetch_oauth_user_id(project.user_id) + authenticated_in_hydra_as(owner) end it 'returns success response' do @@ -57,7 +59,7 @@ expect(response).to have_http_status(:ok) end - it 'returns json with updated project properties' do + it 'returns json with updated project name' do put("/api/projects/#{project.identifier}", params:, headers:) expect(response.body).to include('updated project name') end @@ -68,12 +70,21 @@ end end - context 'when update is invalid' do + context 'when updated project has no components' do let(:params) { { project: { components: [] } } } it 'returns error response' do put("/api/projects/#{project.identifier}", params:, headers:) - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when updated (non-school) project has instructions' do + let(:params) { { project: { instructions: 'updated instructions' } } } + + it 'returns error response' do + put("/api/projects/#{project.identifier}", params:, headers:) + expect(response).to have_http_status(:unprocessable_entity) end end end @@ -81,9 +92,11 @@ context 'when authed user is not creator' do let(:project) { create(:project, locale: nil) } let(:params) { { project: { components: [] } } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } before do - stub_fetch_oauth_user_id(SecureRandom.uuid) + authenticated_in_hydra_as(owner) end it 'returns forbidden response' do @@ -92,11 +105,56 @@ end end + context 'when authed user is a teacher' do + let(:project) { create(:project, :with_instructions, school:, locale: nil, user_id: teacher.id) } + let(:params) { { project: { components: [], instructions: 'updated instructions' } } } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + + before do + authenticated_in_hydra_as(teacher) + end + + it 'returns success when instructions updated' do + put("/api/projects/#{project.identifier}", params:, headers:) + expect(response).to have_http_status(:ok) + end + + it 'includes updated instructions in response' do + put("/api/projects/#{project.identifier}", params:, headers:) + expect(response.body).to include('updated instructions') + end + end + + context 'when authed user is a student and the project is remixed from a lesson project' do + let(:teacher) { create(:teacher, school:) } + let(:lesson_project) { create(:project, school:, locale: nil, user_id: teacher.id, lesson: create(:lesson, visibility: 'students')) } + let(:project) { create(:project, school:, locale: nil, user_id: student.id, remixed_from_id: lesson_project.id) } + let(:params) { { project: { components: [] } } } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + + before do + authenticated_in_hydra_as(student) + end + + it 'returns success if instructions not updated' do + put("/api/projects/#{project.identifier}", params:, headers:) + expect(response).to have_http_status(:ok) + end + + it 'returns unprocessable entity if instructions updated' do + params[:project][:instructions] = 'updated instructions' + put("/api/projects/#{project.identifier}", params:, headers:) + expect(response).to have_http_status(:unprocessable_entity) + end + end + context 'when auth token is invalid' do let(:project) { create(:project) } before do - allow(HydraAdminApi).to receive(:fetch_oauth_user_id).and_return(nil) + unauthenticated_in_hydra end it 'returns unauthorized' do diff --git a/spec/requests/school_projects/set_finished_spec.rb b/spec/requests/school_projects/set_finished_spec.rb new file mode 100644 index 000000000..f30f52fc7 --- /dev/null +++ b/spec/requests/school_projects/set_finished_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'School project finished requests' do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + let(:lesson) { build(:lesson, school:, user_id: teacher.id, visibility: 'students') } + let(:teacher_project) { create(:project, school_id: school.id, lesson_id: lesson.id, user_id: teacher.id, locale: nil) } + let(:school_project_json) do + { + id: student_project.school_project.id, + school_id: student_project.school_project.school_id, + project_id: student_project.school_project.project_id, + finished: student_project.school_project.finished, + identifier: student_project.identifier + }.to_json + end + + before do + authenticated_in_hydra_as(student) + stub_profile_api_list_school_students(school:, student_attributes: [{ name: 'Joe Bloggs' }]) + end + + context 'when the finished flag is initially false' do + let!(:student_project) { create(:project, school_id: school.id, lesson_id: nil, user_id: student.id, remixed_from_id: teacher_project.id, locale: nil, finished: false) } + + before do + put("/api/projects/#{student_project.identifier}/finished", headers:, params: { finished: true }) + student_project.reload + end + + it 'returns success response' do + expect(response).to have_http_status(:ok) + end + + it 'returns the school project json' do + expect(response.body).to eq(school_project_json) + end + + it 'sets the completed flag to true' do + expect(student_project.school_project.finished).to be_truthy + end + end + + context 'when the finished flag is initially true' do + let!(:student_project) { create(:project, school_id: school.id, lesson_id: nil, user_id: student.id, remixed_from_id: teacher_project.id, locale: nil, finished: true) } + + before do + put("/api/projects/#{student_project.identifier}/finished", headers:, params: { finished: false }) + student_project.reload + end + + it 'returns success response' do + expect(response).to have_http_status(:ok) + end + + it 'returns the school project json' do + expect(response.body).to eq(school_project_json) + end + + it 'sets the completed flag to false' do + expect(student_project.school_project.finished).to be_falsey + end + end + + context 'when the user does not own the project' do + before do + put("/api/projects/#{teacher_project.identifier}/finished", headers:, params: { finished: true }) + teacher_project.reload + end + + it 'returns forbidden response' do + expect(response).to have_http_status(:forbidden) + end + + it 'does not change the finished flag' do + expect(teacher_project.school_project.finished).to be_falsey + end + end +end diff --git a/spec/requests/school_projects/show_finished_spec.rb b/spec/requests/school_projects/show_finished_spec.rb new file mode 100644 index 000000000..9f187c486 --- /dev/null +++ b/spec/requests/school_projects/show_finished_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'School project finished requests' do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + let(:lesson) { build(:lesson, school:, user_id: teacher.id, visibility: 'students') } + let(:teacher_project) { create(:project, school_id: school.id, lesson_id: lesson.id, user_id: teacher.id, locale: nil) } + let(:student_project) { create(:project, school_id: school.id, lesson_id: nil, user_id: student.id, remixed_from_id: teacher_project.id, locale: nil, finished: true) } + let(:school_project_json) do + { + id: student_project.school_project.id, + school_id: student_project.school_project.school_id, + project_id: student_project.school_project.project_id, + finished: student_project.school_project.finished, + identifier: student_project.identifier + }.to_json + end + + before do + stub_profile_api_list_school_students(school:, student_attributes: [{ name: 'Joe Bloggs' }]) + end + + context 'when the user is a student' do + before do + authenticated_in_hydra_as(student) + end + + context 'when user owns project' do + before do + get("/api/projects/#{student_project.identifier}/finished", headers:) + end + + it 'returns success response' do + expect(response).to have_http_status(:ok) + end + + it 'returns response containing correct school project data' do + expect(response.body).to eq(school_project_json) + end + end + + context 'when user does not own project' do + before do + get("/api/projects/#{teacher_project.identifier}/finished", headers:) + end + + it 'returns forbidden response' do + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/services/school_verification_service_spec.rb b/spec/services/school_verification_service_spec.rb new file mode 100644 index 000000000..28c4dd3ae --- /dev/null +++ b/spec/services/school_verification_service_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolVerificationService do + let(:website) { 'http://example.com/' } + let(:school) { build(:school, creator_id: school_creator.id, website:) } + let(:user) { create(:user) } + let(:school_creator) { create(:user) } + let(:service) { described_class.new(school) } + let(:organisation_id) { SecureRandom.uuid } + let(:token) { 'token' } + + before do + allow(ProfileApiClient).to receive(:create_school) + end + + describe '#verify' do + describe 'when school can be saved' do + it 'saves the school' do + service.verify(token:) + expect(school).to be_persisted + end + + it 'sets verified_at to a date' do + service.verify(token:) + expect(school.reload.verified_at).to be_a(ActiveSupport::TimeWithZone) + end + + it 'generates school code' do + service.verify(token:) + expect(school.reload.code).to be_present + end + + it 'grants the creator the owner role for the school' do + service.verify(token:) + expect(school_creator).to be_school_owner(school) + end + + it 'grants the creator the teacher role for the school' do + service.verify(token:) + expect(school_creator).to be_school_teacher(school) + end + + it 'creates the school in Profile API' do + service.verify(token:) + expect(ProfileApiClient).to have_received(:create_school).with(token:, id: school.id, code: school.code) + end + + it 'returns true' do + expect(service.verify(token:)).to be(true) + end + end + + describe 'when school cannot be saved' do + let(:website) { 'invalid' } + + it 'does not save the school' do + service.verify(token:) + expect(school).not_to be_persisted + end + + it 'does not create owner role' do + service.verify(token:) + expect(school_creator).not_to be_school_owner(school) + end + + it 'does not create teacher role' do + service.verify(token:) + expect(school_creator).not_to be_school_teacher(school) + end + + it 'does not create school in Profile API' do + expect(ProfileApiClient).not_to have_received(:create_school) + end + + it 'returns false' do + expect(service.verify(token:)).to be(false) + end + end + + describe 'when the school cannot be created in Profile API' do + before do + allow(ProfileApiClient).to receive(:create_school).and_raise(RuntimeError) + end + + it 'does not save the school' do + service.verify(token:) + expect { school.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'does not create owner role' do + service.verify(token:) + expect(school_creator).not_to be_school_owner(school) + end + + it 'does not create teacher role' do + service.verify(token:) + expect(school_creator).not_to be_school_teacher(school) + end + + it 'does not create school in Profile API' do + expect(ProfileApiClient).not_to have_received(:create_school) + end + + it 'returns false' do + expect(service.verify(token:)).to be(false) + end + end + + describe 'when teacher and owner roles cannot be created because they already have a role in another school' do + let(:another_school) { create(:school) } + + before do + create(:role, user_id: school.creator_id, school: another_school) + end + + it 'does not save the school' do + service.verify(token:) + expect { school.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'does not create owner role' do + service.verify(token:) + expect(school_creator).not_to be_school_owner(school) + end + + it 'does not create teacher role' do + service.verify(token:) + expect(school_creator).not_to be_school_teacher(school) + end + + it 'does not create school in Profile API' do + expect(ProfileApiClient).not_to have_received(:create_school) + end + + it 'returns false' do + expect(service.verify(token:)).to be(false) + end + end + end + + describe '#reject' do + before do + service.reject + school.reload + end + + it 'sets verified_at to nil' do + expect(school.verified_at).to be_nil + end + + it 'sets rejected_at to a date' do + expect(school.rejected_at).to be_a(ActiveSupport::TimeWithZone) + end + end + + describe 'when the school was previously verified' do + before do + service.verify(token:) + service.reject + school.reload + end + + it 'does not reset verified_at' do + expect(school.verified_at).to be_present + end + + it 'does not set rejected_at' do + expect(school.rejected_at).to be_nil + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7db67711e..1cc5b2a0d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -53,7 +53,7 @@ # # is tagged with `:focus`, all examples get run. RSpec also provides # # aliases for `it`, `describe`, and `context` that include `:focus` # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - # config.filter_run_when_matching :focus + config.filter_run_when_matching :focus # # # Allows RSpec to persist some state between runs in order to support # # the `--only-failures` and `--next-failure` CLI options. We recommend diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..838e2839a --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'capybara/rspec' + +Capybara.configure do |config| + config.server = :puma, { Silent: true } + config.always_include_port = true + # This is where the server will listen. We use this same `server_host` when + # making requests in our browser etc. + config.server_host = ENV.fetch('http://github.com/HOSTNAME') +end diff --git a/spec/support/graphql_query_helpers.rb b/spec/support/graphql_query_helpers.rb index fce2fb012..1b470ecbf 100644 --- a/spec/support/graphql_query_helpers.rb +++ b/spec/support/graphql_query_helpers.rb @@ -6,10 +6,17 @@ def execute_query(query:, context: query_context, variables: {}) end def query_context - if defined? current_user_id - { current_user_id:, current_ability: Ability.new(current_user_id) } + context = + if defined?(current_user) + { current_user:, current_ability: Ability.new(current_user) } + else + { current_user: nil, current_ability: Ability.new(nil) } + end + + if defined?(remix_origin) + { **context, remix_origin: } else - { current_user_id: nil, current_ability: Ability.new(nil) } + { **context, remix_origin: nil } end end end diff --git a/spec/support/hydra_admin_api_mock.rb b/spec/support/hydra_admin_api_mock.rb deleted file mode 100644 index 6b17f519c..000000000 --- a/spec/support/hydra_admin_api_mock.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -require 'hydra_admin_api' - -module HydraAdminApiMock - def stub_fetch_oauth_user_id(user_id) - allow(HydraAdminApi).to receive(:fetch_oauth_user_id).and_return(user_id) - end -end diff --git a/spec/support/omniauth.rb b/spec/support/omniauth.rb new file mode 100644 index 000000000..64aa3855a --- /dev/null +++ b/spec/support/omniauth.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +OmniAuth.config.test_mode = true diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb new file mode 100644 index 000000000..04c056e45 --- /dev/null +++ b/spec/support/profile_api_mock.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module ProfileApiMock + # TODO: Replace with WebMock HTTP stubs once the profile API has been built. + + def stub_profile_api_list_school_owners(user_id:) + allow(ProfileApiClient).to receive(:list_school_owners).and_return(ids: [user_id]) + end + + def stub_profile_api_invite_school_owner + allow(ProfileApiClient).to receive(:invite_school_owner) + end + + def stub_profile_api_remove_school_owner + allow(ProfileApiClient).to receive(:remove_school_owner) + end + + def stub_profile_api_remove_school_teacher + allow(ProfileApiClient).to receive(:remove_school_teacher) + end + + def stub_profile_api_list_school_students(school:, student_attributes:) + now = Time.current.to_fs(:iso8601) # rubocop:disable Naming/VariableNumber + + students = student_attributes.map do |student_attrs| + ProfileApiClient::Student.new( + schoolId: school.id, + id: student_attrs[:id], + username: student_attrs[:username], + name: student_attrs[:name], + createdAt: now, updatedAt: now, discardedAt: nil + ) + end + + allow(ProfileApiClient).to receive(:list_school_students).and_return(students) + end + + def stub_profile_api_create_school_student(user_id: SecureRandom.uuid) + allow(ProfileApiClient).to receive(:create_school_student).and_return(created: [user_id]) + end + + def stub_profile_api_create_school_students(user_ids: [SecureRandom.uuid]) + allow(ProfileApiClient).to receive(:create_school_students).and_return(created: [user_ids.join(', ')]) + end + + def stub_profile_api_create_school_students_validation_error + # 13/11/24: Response from profile from this request: + # { + # "school_students": [ + # { + # "username": "student-to-create", + # "password": "Password", + # "name": "" + # }, + # { + # "username": "student-to-create", + # "password": "Student2024", + # "name": "Password" + # }, + # { + # "username": "another-student-to-create-2", + # "password": "Pass", + # "name": "" + # } + # ] + # } + allow(ProfileApiClient).to receive(:create_school_students).and_raise( + ProfileApiClient::Student422Error.new( + [{ 'path' => '0.username', 'errorCode' => 'isUniqueInBatch', 'message' => 'Username must be unique in the batch data', 'location' => 'body', 'username' => 'student-to-create' }, + { 'path' => '0.password', 'errorCode' => 'isComplex', 'message' => 'Password is too simple (it should not be easily guessable, need password help?)', 'location' => 'body', 'username' => 'student-to-create' }, + { 'path' => '0.name', 'errorCode' => 'notEmpty', 'message' => 'Validation notEmpty on name failed', 'location' => 'body', 'username' => 'student-to-create' }, + { 'path' => '1.username', 'errorCode' => 'isUniqueInBatch', 'message' => 'Username must be unique in the batch data', 'location' => 'body', 'username' => 'student-to-create' }, + { 'path' => '2.password', 'errorCode' => 'minLength', 'message' => 'Password must be at least 8 characters', 'location' => 'body', 'username' => 'another-student-to-create-2' }, + { 'path' => '2.name', 'errorCode' => 'notEmpty', 'message' => 'Validation notEmpty on name failed', 'location' => 'body', 'username' => 'another-student-to-create-2' }] + ) + ) + end + + def stub_profile_api_update_school_student + allow(ProfileApiClient).to receive(:update_school_student) + end + + def stub_profile_api_delete_school_student + allow(ProfileApiClient).to receive(:delete_school_student) + end + + def stub_profile_api_create_safeguarding_flag + allow(ProfileApiClient).to receive(:create_safeguarding_flag) + end +end diff --git a/spec/support/sign_in_stubs.rb b/spec/support/sign_in_stubs.rb new file mode 100644 index 000000000..8305ee15b --- /dev/null +++ b/spec/support/sign_in_stubs.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module SignInStubs + # Use this method if you don't want to bother going through the login process + # itself. + # rubocop:disable RSpec/AnyInstance + def stub_sign_in(user) + allow_any_instance_of(AuthenticationHelper).to receive(:current_user).and_return(user) + end + # rubocop:enable RSpec/AnyInstance + + def stub_auth_for(user) + OmniAuth.config.add_mock(:rpi, uid: user.id, extra: { raw_info: user.serializable_hash(except: :id) }) + end + + # This method goes through the login process properly. In system specs, you + # need to have visited the page with the "Log in" link before calling this. + # In request specs, we just post directly to `/auth/rpi`, so this can be + # called without any prep. + def sign_in(user) + stub_auth_for(user) + + # This is a bit grotty, but see if we can call `find_link` (from Capybara, + # i.e. system specs) first, and then if that fails fall back to using + # `post` which is available in request specs. + begin + find_button('Log in', match: :first).click + rescue NoMethodError + post '/auth/rpi' + follow_redirect! + end + end +end diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb new file mode 100644 index 000000000..c694d158c --- /dev/null +++ b/spec/support/user_profile_mock.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module UserProfileMock + TOKEN = 'fake-user-access-token' + + def stub_user_info_api_for_unknown_users(user_id:) + stub_user_info_api(user_id:, users: []) + end + + def stub_user_info_api_for(user, user_type = nil) + stub_user_info_api(user_id: user.id, users: [user_to_hash(user, user_type)]) + end + + def authenticated_in_hydra_as(user, user_type = nil) + stub_hydra_public_api(user_to_hash(user, user_type, :sub)) + stub_user_info_api_for(user, user_type) + end + + def unauthenticated_in_hydra + stub_hydra_public_api({}) + end + + def authenticated_user + User.from_token(token: TOKEN) + end + + private + + def user_to_hash(user, user_type, id_field = :id) + { + id_field => user_type ? "#{user_type}:#{user.id}" : user.id, + name: user.name, + email: user.email, + username: user.username, + roles: user.roles + } + end + + # Stubs the API that returns user profile data for the logged in user. + def stub_hydra_public_api(user) + stub_request(:get, "#{HydraPublicApiClient::API_URL}/userinfo") + .with(headers: { Authorization: "Bearer #{TOKEN}" }) + .to_return( + status: 200, + headers: { content_type: 'application/json' }, + body: user.to_json + ) + end + + def stub_user_info_api(user_id:, users:) + stub_request(:get, "#{UserInfoApiClient::API_URL}/users") + .with(headers: { Authorization: "Bearer #{UserInfoApiClient::API_KEY}" }, body: /#{user_id}/) + .to_return({ body: { users: }.to_json, headers: { 'Content-Type' => 'application/json' } }) + end + + # Stubs the api to accept multiple user ids and return multiple users + def stub_user_info_api_for_users(user_ids, users:) + stub_request(:get, "#{UserInfoApiClient::API_URL}/users") + .with(headers: { Authorization: "Bearer #{UserInfoApiClient::API_KEY}" }, body: { userIds: user_ids }.to_json) + .to_return({ body: { users: }.to_json, headers: { 'Content-Type' => 'application/json' } }) + end +end diff --git a/spec/support/webdrivers.rb b/spec/support/webdrivers.rb new file mode 100644 index 000000000..c16ef57bd --- /dev/null +++ b/spec/support/webdrivers.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require 'webdrivers' + +Selenium::WebDriver.logger.ignore(:browser_options) diff --git a/spec/system/auth/new_spec.rb b/spec/system/auth/new_spec.rb new file mode 100644 index 000000000..232d5ae2c --- /dev/null +++ b/spec/system/auth/new_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Authenticate user' do + let(:path) { '/admin' } + + context 'when a admin user is logged in' do + let!(:user) { create(:admin_user) } + + before do + stub_sign_in(user) + end + + describe 'Able to access dashboard' do + it 'remains on the admin path' do + visit path + expect(page).to have_current_path(path) + end + end + end + + context 'when a non-admin user is logged in' do + let!(:factory) { build(:user) } + + before do + stub_sign_in(factory) + end + + describe 'Unable to access dashboard' do + it 'redirects to the root path' do + visit path + expect(page).to have_current_path(root_path) + end + + it 'shows an error message' do + visit path + expect(page).to have_text('Not authorized.') + end + end + end +end diff --git a/spec/tasks/create_starter_spec.rb b/spec/tasks/create_starter_spec.rb deleted file mode 100644 index 02614d5bc..000000000 --- a/spec/tasks/create_starter_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require 'project_importer' - -Rails.application.load_tasks - -describe 'projects:create_starter', type: :task do - subject { task.execute } - - let(:project_config) { { 'NAME' => 'My amazing project', 'IDENTIFIER' => 'my-amazing-project', 'TYPE' => 'python' } } - - it 'runs' do - allow(YAML).to receive(:safe_load).and_return(project_config) - allow(File).to receive(:read).and_return('print("hello")') - expect { Rake::Task['projects:create_starter'].invoke }.not_to raise_error - end -end